[feedback] IJPL-177421 Permanent CSAT survey in IDEs

(cherry picked from commit 70300350ba240bfd2fd3b8235b638794aa30765e)


(cherry picked from commit 3d576c4377c5751d2ef6711c249d86a5ddfe879e)

IJ-MR-155667

GitOrigin-RevId: 47b7246d3af66ff46642254397fbe9c1599e7dc3
This commit is contained in:
Yuriy Artamonov
2025-02-10 19:34:28 +01:00
committed by intellij-monorepo-bot
parent aca8588d4c
commit f7db18dd64
9 changed files with 367 additions and 10 deletions

View File

@@ -39,7 +39,13 @@
<feedback.idleFeedbackSurvey implementation="com.intellij.platform.feedback.demo.DemoInIdeFeedbackSurvey"/>
<feedback.idleFeedbackSurvey implementation="com.intellij.platform.feedback.demo.DemoExternalFeedbackSurvey"/>
<feedback.idleFeedbackSurvey implementation="com.intellij.platform.feedback.pirates.SoftwareAccessibilitySurvey"/>
<feedback.idleFeedbackSurvey implementation="com.intellij.platform.feedback.csat.CsatFeedbackSurvey"/>
<backgroundPostStartupActivity implementation="com.intellij.platform.feedback.csat.CsatNewUserTracker"/>
<registryKey key="csat.survey.enabled" defaultValue="true" description="Enables periodic CSAT survey"/>
<registryKey key="csat.survey.today" defaultValue="" description="ISO Formatted today date such as 2011-12-03"/>
<registryKey key="csat.survey.user.created.date" defaultValue="" description="ISO Formatted date when user created such as 2011-12-03"/>
<registryKey key="csat.survey.show.probability" defaultValue="" description="Override probability of survey, double value from 0 to 1"/>
</extensions>
<applicationListeners>
@@ -64,8 +70,7 @@
<action class="com.intellij.platform.feedback.demo.ShowExternalDemoFeedbackWithStatsAction" internal="true"/>
<action class="com.intellij.platform.feedback.demo.ShowInIdeDemoFeedbackWithStatsAction" internal="true"/>
<action class="com.intellij.platform.feedback.pirates.ShowSoftwareAccessibilitySurveyAction" internal="true"
text="Show Software Accessibility Survey"/>
<action class="com.intellij.platform.feedback.csat.CsatFeedbackAction" internal="true"
text="Show CSAT Feedback Dialog"/>
</actions>
</idea-plugin>

View File

@@ -0,0 +1,10 @@
feedback.notification.title=Tell us about your experience
feedback.notification.text=Please answer a few questions about your experience with {0}
dialog.title=Feedback
dialog.subtitle=Tell us about your experience
dialog.extra.label=Please elaborate on your choice (optional):
dialog.rating.label=How satisfied are you with {0}?
dialog.rating.leftHint=Very dissatisfied
dialog.rating.middleHint=Neutral
dialog.rating.rightHint=Very satisfied

View File

@@ -0,0 +1,14 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.feedback.csat
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
internal class CsatFeedbackAction : AnAction() {
override fun actionPerformed(e: AnActionEvent) {
val project = e.project
if (project != null) {
CsatFeedbackSurvey().showNotification(project, false)
}
}
}

View File

@@ -0,0 +1,16 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.feedback.csat
import com.intellij.DynamicBundle
import org.jetbrains.annotations.NonNls
import org.jetbrains.annotations.PropertyKey
@NonNls
private const val BUNDLE = "messages.CsatFeedbackMessagesBundle"
internal object CsatFeedbackBundle {
private val bundle = DynamicBundle(javaClass, BUNDLE)
@Suppress("SpreadOperator")
fun message(@PropertyKey(resourceBundle = BUNDLE) key: String, vararg params: Any) = bundle.getMessage(key, *params)
}

View File

@@ -0,0 +1,91 @@
// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.feedback.csat
import com.intellij.icons.AllIcons
import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.project.Project
import com.intellij.platform.feedback.dialog.BlockBasedFeedbackDialogWithEmail
import com.intellij.platform.feedback.dialog.CommonFeedbackSystemData
import com.intellij.platform.feedback.dialog.SystemDataJsonSerializable
import com.intellij.platform.feedback.dialog.showFeedbackSystemInfoDialog
import com.intellij.platform.feedback.dialog.uiBlocks.FeedbackBlock
import com.intellij.platform.feedback.dialog.uiBlocks.SegmentedButtonBlock
import com.intellij.platform.feedback.dialog.uiBlocks.TextAreaBlock
import com.intellij.platform.feedback.dialog.uiBlocks.TopLabelBlock
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.encodeToJsonElement
internal data class CsatFeedbackSystemData(
val isNewUser: Boolean,
val systemInfo: CommonFeedbackSystemData
): SystemDataJsonSerializable {
override fun serializeToJson(json: Json): JsonElement {
return json.encodeToJsonElement(this)
}
override fun toString(): String = buildString {
appendLine("Is new installation:")
appendLine(isNewUser)
append(systemInfo.toString())
}
}
internal class CsatFeedbackDialog(
project: Project?,
forTest: Boolean,
) : BlockBasedFeedbackDialogWithEmail<CsatFeedbackSystemData>(project, forTest) {
/** Increase the additional number when the feedback format is changed */
override val myFeedbackJsonVersion: Int = super.myFeedbackJsonVersion + 1
override val zendeskTicketTitle: String = "Experience with IDE"
override val zendeskFeedbackType: String = "Experience with IDE"
override val myFeedbackReportId: String = "csat_feedback"
override val mySystemInfoData: CsatFeedbackSystemData by lazy {
getCsatSystemInfo()
}
@Suppress("HardCodedStringLiteral")
override val myShowFeedbackSystemInfoDialog: () -> Unit = {
showFeedbackSystemInfoDialog(myProject, mySystemInfoData.systemInfo) {
row("Is new installation:") {
label(mySystemInfoData.isNewUser.toString())
}
}
}
override val myTitle: String = CsatFeedbackBundle.message("dialog.title")
override val myBlocks: List<FeedbackBlock> = listOf(
TopLabelBlock(CsatFeedbackBundle.message("dialog.subtitle")),
SegmentedButtonBlock(CsatFeedbackBundle.message("dialog.rating.label", ApplicationInfo.getInstance().versionName),
List(5) { (it + 1).toString() },
"csat_rating",
listOf(
AllIcons.Survey.VeryDissatisfied,
AllIcons.Survey.Dissatisfied,
AllIcons.Survey.Neutral,
AllIcons.Survey.Satisfied,
AllIcons.Survey.VerySatisfied
))
.addLeftBottomLabel(CsatFeedbackBundle.message("dialog.rating.leftHint"))
.addMiddleBottomLabel(CsatFeedbackBundle.message("dialog.rating.middleHint"))
.addRightBottomLabel(CsatFeedbackBundle.message("dialog.rating.rightHint")),
TextAreaBlock(CsatFeedbackBundle.message("dialog.extra.label"), "textarea")
)
init {
init()
}
}
private fun getCsatSystemInfo(): CsatFeedbackSystemData {
val userCreatedDate = getCsatUserCreatedDate()
val today = getCsatToday()
val isNewUser = userCreatedDate?.let { isNewUser(today, userCreatedDate) } ?: false
return CsatFeedbackSystemData(isNewUser, CommonFeedbackSystemData.getCurrentData())
}

View File

@@ -0,0 +1,155 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.feedback.csat
import com.intellij.ide.util.PropertiesComponent
import com.intellij.internal.statistic.eventLog.fus.MachineIdManager
import com.intellij.openapi.application.ApplicationInfo
import com.intellij.openapi.diagnostic.Logger
import com.intellij.openapi.diagnostic.fileLogger
import com.intellij.openapi.project.Project
import com.intellij.openapi.util.registry.Registry
import com.intellij.platform.feedback.FeedbackSurvey
import com.intellij.platform.feedback.InIdeFeedbackSurveyConfig
import com.intellij.platform.feedback.InIdeFeedbackSurveyType
import com.intellij.platform.feedback.dialog.BlockBasedFeedbackDialog
import com.intellij.platform.feedback.dialog.SystemDataJsonSerializable
import com.intellij.platform.feedback.impl.notification.RequestFeedbackNotification
import kotlinx.datetime.LocalDate
import kotlinx.datetime.Month
import java.time.format.DateTimeFormatter
import java.time.temporal.ChronoUnit
internal const val USER_CONSIDERED_NEW_DAYS = 30
internal const val NEW_USER_SURVEY_PERIOD = 29
internal const val EXISTING_USER_SURVEY_PERIOD = 97
internal const val CSAT_SURVEY_LAST_FEEDBACK_DATE_KEY = "csat.survey.last.feedback.date"
internal class CsatFeedbackSurvey : FeedbackSurvey() {
override val feedbackSurveyType: InIdeFeedbackSurveyType<InIdeFeedbackSurveyConfig> =
InIdeFeedbackSurveyType(CsatFeedbackSurveyConfig())
}
private val LOG = Logger.getInstance(CsatFeedbackSurvey::class.java)
internal class CsatFeedbackSurveyConfig : InIdeFeedbackSurveyConfig {
override val surveyId: String = "csat_feedback"
override val lastDayOfFeedbackCollection: LocalDate = LocalDate(2050, Month.JANUARY, 1)
override val requireIdeEAP: Boolean = false
override fun checkIdeIsSuitable(): Boolean = Registry.`is`("csat.survey.enabled")
override fun createFeedbackDialog(project: Project, forTest: Boolean): BlockBasedFeedbackDialog<out SystemDataJsonSerializable> {
return CsatFeedbackDialog(project, forTest)
}
override fun updateStateAfterDialogClosedOk(project: Project) {
PropertiesComponent.getInstance().setValue(CSAT_SURVEY_LAST_FEEDBACK_DATE_KEY, getCsatToday().format(DateTimeFormatter.ISO_LOCAL_DATE))
}
override fun checkExtraConditionSatisfied(project: Project): Boolean {
val today = getCsatToday()
LOG.debug("Today is ${today.format(DateTimeFormatter.ISO_LOCAL_DATE)}")
val lastFeedbackDate = PropertiesComponent.getInstance().getValue(CSAT_SURVEY_LAST_FEEDBACK_DATE_KEY)
?.let { java.time.LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }
if (lastFeedbackDate != null && lastFeedbackDate.plusDays(EXISTING_USER_SURVEY_PERIOD.toLong()).isAfter(today)) {
LOG.debug("User recently filled the survey, vacation period is in progress")
return false
}
val userCreatedDate = getCsatUserCreatedDate()
LOG.debug("User created date is $userCreatedDate")
val isNewUser = userCreatedDate?.let { isNewUser(today, userCreatedDate) } ?: false
if (isNewUser) {
LOG.debug("User is a new user")
}
val surveyPeriod = if (isNewUser) NEW_USER_SURVEY_PERIOD else EXISTING_USER_SURVEY_PERIOD
val productHash = (ApplicationInfo.getInstance().versionName + MachineIdManager.getAnonymizedMachineId("CSAT Survey")).hashCode() % surveyPeriod
val daysHash = ChronoUnit.DAYS.between(java.time.LocalDate.of(1970, 1, 1), today).hashCode() % surveyPeriod
if (productHash != daysHash) {
LOG.debug("Periods do not match: $productHash / $daysHash, is not yet suitable date for the survey")
return false // not the day we need
}
val show = flipACoin(ApplicationInfo.getInstance().build.productCode, isNewUser)
if (!show) {
LOG.debug("Coin flipped to NOT show the survey this time")
}
return show
}
override fun createNotification(project: Project, forTest: Boolean): RequestFeedbackNotification {
return RequestFeedbackNotification(
"Feedback In IDE",
CsatFeedbackBundle.message("feedback.notification.title"),
CsatFeedbackBundle.message("feedback.notification.text", ApplicationInfo.getInstance().versionName)
)
}
override fun updateStateAfterNotificationShowed(project: Project) {
}
}
internal fun getCsatToday(): java.time.LocalDate {
try {
Registry.stringValue("csat.survey.today")
.takeIf { it.isNotBlank() }
?.let { java.time.LocalDate.parse(it, DateTimeFormatter.ISO_LOCAL_DATE) }
?.let { return it }
}
catch (e: Exception) {
fileLogger().error(e)
}
return java.time.LocalDate.now()
}
internal fun flipACoin(productCode: String, newUser: Boolean): Boolean {
val probability: Double? = Registry.stringValue("csat.survey.show.probability")
.takeIf { it.isNotBlank() }
?.toDoubleOrNull()
if (probability != null) {
when {
probability <= 0.0 -> {
return false
}
probability >= 1.0 -> {
return true
}
else -> {
return Math.random() < probability
}
}
}
val probabilityPerProduct: Double =
if (newUser) {
when (productCode) {
"PY", "IC", "IU" -> 0.125
"PC" -> 0.07
else -> 1.0
}
}
else {
when (productCode) {
"PC", "IU" -> 0.015
"IC", "PY" -> 0.025
else -> 0.125
}
}
if (probabilityPerProduct >= 1.0) return true
return Math.random() < probabilityPerProduct
}
internal fun isNewUser(today: java.time.LocalDate, userCreatedDate: java.time.LocalDate): Boolean {
return today.isBefore(userCreatedDate.plusDays(USER_CONSIDERED_NEW_DAYS.toLong()))
}

View File

@@ -0,0 +1,48 @@
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
package com.intellij.platform.feedback.csat
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.application.ConfigImportHelper
import com.intellij.openapi.project.Project
import com.intellij.openapi.startup.ProjectActivity
import com.intellij.openapi.util.registry.Registry
import java.time.LocalDate
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
internal const val CSAT_NEW_USER_CREATED_AT_PROPERTY = "csat.user.created.at"
internal val CSAT_TS_FORMAT = DateTimeFormatter.ISO_LOCAL_DATE
internal class CsatNewUserTracker : ProjectActivity {
override suspend fun execute(project: Project) {
if (ConfigImportHelper.isNewUser()) {
val propertiesComponent = PropertiesComponent.getInstance()
if (!propertiesComponent.isValueSet(CSAT_NEW_USER_CREATED_AT_PROPERTY)) {
propertiesComponent.setValue(CSAT_NEW_USER_CREATED_AT_PROPERTY,
LocalDate.now().format(CSAT_TS_FORMAT))
}
}
}
}
internal fun getCsatUserCreatedDate(): LocalDate? {
val mocked = Registry.stringValue("csat.survey.user.created.date")
if (!mocked.isBlank()) {
return try {
LocalDate.parse(mocked, CSAT_TS_FORMAT)
}
catch (_: DateTimeParseException) {
null
}
}
val date = PropertiesComponent.getInstance().getValue(CSAT_NEW_USER_CREATED_AT_PROPERTY) ?: return null
return try {
LocalDate.parse(date, CSAT_TS_FORMAT)
}
catch (_: DateTimeParseException) {
null
}
}

View File

@@ -10,11 +10,13 @@ import com.intellij.ui.dsl.gridLayout.UnscaledGaps
import com.intellij.util.ui.JBUI
import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.put
import javax.swing.Icon
import javax.swing.SwingConstants
class SegmentedButtonBlock(@NlsContexts.Label private val myMainLabel: String?,
private val myItems: List<String>,
private val myJsonElementName: String) : FeedbackBlock, TextDescriptionProvider, JsonDataProvider {
private val myJsonElementName: String,
private val myIcons: List<Icon> = emptyList()) : FeedbackBlock, TextDescriptionProvider, JsonDataProvider {
private var myProperty: String = ""
@@ -28,6 +30,8 @@ class SegmentedButtonBlock(@NlsContexts.Label private val myMainLabel: String?,
private var myRightBottomLabel: String? = null
override fun addToPanel(panel: Panel) {
val items = myItems.mapIndexed { index, s -> SegmentItem(s, myIcons.getOrNull(index)) }
panel.apply {
if (myMainLabel != null) {
row {
@@ -36,11 +40,20 @@ class SegmentedButtonBlock(@NlsContexts.Label private val myMainLabel: String?,
}.bottomGap(BottomGap.SMALL)
}
row {
segmentedButton(myItems) { text = it }
segmentedButton(items) {
if (it.icon != null) {
icon = it.icon
text = ""
}
else {
text = it.text
}
}
.apply {
maxButtonsCount(myItems.size)
}.customize(UnscaledGaps(top = IntelliJSpacingConfiguration().verticalComponentGap))
.whenItemSelected { myProperty = it }
}
.customize(UnscaledGaps(top = IntelliJSpacingConfiguration().verticalComponentGap))
.whenItemSelected { myProperty = it.text }
.align(Align.FILL)
.validation {
addApplyRule(CommonFeedbackBundle.message("dialog.feedback.segmentedButton.required")) { it.selectedItem == null }
@@ -116,4 +129,9 @@ class SegmentedButtonBlock(@NlsContexts.Label private val myMainLabel: String?,
myRightBottomLabel = rightBottomLabel
return this
}
}
}
private class SegmentItem(
val text: String,
val icon: Icon? = null
)

View File

@@ -205,7 +205,7 @@ internal class SegmentedButtonImpl<T>(dialogPanelConfig: DialogPanelConfig, pare
val result = ItemPresentationImpl()
result.renderer(item)
if (result.text.isNullOrEmpty()) {
if (result.text.isNullOrEmpty() && result.icon == null) {
throw UiDslException("Empty text in segmented button presentation is not allowed")
}