From 7bf9bfecfa2f01a250ebf13897d5322472873855 Mon Sep 17 00:00:00 2001 From: Jihan el medini <119955059+jelmedini@users.noreply.github.com> Date: Fri, 12 Jul 2024 13:11:25 -0400 Subject: [PATCH] feat(genqa): new feedback modal (#4092) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Feedback Modal Full width** ![Screenshot 2024-06-14 at 3 24 25 PM](https://github.com/coveo/ui-kit/assets/119955059/74cf8b69-d580-4cc9-b6f0-ceab3a393ed9) **Hover buttons state** ![Screenshot 2024-06-14 at 3 27 25 PM](https://github.com/coveo/ui-kit/assets/119955059/d4d8ecb9-1e9b-4cb9-8f75-257133a0ebb4) **Selected buttons state** ![Screenshot 2024-06-14 at 3 27 36 PM](https://github.com/coveo/ui-kit/assets/119955059/d34200c3-b422-4379-ad78-85bf66383940) **Required field error after submit state** ![Screenshot 2024-06-14 at 3 27 50 PM](https://github.com/coveo/ui-kit/assets/119955059/26e9cea6-e941-4f46-bebc-9d7cba58cbcf) **Feedback modal mobile version** ![Screenshot 2024-06-14 at 3 28 12 PM](https://github.com/coveo/ui-kit/assets/119955059/273a8083-f112-4e91-8e0d-9bed778489e9) **Feedback modal mobile version with error** Screenshot 2024-07-11 at 11 21 00 AM **Sumbit Events** ![Screenshot 2024-07-05 at 10 39 36 AM](https://github.com/coveo/ui-kit/assets/119955059/c7d24668-58dc-4f73-8c77-1f840cfa10de) **DEMO** https://github.com/coveo/ui-kit/assets/119955059/34dc5da7-fbb6-4e55-b0ed-4a4cecb2f179 --- .../cypress/e2e/generated-answer-selectors.ts | 17 +- .../cypress/e2e/generated-answer.cypress.ts | 60 ++- packages/atomic/src/components.d.ts | 8 + ...tomic-generated-answer-feedback-modal.pcss | 60 +++ ...atomic-generated-answer-feedback-modal.tsx | 366 +++++++++---- .../generated-answer-common.tsx | 30 +- packages/atomic/src/locales.json | 486 ++++++++++++++++-- packages/headless/src/index.ts | 6 +- 8 files changed, 843 insertions(+), 190 deletions(-) diff --git a/packages/atomic/cypress/e2e/generated-answer-selectors.ts b/packages/atomic/cypress/e2e/generated-answer-selectors.ts index 1aad7b0041b..d6b7cd1f5a2 100644 --- a/packages/atomic/cypress/e2e/generated-answer-selectors.ts +++ b/packages/atomic/cypress/e2e/generated-answer-selectors.ts @@ -1,3 +1,5 @@ +import {GeneratedAnswerFeedbackV2} from '@coveo/headless'; + export const generatedAnswerComponent = 'atomic-generated-answer'; export const feedbackModal = 'atomic-generated-answer-feedback-modal'; export const GeneratedAnswerSelectors = { @@ -54,12 +56,17 @@ export const feedbackModalSelectors = { modalHeader: () => feedbackModalSelectors.atomicModal().find('[part="modal-header"]'), modalFooter: () => - feedbackModalSelectors.atomicModal().find('[part="modalFooter"]'), - detailsTextArea: () => - feedbackModalSelectors.atomicModal().find('[part="details-input"]'), + feedbackModalSelectors.atomicModal().find('[part="modal-footer"]'), other: () => feedbackModalSelectors.atomicModal().find('.other'), - reason: () => - feedbackModalSelectors.atomicModal().find('[part="reason-radio"]'), + feedbackOption: ( + feedback: keyof GeneratedAnswerFeedbackV2, + optionText: 'No' | 'Yes' | 'Not sure' + ) => + feedbackModalSelectors + .atomicModal() + .find('[part="form"]') + .find(`.${feedback}`) + .find(`input[value=${optionText}]`), submitButton: () => feedbackModalSelectors.atomicModal().find('[part="submit-button"]'), cancelButton: () => diff --git a/packages/atomic/cypress/e2e/generated-answer.cypress.ts b/packages/atomic/cypress/e2e/generated-answer.cypress.ts index 842309d0828..b37e76765d6 100644 --- a/packages/atomic/cypress/e2e/generated-answer.cypress.ts +++ b/packages/atomic/cypress/e2e/generated-answer.cypress.ts @@ -151,42 +151,68 @@ describe('Generated Answer Test Suites', () => { setupGeneratedAnswer(streamId); cy.wait(getStreamInterceptAlias(streamId)); GeneratedAnswerSelectors.answer(); - GeneratedAnswerSelectors.dislikeButton().click({force: true}); }); it('should open when an answer is disliked', () => { + GeneratedAnswerSelectors.dislikeButton().click({force: true}); + + feedbackModalSelectors.modalBody().should('exist'); + feedbackModalSelectors.modalHeader().should('exist'); + feedbackModalSelectors.modalFooter().should('exist'); + }); + + it('should open when an answer is liked', () => { + GeneratedAnswerSelectors.likeButton().click({force: true}); + feedbackModalSelectors.modalBody().should('exist'); feedbackModalSelectors.modalHeader().should('exist'); feedbackModalSelectors.modalFooter().should('exist'); }); describe('select button', () => { - it('should submit proper reason', () => { - const notAccurateReason = feedbackModalSelectors.reason().eq(1); - notAccurateReason.should('have.id', 'notAccurate'); - notAccurateReason.click({force: true}); + it('should submit proper feedback', () => { + GeneratedAnswerSelectors.likeButton().click({force: true}); + + feedbackModalSelectors + .feedbackOption('correctTopic', 'Yes') + .click({force: true}); + feedbackModalSelectors + .feedbackOption('hallucinationFree', 'No') + .click({force: true}); + + feedbackModalSelectors.submitButton().click(); + feedbackModalSelectors.submitButton().should('exist'); + + feedbackModalSelectors + .feedbackOption('readable', 'Yes') + .click({force: true}); + feedbackModalSelectors + .feedbackOption('documented', 'Yes') + .click({force: true}); feedbackModalSelectors.submitButton().click(); feedbackModalSelectors.submitButton().should('not.exist'); feedbackModalSelectors.cancelButton().should('exist'); cy.get(`${RouteAlias.UA}.3`) - .its('request.body.customData.reason') - .should('equal', 'notAccurate'); - }); - }); + .its('request.body.customData.helpful') + .should('equal', true); - describe('add details text area', () => { - it('should be visible when other is selected', () => { - feedbackModalSelectors.detailsTextArea().should('not.exist'); + cy.get(`${RouteAlias.UA}.3`) + .its('request.body.customData.correctTopic') + .should('equal', 'yes'); - const reasons = feedbackModalSelectors.reason(); - reasons.last().should('have.id', 'other'); + cy.get(`${RouteAlias.UA}.3`) + .its('request.body.customData.hallucinationFree') + .should('equal', 'no'); - reasons.last().click({force: true}); + cy.get(`${RouteAlias.UA}.3`) + .its('request.body.customData.readable') + .should('equal', 'yes'); - feedbackModalSelectors.detailsInput().should('exist'); - feedbackModalSelectors.submitButton().should('be.enabled'); + cy.get(`${RouteAlias.UA}.3`) + .its('request.body.customData.documented') + .should('equal', 'yes'); }); }); }); diff --git a/packages/atomic/src/components.d.ts b/packages/atomic/src/components.d.ts index 380db2804d3..d88ba179ec3 100644 --- a/packages/atomic/src/components.d.ts +++ b/packages/atomic/src/components.d.ts @@ -1020,6 +1020,10 @@ export namespace Components { * A `GeneratedAnswer` controller instance. It is used when the user interacts with the modal. */ "generatedAnswer": GeneratedAnswer; + /** + * Indicates whether the answer was helpful or not. + */ + "helpful": boolean; /** * Indicates whether the modal is open. */ @@ -6486,6 +6490,10 @@ declare namespace LocalJSX { * A `GeneratedAnswer` controller instance. It is used when the user interacts with the modal. */ "generatedAnswer": GeneratedAnswer; + /** + * Indicates whether the answer was helpful or not. + */ + "helpful"?: boolean; /** * Indicates whether the modal is open. */ diff --git a/packages/atomic/src/components/common/generated-answer/atomic-generated-answer-feedback/atomic-generated-answer-feedback-modal.pcss b/packages/atomic/src/components/common/generated-answer/atomic-generated-answer-feedback/atomic-generated-answer-feedback-modal.pcss index 7a0133e5e82..dad81f94711 100644 --- a/packages/atomic/src/components/common/generated-answer/atomic-generated-answer-feedback/atomic-generated-answer-feedback-modal.pcss +++ b/packages/atomic/src/components/common/generated-answer/atomic-generated-answer-feedback/atomic-generated-answer-feedback-modal.pcss @@ -1 +1,61 @@ @import '../../../../global/global.pcss'; + +@define-mixin mobile-feedback-modal { + &::part(container) { + @apply w-auto min-w-full; + } + + &::part(header), + &::part(body), + &::part(footer) { + @apply max-w-full; + } + [part='buttons'] { + .required-label { + @apply text-sm; + } + } + [part='form'] { + .answer-evaluation { + @apply block; + } + + .options { + @apply mt-2; + } + } + [part='modal-header'] { + .hide { + @apply hidden; + } + } +} + +[part='generated-answer-feedback-modal'] { + &::part(container) { + @apply min-w-[42.5rem]; + } + + &::part(header), + &::part(body), + &::part(footer) { + @apply max-w-[42.5rem]; + } + + @screen mobile-only { + @mixin mobile-feedback-modal; + } +} + +[part='form'] { + .active { + @apply border-primary-light text-primary-light; + background-color: var(--atomic-primary-background); + } + .text-error-red { + color: var(--atomic-inline-code); + } + .required { + @apply flex; + } +} diff --git a/packages/atomic/src/components/common/generated-answer/atomic-generated-answer-feedback/atomic-generated-answer-feedback-modal.tsx b/packages/atomic/src/components/common/generated-answer/atomic-generated-answer-feedback/atomic-generated-answer-feedback-modal.tsx index 47cdb497566..7c0f5fc6519 100644 --- a/packages/atomic/src/components/common/generated-answer/atomic-generated-answer-feedback/atomic-generated-answer-feedback-modal.tsx +++ b/packages/atomic/src/components/common/generated-answer/atomic-generated-answer-feedback/atomic-generated-answer-feedback-modal.tsx @@ -1,4 +1,8 @@ -import {GeneratedAnswer, GeneratedAnswerFeedback} from '@coveo/headless'; +import { + GeneratedAnswer, + GeneratedAnswerFeedbackV2, + GeneratedAnswerFeedbackOption, +} from '@coveo/headless'; import { Component, State, @@ -19,7 +23,9 @@ import { import {updateBreakpoints} from '../../../../utils/replace-breakpoint'; import {once, randomID} from '../../../../utils/utils'; import {Button} from '../../button'; +import {FieldsetGroup} from '../../fieldset-group'; import {IconButton} from '../../iconButton'; +import {RadioButton} from '../../radio-button'; /** * @internal @@ -43,16 +49,24 @@ export class AtomicGeneratedAnswerFeedbackModal * A `GeneratedAnswer` controller instance. It is used when the user interacts with the modal. */ @Prop({reflect: true, mutable: true}) generatedAnswer!: GeneratedAnswer; + /** + * Indicates whether the answer was helpful or not. + */ + @Prop({reflect: true, mutable: true}) helpful = false; @State() public error!: Error; - @State() currentAnswer?: GeneratedAnswerFeedback | 'other' | undefined; + @State() private currentAnswer: Partial = + this.getInitialAnswerState(); @State() feedbackSubmitted: boolean = false; + @State() answerEvaluationRequired: boolean = false; private readonly formId = randomID( 'atomic-generated-answer-feedback-modal-form-' ); private detailsInputRef?: HTMLTextAreaElement; + private linkInputRef?: HTMLInputElement; + @Event() feedbackSent!: EventEmitter; @Watch('isOpen') @@ -63,68 +77,96 @@ export class AtomicGeneratedAnswerFeedbackModal } private static options: { - id: string; localeKey: string; - correspondingAnswer: GeneratedAnswerFeedback | 'other'; + correspondingAnswer: keyof GeneratedAnswerFeedbackV2; }[] = [ { - id: 'irrelevant', - localeKey: 'irrelevant', - correspondingAnswer: 'irrelevant', - }, - { - id: 'notAccurate', - localeKey: 'not-accurate', - correspondingAnswer: 'notAccurate', + localeKey: 'feedback-correct-topic', + correspondingAnswer: 'correctTopic', }, { - id: 'outOfDate', - localeKey: 'out-of-date', - correspondingAnswer: 'outOfDate', + localeKey: 'feedback-hallucination-free', + correspondingAnswer: 'hallucinationFree', }, { - id: 'harmful', - localeKey: 'harmful', - correspondingAnswer: 'harmful', + localeKey: 'feedback-documented', + correspondingAnswer: 'documented', }, { - id: 'other', - localeKey: 'other', - correspondingAnswer: 'other', + localeKey: 'feedback-readable', + correspondingAnswer: 'readable', }, ]; - private setIsOpen(isOpen: boolean) { - this.isOpen = isOpen; + private getInitialAnswerState(): Partial { + return { + documented: undefined, + correctTopic: undefined, + hallucinationFree: undefined, + readable: undefined, + }; } - private close() { + private resetState() { this.feedbackSubmitted = false; + this.currentAnswer = this.getInitialAnswerState(); + this.answerEvaluationRequired = false; + this.isOpen = false; + } + + private clearInputRefs() { if (this.detailsInputRef) { this.detailsInputRef.value = ''; } - this.currentAnswer = undefined; - this.setIsOpen(false); + if (this.linkInputRef) { + this.linkInputRef.value = ''; + } + } + + private close() { + this.clearInputRefs(); + this.resetState(); this.generatedAnswer.closeFeedbackModal(); } private updateBreakpoints = once(() => updateBreakpoints(this.host)); - private setCurrentAnswer(answer?: GeneratedAnswerFeedback | 'other') { - this.currentAnswer = answer; + private setCurrentAnswer( + key: keyof GeneratedAnswerFeedbackV2, + value: GeneratedAnswerFeedbackOption | string + ) { + this.currentAnswer = { + ...this.currentAnswer, + [key]: value, + }; } public sendFeedback() { - if (this.currentAnswer === 'other') { - this.generatedAnswer.sendDetailedFeedback(this.detailsInputRef!.value); - } else { - this.generatedAnswer.sendFeedback( - this.currentAnswer as GeneratedAnswerFeedback - ); - } + const feedback: GeneratedAnswerFeedbackV2 = { + ...(this.currentAnswer as GeneratedAnswerFeedbackV2), + helpful: this.helpful, + }; + this.generatedAnswer.sendFeedback(feedback); this.feedbackSent.emit(); } + private isAnyAnswerEvaluationUndefined = () => { + return Object.values(this.currentAnswer).some( + (value) => value === undefined + ); + }; + + private handleSubmit(e: Event) { + e.preventDefault(); + if (this.isAnyAnswerEvaluationUndefined()) { + this.answerEvaluationRequired = true; + return; + } + this.feedbackSubmitted = true; + this.answerEvaluationRequired = false; + this.sendFeedback(); + } + private renderHeader() { return (
-

{this.bindings.i18n.t('feedback')}

+

+ {this.bindings.i18n.t('feedback-modal-title')} + + {this.bindings.i18n.t('additional-feedback')} + +

this.close()} icon={CloseIcon} partPrefix={'close'} - ariaLabel={this.bindings.i18n.t('modal-done')} + ariaLabel={this.bindings.i18n.t('close')} />
); } + private renderFeedbackOption( + option: GeneratedAnswerFeedbackOption, + correspondingAnswer: keyof GeneratedAnswerFeedbackV2 + ) { + const buttonClasses = [ + 'min-w-20', + 'flex', + 'items-center', + 'justify-center', + 'px-3', + 'py-1.5', + 'mr-1', + 'text-neutral-dark', + ]; + const isSelected = this.currentAnswer[correspondingAnswer] === option; + + if (isSelected) { + buttonClasses.push('active'); + } + return ( + { + this.setCurrentAnswer(correspondingAnswer, option); + }} + class={buttonClasses.join(' ')} + text={this.bindings.i18n.t(option)} + > + ); + } + + private renderAnswerEvaluation( + label: string, + correspondingAnswer: keyof GeneratedAnswerFeedbackV2 + ) { + const labelClasses = ['text-error-red', 'text-sm', 'hidden']; + const isRequired = + this.answerEvaluationRequired && + this.currentAnswer[correspondingAnswer] === undefined; + if (isRequired) { + labelClasses.push('required'); + } + return ( +
+
+ +
+ + {this.bindings.i18n.t('required-fields-error')} + +
+ ); + } + private renderOptions() { return (
- - {this.bindings.i18n.t('generated-answer-feedback-instructions')} + + {this.bindings.i18n.t('answer-evaluation')} {AtomicGeneratedAnswerFeedbackModal.options.map( - ({id, localeKey, correspondingAnswer}) => ( -
- - (e.currentTarget as HTMLInputElement | null)?.checked && - this.setCurrentAnswer(correspondingAnswer) - } - class="mr-2" - required - /> - -
+ ({localeKey, correspondingAnswer}) => ( + +
+ {this.renderAnswerEvaluation(localeKey, correspondingAnswer)} +
+ {this.renderFeedbackOption('yes', correspondingAnswer)} + {this.renderFeedbackOption('unknown', correspondingAnswer)} + {this.renderFeedbackOption('no', correspondingAnswer)} +
+
+
) )}
); } - private renderDetails() { - if (this.currentAnswer !== 'other') { - return; - } + private renderLinkToCorrectAnswerField() { + return ( +
+ + {this.bindings.i18n.t('generated-answer-feedback-link')} + + (this.linkInputRef = linkInputRef)} + placeholder="https://URL" + class="input-primary mt-4 w-full h-9 rounded-md px-4 placeholder-neutral-dark" + onChange={(e) => + this.setCurrentAnswer( + 'documentUrl', + (e.currentTarget as HTMLInputElement).value + ) + } + /> +
+ ); + } + private renderAddNotesField() { return (
+ + {this.bindings.i18n.t('generated-answer-additional-notes')} +
); } - private renderBody() { - return !this.feedbackSubmitted ? ( + private renderFeedbackForm() { + return (
{ - e.preventDefault(); - this.feedbackSubmitted = true; - this.sendFeedback(); - }} - class="flex flex-col gap-8 text-base leading-4 text-neutral-dark p-2" + onSubmit={(e) => this.handleSubmit(e)} + class="flex flex-col gap-8 leading-4" > {this.renderOptions()} - {this.renderDetails()} + {this.renderLinkToCorrectAnswerField()} + {this.renderAddNotesField()}
- ) : ( + ); + } + + private renderSuccessMessage() { + return (

@@ -227,48 +355,77 @@ export class AtomicGeneratedAnswerFeedbackModal ); } - private renderFooter() { + private renderBody() { + if (!this.feedbackSubmitted) { + return this.renderFeedbackForm(); + } else { + return this.renderSuccessMessage(); + } + } + + private renderFeedbackFormFooter() { + const buttonClasses = + 'flex items-center justify-center text-sm leading-4 p-2 rounded-md'; + return ( -

- {!this.feedbackSubmitted ? ( -
+
+
+
+ * + {this.bindings.i18n.t('required-fields')} +
+
-
- ) : ( -
-
- )} +
); } + private renderSuccessFormFooter() { + return ( +
+
+ +
+
+ ); + } + + private renderFooter() { + if (!this.feedbackSubmitted) { + return this.renderFeedbackFormFooter(); + } else { + return this.renderSuccessFormFooter(); + } + } + public render() { this.updateBreakpoints(); @@ -278,6 +435,7 @@ export class AtomicGeneratedAnswerFeedbackModal isOpen={this.isOpen} close={() => this.close()} container={this.host} + part="generated-answer-feedback-modal" exportparts="backdrop,container,header,header-wrapper,header-ruler,body,body-wrapper,footer,footer-wrapper,footer-wrapper" > {this.renderHeader()} diff --git a/packages/atomic/src/components/common/generated-answer/generated-answer-common.tsx b/packages/atomic/src/components/common/generated-answer/generated-answer-common.tsx index 167b6f3ca3d..5ab5b3b69a8 100644 --- a/packages/atomic/src/components/common/generated-answer/generated-answer-common.tsx +++ b/packages/atomic/src/components/common/generated-answer/generated-answer-common.tsx @@ -220,13 +220,8 @@ export class GeneratedAnswerCommon { } private renderFeedbackAndCopyButtons() { - const { - getGeneratedAnswerState, - getBindings, - getGeneratedAnswer, - getCopied, - getCopyError, - } = this.props; + const {getGeneratedAnswerState, getBindings, getCopied, getCopyError} = + this.props; const {i18n} = getBindings(); const {liked, disliked, answer, isStreaming} = getGeneratedAnswerState() ?? {}; @@ -252,7 +247,7 @@ export class GeneratedAnswerCommon { title={i18n.t('this-answer-was-helpful')} variant="like" active={!!liked} - onClick={() => getGeneratedAnswer()?.like()} + onClick={() => this.clickLike()} />