Skip to content

Commit ac69c0d

Browse files
authored
Merge pull request #1529 from jetstreamapp/feat/1528
feat: improve feedback widget
2 parents 68644a6 + 5ae0ba8 commit ac69c0d

File tree

4 files changed

+221
-9
lines changed

4 files changed

+221
-9
lines changed

.claude/settings.local.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,8 @@
1010
],
1111
"deny": [],
1212
"ask": []
13-
}
13+
},
14+
"enabledMcpjsonServers": [
15+
"nx-mcp"
16+
]
1417
}

libs/features/query/src/QueryBuilder/QueryBuilder.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,13 @@ export const QueryBuilder = () => {
543543
]}
544544
/>
545545
)}
546+
{/* Placeholder to allow over-scrolling */}
547+
<div
548+
aria-hidden="true"
549+
css={css`
550+
min-height: 100px;
551+
`}
552+
/>
546553
</AutoFullHeightContainer>
547554
</div>
548555
</Split>

libs/icon-factory/src/lib/icon-factory.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ import UtilityIcon_Formula from './icons/utility/Formula';
9595
import UtilityIcon_Forward from './icons/utility/Forward';
9696
import UtilityIcon_Help from './icons/utility/Help';
9797
import UtilityIcon_HelpDocExt from './icons/utility/HelpDocExt';
98+
import UtilityIcon_Hide from './icons/utility/Hide';
9899
import UtilityIcon_Home from './icons/utility/Home';
99100
import UtilityIcon_Identity from './icons/utility/Identity';
100101
import UtilityIcon_Image from './icons/utility/Image';
@@ -286,8 +287,9 @@ const utilityIcons = {
286287
filterList: UtilityIcon_FilterList,
287288
formula: UtilityIcon_Formula,
288289
forward: UtilityIcon_Forward,
289-
help_doc_ext: UtilityIcon_HelpDocExt,
290290
help: UtilityIcon_Help,
291+
help_doc_ext: UtilityIcon_HelpDocExt,
292+
hide: UtilityIcon_Hide,
291293
home: UtilityIcon_Home,
292294
identity: UtilityIcon_Identity,
293295
image: UtilityIcon_Image,

libs/ui/src/lib/user-feedback-widget/UserFeedbackWidget.tsx

Lines changed: 207 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { submitUserFeedback } from '@jetstream/shared/data';
44
import { InputReadFileContent } from '@jetstream/types';
55
import { fromAppState } from '@jetstream/ui/app-state';
66
import { useAtomValue } from 'jotai';
7-
import { useState } from 'react';
7+
import { useEffect, useRef, useState } from 'react';
88
import Checkbox from '../form/checkbox/Checkbox';
99
import FileSelector from '../form/file-selector/FileSelector';
1010
import { RadioButton } from '../form/radio/RadioButton';
@@ -16,8 +16,13 @@ import ScopedNotification from '../scoped-notification/ScopedNotification';
1616
import { fireToast } from '../toast/AppToast';
1717
import Icon from '../widgets/Icon';
1818
import Spinner from '../widgets/Spinner';
19+
import Tooltip from '../widgets/Tooltip';
1920

2021
export type FeedbackType = 'bug' | 'feature' | 'other' | 'testimonial';
22+
export type FeedbackPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
23+
24+
const STORAGE_KEY = 'jetstream-feedback-widget-position';
25+
const HIDDEN_STORAGE_KEY = 'jetstream-feedback-widget-hidden';
2126

2227
const allowFromClipboardAcceptType = /^image\/(png|jpg|jpeg|gif)$/;
2328

@@ -34,6 +39,19 @@ const getFeedbackHelpText = (type: FeedbackType): string => {
3439
}
3540
};
3641

42+
const getPositionStyles = (position: FeedbackPosition) => {
43+
switch (position) {
44+
case 'bottom-right':
45+
return { bottom: '1.5rem', right: '1.5rem' };
46+
case 'bottom-left':
47+
return { bottom: '1.5rem', left: '1.5rem' };
48+
case 'top-right':
49+
return { top: '1.5rem', right: '1.5rem' };
50+
case 'top-left':
51+
return { top: '1.5rem', left: '1.5rem' };
52+
}
53+
};
54+
3755
export const UserFeedbackWidget = () => {
3856
const [isOpen, setIsOpen] = useState(false);
3957
const [feedbackType, setFeedbackType] = useState<FeedbackType>('bug');
@@ -42,8 +60,48 @@ export const UserFeedbackWidget = () => {
4260
const [canFeatureTestimonial, setCanFeatureTestimonial] = useState(false);
4361
const [isSubmitting, setIsSubmitting] = useState(false);
4462
const [submitError, setSubmitError] = useState<string | null>(null);
63+
const [position, setPosition] = useState<FeedbackPosition>('bottom-right');
64+
const [isHidden, setIsHidden] = useState(false);
65+
const [showContextMenu, setShowContextMenu] = useState(false);
66+
const buttonRef = useRef<HTMLButtonElement>(null);
67+
const contextMenuRef = useRef<HTMLDivElement>(null);
4568
const { version: clientVersion } = useAtomValue(fromAppState.appInfoState);
4669

70+
// Load position from localStorage on mount
71+
useEffect(() => {
72+
const savedPosition = localStorage.getItem(STORAGE_KEY);
73+
if (savedPosition && ['bottom-right', 'bottom-left', 'top-right', 'top-left'].includes(savedPosition)) {
74+
setPosition(savedPosition as FeedbackPosition);
75+
}
76+
}, []);
77+
78+
// Load hidden state from sessionStorage on mount
79+
useEffect(() => {
80+
const isHiddenFromStorage = sessionStorage.getItem(HIDDEN_STORAGE_KEY) === 'true';
81+
if (isHiddenFromStorage) {
82+
setIsHidden(true);
83+
}
84+
}, []);
85+
86+
// Close context menu when clicking outside
87+
useEffect(() => {
88+
const handleClickOutside = (event: MouseEvent) => {
89+
if (
90+
contextMenuRef.current &&
91+
!contextMenuRef.current.contains(event.target as Node) &&
92+
buttonRef.current &&
93+
!buttonRef.current.contains(event.target as Node)
94+
) {
95+
setShowContextMenu(false);
96+
}
97+
};
98+
99+
if (showContextMenu) {
100+
document.addEventListener('mousedown', handleClickOutside);
101+
return () => document.removeEventListener('mousedown', handleClickOutside);
102+
}
103+
}, [showContextMenu]);
104+
47105
const handleOpen = () => {
48106
setIsOpen(true);
49107
setSubmitError(null);
@@ -108,19 +166,50 @@ export const UserFeedbackWidget = () => {
108166
setScreenshots((prev) => prev.filter((_, i) => i !== index));
109167
};
110168

169+
const handlePositionChange = (newPosition: FeedbackPosition) => {
170+
setPosition(newPosition);
171+
localStorage.setItem(STORAGE_KEY, newPosition);
172+
setShowContextMenu(false);
173+
};
174+
175+
const handleHideForSession = () => {
176+
setIsHidden(true);
177+
sessionStorage.setItem(HIDDEN_STORAGE_KEY, 'true');
178+
setShowContextMenu(false);
179+
fireToast({
180+
message: 'Feedback button hidden for this session and will be visible again when you open Jetstream in a new tab.',
181+
type: 'info',
182+
});
183+
};
184+
185+
const handleContextMenu = (event: React.MouseEvent) => {
186+
event.preventDefault();
187+
setShowContextMenu(true);
188+
};
189+
111190
const canSubmit = message.trim().length > 0;
191+
const positionStyles = getPositionStyles(position);
192+
193+
if (isHidden) {
194+
return null;
195+
}
112196

113197
return (
114198
<>
115199
{/* Floating feedback button */}
200+
116201
<button
202+
ref={buttonRef}
203+
aria-label="Send Feedback"
117204
className="slds-button slds-button_icon slds-button_icon-container slds-button_icon-border-filled"
118-
title="Send Feedback"
119205
onClick={handleOpen}
206+
onContextMenu={handleContextMenu}
120207
css={css`
121208
position: fixed;
122-
bottom: 1.5rem;
123-
right: 1.5rem;
209+
${positionStyles.top ? `top: ${positionStyles.top}` : ''};
210+
${positionStyles.bottom ? `bottom: ${positionStyles.bottom}` : ''};
211+
${positionStyles.left ? `left: ${positionStyles.left}` : ''};
212+
${positionStyles.right ? `right: ${positionStyles.right}` : ''};
124213
z-index: 9000;
125214
width: 3.5rem;
126215
height: 3.5rem;
@@ -129,7 +218,7 @@ export const UserFeedbackWidget = () => {
129218
background-color: #0176d3;
130219
border-color: #0176d3;
131220
transition: all 0.2s ease;
132-
opacity: 0.75;
221+
opacity: 0.6;
133222
134223
&:hover {
135224
background-color: #014486;
@@ -142,10 +231,121 @@ export const UserFeedbackWidget = () => {
142231
}
143232
`}
144233
>
145-
<Icon type="standard" icon="feedback" className="" omitContainer />
146-
<span className="slds-assistive-text">Send Feedback</span>
234+
<Tooltip
235+
content={isOpen || showContextMenu ? undefined : 'Send us your feedback. Right click for positioning options.'}
236+
openDelay={300}
237+
>
238+
<Icon type="standard" icon="feedback" className="" omitContainer />
239+
<span className="slds-assistive-text">Send Feedback</span>
240+
</Tooltip>
147241
</button>
148242

243+
{/* Context menu for positioning */}
244+
{showContextMenu && (
245+
<div
246+
ref={contextMenuRef}
247+
css={css`
248+
position: fixed;
249+
${positionStyles.top ? `top: calc(${positionStyles.top} + 4rem)` : ''};
250+
${positionStyles.bottom ? `bottom: calc(${positionStyles.bottom} + 4rem)` : ''};
251+
${positionStyles.left ? `left: ${positionStyles.left}` : ''};
252+
${positionStyles.right ? `right: ${positionStyles.right}` : ''};
253+
z-index: 5001;
254+
background: white;
255+
border-radius: 0.25rem;
256+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
257+
min-width: 200px;
258+
padding: 0.5rem 0;
259+
`}
260+
>
261+
<div
262+
css={css`
263+
padding: 0.5rem 1rem;
264+
font-size: 0.75rem;
265+
font-weight: 600;
266+
color: #3e3e3c;
267+
text-transform: uppercase;
268+
letter-spacing: 0.025em;
269+
`}
270+
>
271+
Button Position
272+
</div>
273+
{(['bottom-right', 'bottom-left', 'top-right', 'top-left'] as FeedbackPosition[]).map((pos) => (
274+
<button
275+
key={pos}
276+
onClick={() => handlePositionChange(pos)}
277+
css={css`
278+
display: flex;
279+
align-items: center;
280+
width: 100%;
281+
padding: 0.5rem 1rem;
282+
border: none;
283+
background: ${pos === position ? '#f3f2f2' : 'transparent'};
284+
text-align: left;
285+
cursor: pointer;
286+
font-size: 0.875rem;
287+
color: #181818;
288+
transition: background-color 0.1s ease;
289+
290+
&:hover {
291+
background-color: #f3f2f2;
292+
}
293+
`}
294+
>
295+
{pos === position && (
296+
<Icon
297+
type="utility"
298+
icon="check"
299+
className="slds-icon slds-icon-text-success slds-icon_xx-small"
300+
containerClassname="slds-m-right_x-small"
301+
/>
302+
)}
303+
<span style={{ marginLeft: pos === position ? '0' : '1.25rem' }}>
304+
{pos
305+
.split('-')
306+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
307+
.join(' ')}
308+
</span>
309+
</button>
310+
))}
311+
<div
312+
css={css`
313+
height: 1px;
314+
background-color: #dddbda;
315+
margin: 0.5rem 0;
316+
`}
317+
/>
318+
<button
319+
onClick={handleHideForSession}
320+
css={css`
321+
display: flex;
322+
align-items: center;
323+
width: 100%;
324+
padding: 0.5rem 1rem;
325+
border: none;
326+
background: transparent;
327+
text-align: left;
328+
cursor: pointer;
329+
font-size: 0.875rem;
330+
color: #181818;
331+
transition: background-color 0.1s ease;
332+
333+
&:hover {
334+
background-color: #f3f2f2;
335+
}
336+
`}
337+
>
338+
<Icon
339+
type="utility"
340+
icon="hide"
341+
className="slds-icon slds-icon-text-default slds-icon_xx-small"
342+
containerClassname="slds-m-right_x-small"
343+
/>
344+
Hide for session
345+
</button>
346+
</div>
347+
)}
348+
149349
{/* Feedback modal */}
150350
{isOpen && (
151351
<Modal

0 commit comments

Comments
 (0)