feedbackModal.tsx 9.3 KB

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