fix(csat): Require confirmation before submitting rating (#14450)

Customers reported that the CSAT survey was recording their rating the
instant they tapped a star — leaving no chance to correct an accidental
pick. This change lets the customer freely change their selection until
they actually submit the comment/feedback. The rating still saves on
click (so we don't lose ratings when a customer never types a comment),
but it stays editable until the comment form is submitted. Once that
happens, the rating locks.

The flow on both surfaces:

- Customer taps a star/emoji → rating is saved.
- Customer taps a different star/emoji → previous save is overwritten
with the new value.
- Customer types a comment and submits → latest rating + comment are
saved together.
- After that submit, the rating is locked and can't be changed.

Two surfaces are updated:

- **Standalone survey page** (`/survey/responses/:uuid`) — the rating
buttons remain re-tappable until the Feedback form is submitted; once
submitted, both rating and feedback lock.
- **In-conversation widget CSAT** — same behavior, the inline
arrow-submit button on the feedback form is the locking action.

In-flight guards prevent a race where the customer changes their pick
mid-network-call (raised by the codex review on the earlier revision):
while a save is in flight, the rating controls are temporarily disabled
so the request and the displayed selection can't diverge.

## Closes

-
https://linear.app/chatwoot/issue/CW-7061/csat-star-rating-submits-on-first-click-needs-confirmation-step

## How to test

**Standalone survey page**
1. Enable CSAT on any inbox (Settings → Inboxes → Configuration → CSAT
survey).
2. Resolve a conversation in that inbox so a CSAT message is generated.
3. Open the survey URL:
`{FRONTEND_URL}/survey/responses/{conversation.uuid}` (easiest: `bundle
exec rails runner 'puts Conversation.joins(:messages).where(messages: {
content_type: "input_csat" }).last.csat_survey_link'`).
4. Tap a star/emoji — confirm the rating saves (Network panel shows a
PUT to `/public/api/v1/csat_survey/{uuid}`).
5. Tap a different star/emoji — confirm a second PUT goes out with the
new value; the latest selection is reflected.
6. Type a comment and hit Submit feedback — confirm rating + feedback
persist; both controls now lock.
7. Reload the page — the locked state is rehydrated correctly.

**Widget CSAT**
1. From an inbox with CSAT enabled, resolve a conversation that has an
active widget session.
2. In the widget, the CSAT card appears with stars/emojis + the inline
comment box.
3. Tap a star/emoji — confirm a PATCH goes out and the selection visibly
updates.
4. Tap different stars/emojis — confirm each overrides the previous
save.
5. Type a comment and click the arrow — rating + comment submit
together; stars lock.

Both display types (emoji and 5-star) should behave consistently.

## What changed

- `app/javascript/survey/views/Response.vue` — `selectRating()` saves on
every tap and short-circuits while a save is in flight (or after
feedback was submitted). Rating components are disabled by
`isFeedbackSubmitted || isUpdating` so the lock follows the feedback
submission, not the first rating tap.
- `app/javascript/survey/components/Rating.vue` — new `isDisabled` prop.
The disabled / hover styling and click guard key off it instead of the
presence of `selectedRating`, so emojis stay re-clickable until the
feedback step locks them.
- `app/javascript/shared/components/CustomerSatisfaction.vue` — same
shape for the widget: rating click auto-submits and re-clicks override
the previous save; controls disabled while a submit is in flight;
emoji-button styling and the inline `StarRating` lock on
`isFeedbackSubmitted || isUpdating`.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-05-18 21:46:30 +05:30
committed by GitHub
co-authored by Muhsin Sony Mathew
parent bcb66cdcc0
commit c4089f2226
4 changed files with 49 additions and 12 deletions
@@ -49,7 +49,9 @@ export default {
?.feedback_message;
},
isButtonDisabled() {
return !(this.selectedRating && this.feedback);
if (!(this.selectedRating && this.feedback)) return true;
if (this.isUpdating) return true;
return false;
},
textColor() {
return getContrastingTextColor(this.widgetColor);
@@ -79,14 +81,16 @@ export default {
methods: {
buttonClass(rating) {
const isLocked = this.isFeedbackSubmitted || this.isUpdating;
return [
{ selected: rating.value === this.selectedRating },
{ disabled: this.isRatingSubmitted },
{ hover: this.isRatingSubmitted },
{ disabled: isLocked },
{ hover: isLocked },
'emoji-button',
];
},
async onSubmit() {
if (this.isUpdating) return;
this.isUpdating = true;
try {
await this.$store.dispatch('message/update', {
@@ -106,10 +110,12 @@ export default {
},
selectRating(rating) {
if (this.isFeedbackSubmitted || this.isUpdating) return;
this.selectedRating = rating.value;
this.onSubmit();
},
selectStarRating(value) {
if (this.isFeedbackSubmitted || this.isUpdating) return;
this.selectedRating = value;
this.onSubmit();
},
@@ -138,7 +144,7 @@ export default {
<StarRating
v-else-if="isStarType"
:selected-rating="selectedRating"
:is-disabled="isRatingSubmitted"
:is-disabled="isFeedbackSubmitted || isUpdating"
@select-rating="selectStarRating"
/>
<form
+17 -1
View File
@@ -15,6 +15,14 @@ export default {
type: Boolean,
default: false,
},
isButtonDisabled: {
type: Boolean,
default: false,
},
selectedRating: {
type: Number,
default: null,
},
},
emits: ['sendFeedback'],
data() {
@@ -22,8 +30,16 @@ export default {
feedback: '',
};
},
computed: {
isSubmitDisabled() {
return (
this.isButtonDisabled || !this.selectedRating || !this.feedback.trim()
);
},
},
methods: {
onClick() {
if (this.isSubmitDisabled) return;
this.$emit('sendFeedback', this.feedback);
},
},
@@ -41,7 +57,7 @@ export default {
:placeholder="$t('SURVEY.FEEDBACK.PLACEHOLDER')"
/>
<div class="flex items-center float-right font-medium">
<CustomButton @click="onClick">
<CustomButton :disabled="isSubmitDisabled" @click="onClick">
<Spinner v-if="isUpdating" class="p-0" />
{{ $t('SURVEY.FEEDBACK.BUTTON_TEXT') }}
</CustomButton>
+7 -2
View File
@@ -7,6 +7,10 @@ export default {
type: Number,
default: null,
},
isDisabled: {
type: Boolean,
default: false,
},
},
emits: ['selectRating'],
data() {
@@ -19,12 +23,13 @@ export default {
buttonClass(rating) {
return [
{ selected: rating.value === this.selectedRating },
{ disabled: !!this.selectedRating },
{ hover: !!this.selectedRating },
{ disabled: this.isDisabled },
{ hover: this.isDisabled },
'emoji-button shadow-none text-3xl lg:text-4xl outline-none mr-8',
];
},
onClick(rating) {
if (this.isDisabled) return;
this.$emit('selectRating', rating.value);
},
},
+15 -5
View File
@@ -27,6 +27,7 @@ export default {
errorMessage: null,
selectedRating: null,
feedbackMessage: '',
hasSubmittedFeedback: false,
isUpdating: false,
logo: '',
inboxName: '',
@@ -43,10 +44,14 @@ export default {
return this.surveyDetails && this.surveyDetails.rating;
},
isFeedbackSubmitted() {
return this.surveyDetails && this.surveyDetails.feedback_message;
return (
this.hasSubmittedFeedback || !!this.surveyDetails?.feedback_message
);
},
isButtonDisabled() {
return !(this.selectedRating && this.feedback);
if (!this.selectedRating) return true;
if (this.isUpdating) return true;
return false;
},
isEmojiType() {
return this.displayType === CSAT_DISPLAY_TYPES.EMOJI;
@@ -78,12 +83,13 @@ export default {
},
methods: {
selectRating(rating) {
if (this.isFeedbackSubmitted || this.isUpdating) return;
this.selectedRating = rating;
this.updateSurveyDetails();
},
sendFeedback(message) {
this.feedbackMessage = message;
this.updateSurveyDetails();
this.updateSurveyDetails({ markFeedbackSubmitted: true });
},
async getSurveyDetails() {
this.isLoading = true;
@@ -106,7 +112,7 @@ export default {
this.isLoading = false;
}
},
async updateSurveyDetails() {
async updateSurveyDetails({ markFeedbackSubmitted = false } = {}) {
this.isUpdating = true;
try {
const data = {
@@ -127,6 +133,9 @@ export default {
rating: this.selectedRating,
feedback_message: this.feedbackMessage,
};
if (markFeedbackSubmitted) {
this.hasSubmittedFeedback = true;
}
} catch (error) {
const errorMessage = error?.response?.data?.error;
this.errorMessage = errorMessage || this.$t('SURVEY.API.ERROR_MESSAGE');
@@ -179,12 +188,13 @@ export default {
<Rating
v-if="isEmojiType"
:selected-rating="selectedRating"
:is-disabled="isFeedbackSubmitted || isUpdating"
@select-rating="selectRating"
/>
<StarRating
v-if="isStarType"
:selected-rating="selectedRating"
:is-disabled="isRatingSubmitted"
:is-disabled="isFeedbackSubmitted || isUpdating"
class="[&>button>span]:text-4xl !justify-start !px-0"
@select-rating="selectRating"
/>