Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/funny-humans-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"posthog": minor
"posthog-android": patch
"posthog-android-surveys-compose": minor
---

Support skipSubmitButton for survey rating and single-choice questions. The Compose renderer submits eligible selections immediately and hides the submit button; multiple-choice questions and single-choice questions with an open option retain explicit submission.
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ jobs:
if: needs.detect-markdown-only.outputs.markdown_only != 'true'
run: make compile

- name: Test survey interactions
if: needs.detect-markdown-only.outputs.markdown_only != 'true'
run: make testSurveyUI

- name: Check release tasks and dependency locks
if: needs.detect-markdown-only.outputs.markdown_only != 'true'
run: make checkRelease
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: clean compile stop checkFormat format api dryRelease release testReport test testJava generateLintBaseLine checkRelease updateLocks
.PHONY: clean compile stop checkFormat format api dryRelease release testReport test testJava generateLintBaseLine checkRelease updateLocks testSurveyUI

clean:
./gradlew clean
Expand Down Expand Up @@ -63,6 +63,10 @@ testReport:
test:
./gradlew testDebugUnitTest

# Mounted Compose tests need the debug-only test host, which CI builds normally skip.
testSurveyUI:
CI=false ./gradlew :posthog-android-surveys-compose:testDebugUnitTest

# compile already runs the tests (tests only java)
testJava:
./gradlew :posthog:test
Expand Down
8 changes: 8 additions & 0 deletions posthog-android-surveys-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ android {
}
}

testOptions {
unitTests.isIncludeAndroidResources = true
}

buildFeatures {
compose = true
}
Expand Down Expand Up @@ -91,6 +95,10 @@ dependencies {
// tests
testImplementation("junit:junit:${PosthogBuildConfig.Dependencies.ANDROIDX_JUNIT}")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit:${PosthogBuildConfig.Kotlin.KOTLIN}")
testImplementation("androidx.test.ext:junit:${PosthogBuildConfig.Dependencies.ANDROIDX_JUNIT}")
testImplementation("org.robolectric:robolectric:${PosthogBuildConfig.Dependencies.ROBOLECTRIC}")
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
}

project.publishingAndroidConfig()
Expand Down
65 changes: 58 additions & 7 deletions posthog-android-surveys-compose/gradle.lockfile

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import com.posthog.surveys.PostHogDisplayChoiceQuestion
import com.posthog.surveys.PostHogDisplaySurveyAppearance
import com.posthog.surveys.PostHogDisplaySurveyTextContentType

internal val PostHogDisplayChoiceQuestion.shouldAutoSubmit: Boolean
get() = skipSubmitButton && !isMultipleChoice && !hasOpenChoice

/**
* Single-choice list renderer for [PostHogDisplayChoiceQuestion]s where
* [PostHogDisplayChoiceQuestion.isMultipleChoice] is `false`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,26 +237,34 @@ private fun RatingQuestionDispatch(
) {
var rating by rememberSaveable(question.id) { mutableStateOf<Int?>(null) }
val canSubmit = question.isOptional || rating != null
val onRatingSelected: (Int?) -> Unit = { value ->
rating = value
if (question.skipSubmitButton && value != null) {
onSubmit(PostHogSurveyResponse.Rating(value))
}
}

QuestionHeader(question)
if (question.ratingType == PostHogDisplaySurveyRatingType.EMOJI) {
EmojiRating(
question = question,
selectedValue = rating,
onSelect = { rating = it },
onSelect = onRatingSelected,
)
} else {
NumberRating(
question = question,
selectedValue = rating,
onSelect = { rating = it },
onSelect = onRatingSelected,
)
}
if (!question.skipSubmitButton) {
BottomSection(
label = question.buttonText ?: localAppearance().submitButtonText,
enabled = canSubmit,
onClick = { onSubmit(PostHogSurveyResponse.Rating(rating)) },
)
}
BottomSection(
label = question.buttonText ?: localAppearance().submitButtonText,
enabled = canSubmit,
onClick = { onSubmit(PostHogSurveyResponse.Rating(rating)) },
)
}

@Composable
Expand Down Expand Up @@ -313,18 +321,25 @@ private fun SingleChoiceQuestionDispatch(
SingleChoice(
question = question,
selectedChoice = selected,
onSelectedChoiceChange = { selected = it },
onSelectedChoiceChange = { value ->
selected = value
if (question.shouldAutoSubmit && value != null) {
onSubmit(PostHogSurveyResponse.SingleChoice(value))
}
},
openChoiceInput = openInput,
onOpenChoiceInputChange = { openInput = it },
)
BottomSection(
label = question.buttonText ?: localAppearance().submitButtonText,
enabled = canSubmit,
onClick = {
val response = if (hasOpenChoiceSelected) openInput.trim() else selected
onSubmit(PostHogSurveyResponse.SingleChoice(response))
},
)
if (!question.shouldAutoSubmit) {
BottomSection(
label = question.buttonText ?: localAppearance().submitButtonText,
enabled = canSubmit,
onClick = {
val response = if (hasOpenChoiceSelected) openInput.trim() else selected
onSubmit(PostHogSurveyResponse.SingleChoice(response))
},
)
}
}

@Composable
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.posthog.android.surveys.compose.internal.ui

import com.posthog.surveys.PostHogDisplayChoiceQuestion
import com.posthog.surveys.PostHogDisplaySurveyTextContentType
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import kotlin.test.assertEquals

@RunWith(Parameterized::class)
internal class SurveyAutoSubmitTest(
private val enabled: Boolean,
private val multiple: Boolean,
private val openChoice: Boolean,
private val expected: Boolean,
) {
@Test
fun `only single choice without an open option auto-submits`() {
val question =
PostHogDisplayChoiceQuestion(
id = "question", question = "Choose", questionDescription = null,
questionDescriptionContentType = PostHogDisplaySurveyTextContentType.TEXT,
isOptional = false, buttonText = null, choices = listOf("First", "Other"),
hasOpenChoice = openChoice, shuffleOptions = false, isMultipleChoice = multiple,
skipSubmitButton = enabled,
)
assertEquals(expected, question.shouldAutoSubmit)
}
Comment thread
lucasheriques marked this conversation as resolved.

companion object {
@JvmStatic
@Parameterized.Parameters(name = "enabled={0}, multiple={1}, open={2}")
fun cases(): List<Array<Boolean>> =
listOf(
arrayOf(true, false, false, true),
arrayOf(true, false, true, false),
arrayOf(true, true, false, false),
arrayOf(true, true, true, false),
arrayOf(false, false, false, false),
arrayOf(false, false, true, false),
arrayOf(false, true, false, false),
arrayOf(false, true, true, false),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package com.posthog.android.surveys.compose.internal.ui

import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.hasClickAction
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import com.posthog.surveys.PostHogDisplayChoiceQuestion
import com.posthog.surveys.PostHogDisplayRatingQuestion
import com.posthog.surveys.PostHogDisplaySurvey
import com.posthog.surveys.PostHogDisplaySurveyAppearance
import com.posthog.surveys.PostHogDisplaySurveyQuestion
import com.posthog.surveys.PostHogDisplaySurveyRatingType
import com.posthog.surveys.PostHogDisplaySurveyTextContentType
import com.posthog.surveys.PostHogNextSurveyQuestion
import com.posthog.surveys.PostHogSurveyResponse
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.ParameterizedRobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals

@RunWith(ParameterizedRobolectricTestRunner::class)
@Config(sdk = [28])
internal class SurveyAutoSubmitInteractionTest(
private val kind: String,
private val enabled: Boolean,
) {
@get:Rule
val compose = createComposeRule()

@Test
fun `selection submits once and follows branching without carrying selection to the next question`() {
val responses = mutableListOf<Pair<Int, PostHogSurveyResponse>>()
var closes = 0
val survey =
PostHogDisplaySurvey(
id = "interaction-survey",
name = "Interaction survey",
questions =
listOf(
question("First question", enabled),
question("Skipped question", false),
question("Last question", false),
),
appearance = PostHogDisplaySurveyAppearance(displayThankYouMessage = false),
)
compose.setContent {
MaterialTheme {
SurveySheet(
survey = survey,
onSurveyShown = {},
onSubmit = { index, response ->
responses.add(index to response)
PostHogNextSurveyQuestion(2, index == 2)
},
onClose = { closes++ },
)
}
}
compose.onNodeWithText("First question").assertIsDisplayed()
val autoSubmit = enabled && kind in listOf("number", "emoji", "single")
if (autoSubmit) {
compose.onNodeWithText("Submit").assertDoesNotExist()
} else {
compose.onNodeWithText("Submit").assertIsNotEnabled()
}

selectAnswer()
if (!autoSubmit) {
compose.runOnIdle { assertEquals(emptyList(), responses) }
compose.onNodeWithText("Submit").assertIsEnabled().performClick()
}

compose.onNodeWithText("Last question").assertIsDisplayed()
compose.onNodeWithText("Skipped question").assertDoesNotExist()
compose.onNodeWithText("Submit").assertIsNotEnabled()
compose.runOnIdle {
assertEquals(listOf(0 to expectedResponse()), responses)
assertEquals(0, closes)
}

// Selecting the same value again must select it on the new question, not deselect stale state.
selectAnswer()
compose.onNodeWithText("Submit").assertIsEnabled().performClick()
compose.runOnIdle {
assertEquals(listOf(0 to expectedResponse(), 2 to expectedResponse()), responses)
assertEquals(1, closes)
}
}

private fun selectAnswer() {
when (kind) {
"number" -> compose.onNodeWithText("3").performClick()
"emoji" -> {
// Emoji choices are canvases without text; other sheet actions have accessible labels.
val choices =
compose.onAllNodes(
hasClickAction() and
SemanticsMatcher.keyNotDefined(SemanticsProperties.Text) and
SemanticsMatcher.keyNotDefined(SemanticsProperties.ContentDescription),
)
choices.assertCountEquals(5)
choices[2].performClick()
}
"open" -> {
compose.onNodeWithText("Other:").performClick()
compose.onNode(hasSetTextAction()).performTextInput("Free form")
}
else -> compose.onNodeWithText("First").performClick()
}
}

private fun expectedResponse(): PostHogSurveyResponse =
when (kind) {
"number", "emoji" -> PostHogSurveyResponse.Rating(3)
"multiple" -> PostHogSurveyResponse.MultipleChoice(listOf("First"))
"open" -> PostHogSurveyResponse.SingleChoice("Free form")
else -> PostHogSurveyResponse.SingleChoice("First")
}

private fun question(
title: String,
autoSubmit: Boolean,
): PostHogDisplaySurveyQuestion =
when (kind) {
"number", "emoji" ->
PostHogDisplayRatingQuestion(
id = title, question = title, questionDescription = null,
questionDescriptionContentType = PostHogDisplaySurveyTextContentType.TEXT,
isOptional = false, buttonText = "Submit",
ratingType = if (kind == "number") PostHogDisplaySurveyRatingType.NUMBER else PostHogDisplaySurveyRatingType.EMOJI,
scaleLowerBound = 1, scaleUpperBound = 5, lowerBoundLabel = "", upperBoundLabel = "",
skipSubmitButton = autoSubmit,
)
else ->
PostHogDisplayChoiceQuestion(
id = title, question = title, questionDescription = null,
questionDescriptionContentType = PostHogDisplaySurveyTextContentType.TEXT,
isOptional = false, buttonText = "Submit", choices = listOf("First", "Other"),
hasOpenChoice = kind == "open", shuffleOptions = false, isMultipleChoice = kind == "multiple",
skipSubmitButton = autoSubmit,
)
}

companion object {
@JvmStatic
@ParameterizedRobolectricTestRunner.Parameters(name = "kind={0}, enabled={1}")
fun cases(): List<Array<Any>> =
listOf("number", "emoji", "single", "open", "multiple").flatMap { kind ->
listOf(true, false).map { enabled -> arrayOf(kind, enabled) }
}
}
}
7 changes: 7 additions & 0 deletions posthog/api/posthog.api
Original file line number Diff line number Diff line change
Expand Up @@ -1660,9 +1660,12 @@ public final class com/posthog/surveys/OpenSurveyQuestion : com/posthog/surveys/

public final class com/posthog/surveys/PostHogDisplayChoiceQuestion : com/posthog/surveys/PostHogDisplaySurveyQuestion {
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/PostHogDisplaySurveyTextContentType;ZLjava/lang/String;Ljava/util/List;ZZZ)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/PostHogDisplaySurveyTextContentType;ZLjava/lang/String;Ljava/util/List;ZZZZ)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/PostHogDisplaySurveyTextContentType;ZLjava/lang/String;Ljava/util/List;ZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getChoices ()Ljava/util/List;
public final fun getHasOpenChoice ()Z
public final fun getShuffleOptions ()Z
public final fun getSkipSubmitButton ()Z
public final fun isMultipleChoice ()Z
}

Expand All @@ -1677,10 +1680,13 @@ public final class com/posthog/surveys/PostHogDisplayOpenQuestion : com/posthog/

public final class com/posthog/surveys/PostHogDisplayRatingQuestion : com/posthog/surveys/PostHogDisplaySurveyQuestion {
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/PostHogDisplaySurveyTextContentType;ZLjava/lang/String;Lcom/posthog/surveys/PostHogDisplaySurveyRatingType;IILjava/lang/String;Ljava/lang/String;)V
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/PostHogDisplaySurveyTextContentType;ZLjava/lang/String;Lcom/posthog/surveys/PostHogDisplaySurveyRatingType;IILjava/lang/String;Ljava/lang/String;Z)V
public synthetic fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/posthog/surveys/PostHogDisplaySurveyTextContentType;ZLjava/lang/String;Lcom/posthog/surveys/PostHogDisplaySurveyRatingType;IILjava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun getLowerBoundLabel ()Ljava/lang/String;
public final fun getRatingType ()Lcom/posthog/surveys/PostHogDisplaySurveyRatingType;
public final fun getScaleLowerBound ()I
public final fun getScaleUpperBound ()I
public final fun getSkipSubmitButton ()Z
public final fun getUpperBoundLabel ()Ljava/lang/String;
}

Expand Down Expand Up @@ -2157,6 +2163,7 @@ public class com/posthog/surveys/SurveyQuestion {
public final fun getId ()Ljava/lang/String;
public final fun getOptional ()Ljava/lang/Boolean;
public final fun getQuestion ()Ljava/lang/String;
public final fun getSkipSubmitButton ()Ljava/lang/Boolean;
public final fun getTranslations ()Ljava/util/Map;
public final fun getType ()Lcom/posthog/surveys/SurveyQuestionType;
}
Expand Down
Loading
Loading