Skip to content

Commit 0ec535f

Browse files
authored
feat(compass-query-bar): add query ai feedback options COMPASS-7051 (#4716)
1 parent f9724bb commit 0ec535f

File tree

8 files changed

+520
-85
lines changed

8 files changed

+520
-85
lines changed
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React, { useState } from 'react';
2+
import { expect } from 'chai';
3+
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
4+
5+
import { FeedbackPopover } from './feedback-popover';
6+
7+
function FeedbackPopoverRenderer(
8+
props: Partial<React.ComponentProps<typeof FeedbackPopover>>
9+
) {
10+
const buttonRef = React.createRef<any>();
11+
const [open, setOpen] = useState(false);
12+
13+
return (
14+
<div>
15+
<button
16+
data-testid="open-feedback-button"
17+
ref={buttonRef}
18+
onClick={() => setOpen(!open)}
19+
>
20+
Feedback Button
21+
</button>
22+
<FeedbackPopover
23+
label="test"
24+
placeholder=""
25+
refEl={buttonRef}
26+
open={open}
27+
setOpen={setOpen}
28+
onSubmitFeedback={() => {
29+
/* no-op */
30+
}}
31+
{...props}
32+
/>
33+
</div>
34+
);
35+
}
36+
37+
const renderFeedbackPopover = (
38+
props: Partial<React.ComponentProps<typeof FeedbackPopover>>
39+
) => {
40+
render(<FeedbackPopoverRenderer {...props} />);
41+
};
42+
43+
describe('FeedbackPopover', function () {
44+
afterEach(function () {
45+
cleanup();
46+
});
47+
48+
it('renders the popover and passes feedback when submitted', async function () {
49+
let feedbackText = '';
50+
renderFeedbackPopover({
51+
onSubmitFeedback: (text: string) => {
52+
feedbackText = text;
53+
},
54+
});
55+
56+
expect(screen.queryByRole('textbox')).to.not.exist;
57+
58+
screen.getByTestId('open-feedback-button').click();
59+
60+
const textArea = screen.getByTestId('feedback-popover-textarea');
61+
expect(textArea).to.be.visible;
62+
fireEvent.change(textArea, {
63+
target: { value: 'pineapple' },
64+
});
65+
66+
screen.getByText('Submit').click();
67+
// Wait for the event to go through.
68+
await new Promise((resolve) => setTimeout(resolve, 3));
69+
70+
expect(feedbackText).to.equal('pineapple');
71+
});
72+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React, { useCallback, useEffect, useState } from 'react';
2+
import { GuideCue as LGGuideCue } from '@leafygreen-ui/guide-cue';
3+
4+
import { TextArea, css, spacing, useId } from '..';
5+
6+
const guideCueStyles = css({
7+
minWidth: spacing[7] * 4,
8+
});
9+
10+
type LGGuideCueProps = React.ComponentProps<typeof LGGuideCue>;
11+
12+
// Omit the props we are handling.
13+
export type FeedbackPopoverProps = Omit<
14+
LGGuideCueProps,
15+
'currentStep' | 'numberOfSteps' | 'children' | 'title'
16+
> & {
17+
onSubmitFeedback: (text: string) => void;
18+
placeholder: string;
19+
label: string;
20+
};
21+
22+
export const FeedbackPopover = ({
23+
onSubmitFeedback,
24+
label,
25+
placeholder,
26+
setOpen,
27+
refEl,
28+
open,
29+
...props
30+
}: FeedbackPopoverProps) => {
31+
const [feedbackText, setFeedbackText] = useState('');
32+
const feedbackPopoverId = useId();
33+
34+
useEffect(() => {
35+
if (!open) {
36+
return;
37+
}
38+
const listener = (event: MouseEvent) => {
39+
const popover = document.querySelector(
40+
`[data-popoverid="feedback-popover-${feedbackPopoverId}"]`
41+
);
42+
if (!popover) {
43+
return;
44+
}
45+
46+
// Clicked within popover or the trigger.
47+
if (
48+
event.composedPath().includes(popover) ||
49+
event.composedPath().includes(refEl.current!)
50+
) {
51+
return;
52+
}
53+
54+
setOpen(false);
55+
};
56+
57+
document.addEventListener('mousedown', listener);
58+
return () => {
59+
document.removeEventListener('mousedown', listener);
60+
};
61+
}, [feedbackPopoverId, open, setOpen]);
62+
63+
const onTextAreaKeyDown = useCallback(
64+
(evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
65+
if (evt.key === 'Enter' && !evt.shiftKey) {
66+
evt.preventDefault();
67+
onSubmitFeedback(feedbackText);
68+
} else if (evt.key === 'Escape') {
69+
evt.preventDefault();
70+
setOpen(false);
71+
}
72+
},
73+
[feedbackText, setOpen, onSubmitFeedback]
74+
);
75+
76+
return (
77+
<LGGuideCue
78+
tooltipClassName={guideCueStyles}
79+
numberOfSteps={1}
80+
currentStep={1}
81+
data-popoverid={`feedback-popover-${feedbackPopoverId}`}
82+
title=""
83+
tooltipAlign="bottom"
84+
refEl={refEl}
85+
onPrimaryButtonClick={() => onSubmitFeedback(feedbackText)}
86+
buttonText="Submit"
87+
setOpen={setOpen}
88+
open={open}
89+
{...props}
90+
>
91+
<TextArea
92+
label={label}
93+
data-testid="feedback-popover-textarea"
94+
placeholder={placeholder}
95+
value={feedbackText}
96+
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) =>
97+
setFeedbackText(event.target.value)
98+
}
99+
onKeyDown={onTextAreaKeyDown}
100+
/>
101+
</LGGuideCue>
102+
);
103+
};

packages/compass-components/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export type IconGlyph = Extract<keyof typeof glyphs, string>;
135135

136136
export { EmptyContent } from './components/empty-content';
137137
export { ErrorBoundary } from './components/error-boundary';
138+
export { FeedbackPopover } from './components/feedback-popover';
138139
export { StoreConnector } from './components/store-connector';
139140
export { TabNavBar } from './components/tab-nav-bar';
140141
export { WorkspaceContainer } from './components/workspace-container';

packages/compass-query-bar/src/components/generative-ai/ai-text-input.spec.tsx

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import React from 'react';
22
import type { ComponentProps } from 'react';
3-
import { cleanup, render, screen } from '@testing-library/react';
3+
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
44
import { expect } from 'chai';
55
import sinon from 'sinon';
66
import type { SinonSpy } from 'sinon';
77
import { Provider } from 'react-redux';
88

99
import { AITextInput } from './ai-text-input';
1010
import { configureStore } from '../../stores/query-bar-store';
11-
import { changeAIPromptText } from '../../stores/ai-query-reducer';
11+
import {
12+
AIQueryActionTypes,
13+
changeAIPromptText,
14+
} from '../../stores/ai-query-reducer';
15+
import { DEFAULT_FIELD_VALUES } from '../../constants/query-bar-store';
16+
import { mapQueryToFormFields } from '../../utils/query';
1217

1318
const noop = () => {
1419
/* no op */
@@ -27,6 +32,8 @@ const renderAITextInput = ({
2732
return store;
2833
};
2934

35+
const feedbackPopoverTextAreaId = 'feedback-popover-textarea';
36+
3037
describe('QueryBar Component', function () {
3138
let store: ReturnType<typeof configureStore>;
3239
let onCloseSpy: SinonSpy;
@@ -69,4 +76,58 @@ describe('QueryBar Component', function () {
6976
expect(store.getState().aiQuery.aiPromptText).to.equal('');
7077
});
7178
});
79+
80+
describe('QueryFeedback', function () {
81+
beforeEach(function () {
82+
store = renderAITextInput({
83+
onClose: onCloseSpy,
84+
});
85+
});
86+
87+
it('should log a telemetry event with the entered text on submit', async function () {
88+
// Note: This is coupling this test with internals of the logger and telemetry.
89+
// We're doing this as this is a unique case where we're using telemetry
90+
// for feedback. Avoid repeating this elsewhere.
91+
const trackingLogs: any[] = [];
92+
process.on('compass:track', (event) => trackingLogs.push(event));
93+
94+
// No feedback popover is shown yet.
95+
expect(screen.queryByTestId(feedbackPopoverTextAreaId)).to.not.exist;
96+
expect(screen.queryByTestId('ai-query-feedback-thumbs-up')).to.not.exist;
97+
98+
store.dispatch({
99+
type: AIQueryActionTypes.AIQuerySucceeded,
100+
fields: mapQueryToFormFields(DEFAULT_FIELD_VALUES),
101+
});
102+
103+
expect(screen.queryByTestId(feedbackPopoverTextAreaId)).to.not.exist;
104+
const thumbsUpButton = screen.getByTestId('ai-query-feedback-thumbs-up');
105+
expect(thumbsUpButton).to.be.visible;
106+
thumbsUpButton.click();
107+
108+
const textArea = screen.getByTestId(feedbackPopoverTextAreaId);
109+
expect(textArea).to.be.visible;
110+
fireEvent.change(textArea, {
111+
target: { value: 'this is the query I was looking for' },
112+
});
113+
114+
screen.getByText('Submit').click();
115+
116+
// Let the track event occur.
117+
await new Promise((resolve) => setTimeout(resolve, 6));
118+
119+
// No feedback popover is shown.
120+
expect(screen.queryByTestId(feedbackPopoverTextAreaId)).to.not.exist;
121+
122+
expect(trackingLogs).to.deep.equal([
123+
{
124+
event: 'AIQuery Feedback',
125+
properties: {
126+
feedback: 'positive',
127+
text: 'this is the query I was looking for',
128+
},
129+
},
130+
]);
131+
});
132+
});
72133
});

0 commit comments

Comments
 (0)