feedbackModal.tsx 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325
  1. import React, {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 Field from 'sentry/components/forms/field';
  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. 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: location.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. location.pathname,
  141. closeModal,
  142. organization?.features,
  143. organization?.access,
  144. project?.features,
  145. user,
  146. props,
  147. state,
  148. ]
  149. );
  150. const ModalHeader = useCallback(
  151. ({children: headerChildren}: {children: React.ReactNode}) => {
  152. return (
  153. <Header closeButton>
  154. <h3>{headerChildren}</h3>
  155. </Header>
  156. );
  157. },
  158. [Header]
  159. );
  160. const ModalFooter = useCallback(
  161. ({
  162. onBack,
  163. onNext,
  164. submitEventData,
  165. primaryDisabledReason,
  166. secondaryAction,
  167. }: Parameters<ChildrenProps<T>['Footer']>[0]) => {
  168. return (
  169. <Footer>
  170. {secondaryAction && (
  171. <SecondaryActionWrapper>{secondaryAction}</SecondaryActionWrapper>
  172. )}
  173. {onBack && (
  174. <BackButtonWrapper>
  175. <Button onClick={onBack}>{t('Back')}</Button>
  176. </BackButtonWrapper>
  177. )}
  178. <ButtonBar gap={1}>
  179. <Button onClick={closeModal}>{t('Cancel')}</Button>
  180. <Button
  181. priority="primary"
  182. title={
  183. props.children === undefined
  184. ? !defined(state.subject)
  185. ? t('Required fields must be filled out')
  186. : undefined
  187. : primaryDisabledReason
  188. }
  189. onClick={onNext ?? (() => handleSubmit(submitEventData))}
  190. disabled={
  191. props.children === undefined
  192. ? !defined(state.subject)
  193. : defined(primaryDisabledReason)
  194. }
  195. >
  196. {onNext ? t('Next') : isScreenSmall ? t('Submit') : t('Submit Feedback')}
  197. </Button>
  198. </ButtonBar>
  199. </Footer>
  200. );
  201. },
  202. [Footer, isScreenSmall, closeModal, handleSubmit, state, props.children]
  203. );
  204. const ModalBody = useCallback(
  205. ({
  206. children: bodyChildren,
  207. showSelfHostedMessage = true,
  208. }: Parameters<ChildrenProps<T>['Body']>[0]) => {
  209. return (
  210. <Body>
  211. {bodyChildren}
  212. {isSelfHosted && showSelfHostedMessage && (
  213. <Alert type="info">
  214. {tct(
  215. "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.",
  216. {
  217. privacyPolicy: <ExternalLink href="https://sentry.io/privacy/" />,
  218. }
  219. )}
  220. </Alert>
  221. )}
  222. </Body>
  223. );
  224. },
  225. [Body, isSelfHosted]
  226. );
  227. function handleFieldChange<Field extends keyof T>(field: Field, value: T[Field]) {
  228. const newState = cloneDeep(state);
  229. newState[field] = value;
  230. setState(newState);
  231. }
  232. if (props.children === undefined) {
  233. const feedbackTypes = props.feedbackTypes ?? defaultFeedbackTypes;
  234. return (
  235. <Fragment>
  236. <ModalHeader>{t('Submit Feedback')}</ModalHeader>
  237. <ModalBody>
  238. <SelectField
  239. label={t('Type of feedback')}
  240. name="subject"
  241. inline={false}
  242. options={feedbackTypes.map((feedbackType, index) => ({
  243. value: index,
  244. label: feedbackType,
  245. }))}
  246. placeholder={t('Select type of feedback')}
  247. value={state.subject}
  248. onChange={value => setState({...state, subject: value})}
  249. flexibleControlStateSize
  250. stacked
  251. required
  252. />
  253. <Field
  254. label={t('Additional feedback')}
  255. inline={false}
  256. required={false}
  257. flexibleControlStateSize
  258. stacked
  259. >
  260. <Textarea
  261. name="additional-feedback"
  262. value={state.additionalInfo}
  263. rows={5}
  264. autosize
  265. placeholder={t('What did you expect?')}
  266. onChange={event =>
  267. setState({
  268. ...state,
  269. additionalInfo: event.target.value,
  270. })
  271. }
  272. />
  273. </Field>
  274. </ModalBody>
  275. <ModalFooter secondaryAction={props?.secondaryAction} />
  276. </Fragment>
  277. );
  278. }
  279. return (
  280. <Fragment>
  281. {props.children({
  282. Header: ModalHeader,
  283. Body: ModalBody,
  284. Footer: ModalFooter,
  285. onFieldChange: handleFieldChange,
  286. state,
  287. })}
  288. </Fragment>
  289. );
  290. }
  291. export const modalCss = css`
  292. width: 100%;
  293. max-width: 680px;
  294. `;
  295. const BackButtonWrapper = styled('div')`
  296. margin-right: ${space(1)};
  297. width: 100%;
  298. `;
  299. const SecondaryActionWrapper = styled('div')`
  300. flex: 1;
  301. align-self: center;
  302. `;