mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-04-21 22:11:40 +07:00
[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:
committed by
intellij-monorepo-bot
parent
aca8588d4c
commit
f7db18dd64
@@ -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>
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user