feedbackModal.tsx 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import {Fragment, useCallback, useMemo, useState} from 'react';
  2. import {css, useTheme} from '@emotion/react';
  3. import styled from '@emotion/styled';
  4. import {
  5. BrowserClient,
  6. defaultIntegrations,
  7. defaultStackParser,
  8. makeFetchTransport,
  9. } from '@sentry/react';
  10. import {Event} from '@sentry/types';
  11. import cloneDeep from 'lodash/cloneDeep';
  12. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  13. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  14. import {Alert} from 'sentry/components/alert';
  15. import {Button} from 'sentry/components/button';
  16. import ButtonBar from 'sentry/components/buttonBar';
  17. import Textarea from 'sentry/components/forms/controls/textarea';
  18. import FieldGroup from 'sentry/components/forms/fieldGroup';
  19. import SelectField from 'sentry/components/forms/fields/selectField';
  20. import {Data} from 'sentry/components/forms/types';
  21. import ExternalLink from 'sentry/components/links/externalLink';
  22. import {t, tct} from 'sentry/locale';
  23. import ConfigStore from 'sentry/stores/configStore';
  24. import OrganizationStore from 'sentry/stores/organizationStore';
  25. import {useLegacyStore} from 'sentry/stores/useLegacyStore';
  26. import {space} from 'sentry/styles/space';
  27. import {defined} from 'sentry/utils';
  28. import {useLocation} from 'sentry/utils/useLocation';
  29. import useMedia from 'sentry/utils/useMedia';
  30. import useProjects from 'sentry/utils/useProjects';
  31. export const feedbackClient = new BrowserClient({
  32. // feedback project under Sentry organization
  33. dsn: 'https://3c5ef4e344a04a0694d187a1272e96de@o1.ingest.sentry.io/6356259',
  34. transport: makeFetchTransport,
  35. stackParser: defaultStackParser,
  36. integrations: defaultIntegrations,
  37. });
  38. const defaultFeedbackTypes = [
  39. t("I don't like this feature"),
  40. t('I like this feature'),
  41. t('Other reason'),
  42. ];
  43. export type ChildrenProps<T> = {
  44. Body: (props: {
  45. children: React.ReactNode;
  46. showSelfHostedMessage?: boolean;
  47. }) => ReturnType<ModalRenderProps['Body']>;
  48. Footer: (props: {
  49. onBack?: () => void;
  50. onNext?: () => void;
  51. primaryDisabledReason?: string;
  52. secondaryAction?: React.ReactNode;
  53. submitEventData?: Event;
  54. }) => ReturnType<ModalRenderProps['Footer']>;
  55. Header: (props: {children: React.ReactNode}) => ReturnType<ModalRenderProps['Header']>;
  56. onFieldChange: <Field extends keyof T>(field: Field, value: T[Field]) => void;
  57. state: T;
  58. };
  59. type CustomFeedbackModal<T> = {
  60. children: (props: ChildrenProps<T>) => React.ReactNode;
  61. featureName: string;
  62. initialData: T;
  63. };
  64. type DefaultFeedbackModal = {
  65. featureName: string;
  66. children?: undefined;
  67. feedbackTypes?: string[];
  68. secondaryAction?: React.ReactNode;
  69. };
  70. export type FeedbackModalProps<T extends Data> =
  71. | DefaultFeedbackModal
  72. | CustomFeedbackModal<T>;
  73. export function FeedbackModal<T extends Data>({
  74. Header,
  75. Body,
  76. Footer,
  77. closeModal,
  78. ...props
  79. }: FeedbackModalProps<T> & ModalRenderProps) {
  80. const {organization} = useLegacyStore(OrganizationStore);
  81. const {projects, initiallyLoaded: projectsLoaded} = useProjects();
  82. const location = useLocation();
  83. const theme = useTheme();
  84. const user = ConfigStore.get('user');
  85. const isSelfHosted = ConfigStore.get('isSelfHosted');
  86. const [state, setState] = useState<T>(
  87. props.children === undefined
  88. ? ({subject: undefined, additionalInfo: undefined} as unknown as T)
  89. : props.initialData
  90. );
  91. const isScreenSmall = useMedia(`(max-width: ${theme.breakpoints.small})`);
  92. const project = useMemo(() => {
  93. if (projectsLoaded && location.query.project) {
  94. return projects.find(p => p.id === location.query.project);
  95. }
  96. return undefined;
  97. }, [projectsLoaded, projects, location.query.project]);
  98. const handleSubmit = useCallback(
  99. (submitEventData?: Event) => {
  100. const message = `${props.featureName} feedback by ${user.email}`;
  101. const commonEventProps: Event = {
  102. message,
  103. request: {
  104. url: window.location.href, // gives the full url (origin + pathname)
  105. },
  106. extra: {
  107. orgFeatures: organization?.features ?? [],
  108. orgAccess: organization?.access ?? [],
  109. projectFeatures: project?.features ?? [],
  110. },
  111. tags: {
  112. featureName: props.featureName,
  113. },
  114. user,
  115. level: 'info',
  116. };
  117. if (props.children === undefined) {
  118. const feedbackTypes = props.feedbackTypes ?? defaultFeedbackTypes;
  119. feedbackClient.captureEvent({
  120. ...commonEventProps,
  121. contexts: {
  122. feedback: {
  123. additionalInfo: state.additionalInfo?.trim() ? state.additionalInfo : null,
  124. },
  125. },
  126. message: state.additionalInfo?.trim()
  127. ? `${message} - ${feedbackTypes[state.subject]} - ${state.additionalInfo}`
  128. : `${message} - ${feedbackTypes[state.subject]}`,
  129. });
  130. } else {
  131. feedbackClient.captureEvent({
  132. ...commonEventProps,
  133. ...(submitEventData ?? {}),
  134. });
  135. }
  136. addSuccessMessage(t('Thanks for taking the time to provide us feedback!'));
  137. closeModal();
  138. },
  139. [
  140. closeModal,
  141. organization?.features,
  142. organization?.access,
  143. project?.features,
  144. user,
  145. props,
  146. state,
  147. ]
  148. );
  149. const ModalHeader = useCallback(
  150. ({children: headerChildren}: {children: React.ReactNode}) => {
  151. return (
  152. <Header closeButton>
  153. <h3>{headerChildren}</h3>
  154. </Header>
  155. );
  156. },
  157. [Header]
  158. );
  159. const ModalFooter = useCallback(
  160. ({
  161. onBack,
  162. onNext,
  163. submitEventData,
  164. primaryDisabledReason,
  165. secondaryAction,
  166. }: Parameters<ChildrenProps<T>['Footer']>[0]) => {
  167. return (
  168. <Footer>
  169. {secondaryAction && (
  170. <SecondaryActionWrapper>{secondaryAction}</SecondaryActionWrapper>
  171. )}
  172. {onBack && (
  173. <BackButtonWrapper>
  174. <Button onClick={onBack}>{t('Back')}</Button>
  175. </BackButtonWrapper>
  176. )}
  177. <ButtonBar gap={1}>
  178. <Button onClick={closeModal}>{t('Cancel')}</Button>
  179. <Button
  180. priority="primary"
  181. title={
  182. props.children === undefined
  183. ? !defined(state.subject)
  184. ? t('Required fields must be filled out')
  185. : undefined
  186. : primaryDisabledReason
  187. }
  188. onClick={onNext ?? (() => handleSubmit(submitEventData))}
  189. disabled={
  190. props.children === undefined
  191. ? !defined(state.subject)
  192. : defined(primaryDisabledReason)
  193. }
  194. >
  195. {onNext ? t('Next') : isScreenSmall ? t('Submit') : t('Submit Feedback')}
  196. </Button>
  197. </ButtonBar>
  198. </Footer>
  199. );
  200. },
  201. [Footer, isScreenSmall, closeModal, handleSubmit, state, props.children]
  202. );
  203. const ModalBody = useCallback(
  204. ({
  205. children: bodyChildren,
  206. showSelfHostedMessage = true,
  207. }: Parameters<ChildrenProps<T>['Body']>[0]) => {
  208. return (
  209. <Body>
  210. {bodyChildren}
  211. {isSelfHosted && showSelfHostedMessage && (
  212. <Alert type="info">
  213. {tct(
  214. "You agree that any feedback you submit is subject to Sentry's [privacyPolicy:Privacy Policy] and Sentry may use such feedback without restriction or obligation.",
  215. {
  216. privacyPolicy: <ExternalLink href="https://sentry.io/privacy/" />,
  217. }
  218. )}
  219. </Alert>
  220. )}
  221. </Body>
  222. );
  223. },
  224. [Body, isSelfHosted]
  225. );
  226. function handleFieldChange<Field extends keyof T>(field: Field, value: T[Field]) {
  227. const newState = cloneDeep(state);
  228. newState[field] = value;
  229. setState(newState);
  230. }
  231. if (props.children === undefined) {
  232. const feedbackTypes = props.feedbackTypes ?? defaultFeedbackTypes;
  233. return (
  234. <Fragment>
  235. <ModalHeader>{t('Submit Feedback')}</ModalHeader>
  236. <ModalBody>
  237. <SelectField
  238. label={t('Type of feedback')}
  239. name="subject"
  240. inline={false}
  241. options={feedbackTypes.map((feedbackType, index) => ({
  242. value: index,
  243. label: feedbackType,
  244. }))}
  245. placeholder={t('Select type of feedback')}
  246. value={state.subject}
  247. onChange={value => setState({...state, subject: value})}
  248. flexibleControlStateSize
  249. stacked
  250. required
  251. />
  252. <FieldGroup
  253. label={t('Additional feedback')}
  254. inline={false}
  255. required={false}
  256. flexibleControlStateSize
  257. stacked
  258. >
  259. <Textarea
  260. name="additional-feedback"
  261. value={state.additionalInfo}
  262. rows={5}
  263. autosize
  264. placeholder={t('What did you expect?')}
  265. onChange={event =>
  266. setState({
  267. ...state,
  268. additionalInfo: event.target.value,
  269. })
  270. }
  271. />
  272. </FieldGroup>
  273. </ModalBody>
  274. <ModalFooter secondaryAction={props?.secondaryAction} />
  275. </Fragment>
  276. );
  277. }
  278. return (
  279. <Fragment>
  280. {props.children({
  281. Header: ModalHeader,
  282. Body: ModalBody,
  283. Footer: ModalFooter,
  284. onFieldChange: handleFieldChange,
  285. state,
  286. })}
  287. </Fragment>
  288. );
  289. }
  290. export const modalCss = css`
  291. width: 100%;
  292. max-width: 680px;
  293. `;
  294. const BackButtonWrapper = styled('div')`
  295. margin-right: ${space(1)};
  296. width: 100%;
  297. `;
  298. const SecondaryActionWrapper = styled('div')`
  299. flex: 1;
  300. align-self: center;
  301. `;