123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325 |
- import React, {Fragment, useCallback, useMemo, useState} from 'react';
- import {css, useTheme} from '@emotion/react';
- import styled from '@emotion/styled';
- import {
- BrowserClient,
- defaultIntegrations,
- defaultStackParser,
- makeFetchTransport,
- } from '@sentry/react';
- import {Event} from '@sentry/types';
- import cloneDeep from 'lodash/cloneDeep';
- import {addSuccessMessage} from 'sentry/actionCreators/indicator';
- import {ModalRenderProps} from 'sentry/actionCreators/modal';
- import Alert from 'sentry/components/alert';
- import Button from 'sentry/components/button';
- import ButtonBar from 'sentry/components/buttonBar';
- import Textarea from 'sentry/components/forms/controls/textarea';
- import Field from 'sentry/components/forms/field';
- import SelectField from 'sentry/components/forms/fields/selectField';
- import {Data} from 'sentry/components/forms/types';
- import ExternalLink from 'sentry/components/links/externalLink';
- import {t, tct} from 'sentry/locale';
- import ConfigStore from 'sentry/stores/configStore';
- import OrganizationStore from 'sentry/stores/organizationStore';
- import {useLegacyStore} from 'sentry/stores/useLegacyStore';
- import space from 'sentry/styles/space';
- import {defined} from 'sentry/utils';
- import {useLocation} from 'sentry/utils/useLocation';
- import useMedia from 'sentry/utils/useMedia';
- import useProjects from 'sentry/utils/useProjects';
- const feedbackClient = new BrowserClient({
- // feedback project under Sentry organization
- dsn: 'https://3c5ef4e344a04a0694d187a1272e96de@o1.ingest.sentry.io/6356259',
- transport: makeFetchTransport,
- stackParser: defaultStackParser,
- integrations: defaultIntegrations,
- });
- const defaultFeedbackTypes = [
- t("I don't like this feature"),
- t('I like this feature'),
- t('Other reason'),
- ];
- export type ChildrenProps<T> = {
- Body: (props: {
- children: React.ReactNode;
- showSelfHostedMessage?: boolean;
- }) => ReturnType<ModalRenderProps['Body']>;
- Footer: (props: {
- onBack?: () => void;
- onNext?: () => void;
- primaryDisabledReason?: string;
- secondaryAction?: React.ReactNode;
- submitEventData?: Event;
- }) => ReturnType<ModalRenderProps['Footer']>;
- Header: (props: {children: React.ReactNode}) => ReturnType<ModalRenderProps['Header']>;
- onFieldChange: <Field extends keyof T>(field: Field, value: T[Field]) => void;
- state: T;
- };
- type CustomFeedbackModal<T> = {
- children: (props: ChildrenProps<T>) => React.ReactNode;
- featureName: string;
- initialData: T;
- };
- type DefaultFeedbackModal = {
- featureName: string;
- children?: undefined;
- feedbackTypes?: string[];
- secondaryAction?: React.ReactNode;
- };
- export type FeedbackModalProps<T extends Data> =
- | DefaultFeedbackModal
- | CustomFeedbackModal<T>;
- export function FeedbackModal<T extends Data>({
- Header,
- Body,
- Footer,
- closeModal,
- ...props
- }: FeedbackModalProps<T> & ModalRenderProps) {
- const {organization} = useLegacyStore(OrganizationStore);
- const {projects, initiallyLoaded: projectsLoaded} = useProjects();
- const location = useLocation();
- const theme = useTheme();
- const user = ConfigStore.get('user');
- const isSelfHosted = ConfigStore.get('isSelfHosted');
- const [state, setState] = useState<T>(
- props.children === undefined
- ? ({subject: undefined, additionalInfo: undefined} as unknown as T)
- : props.initialData
- );
- const isScreenSmall = useMedia(`(max-width: ${theme.breakpoints.small})`);
- const project = useMemo(() => {
- if (projectsLoaded && location.query.project) {
- return projects.find(p => p.id === location.query.project);
- }
- return undefined;
- }, [projectsLoaded, projects, location.query.project]);
- const handleSubmit = useCallback(
- (submitEventData?: Event) => {
- const message = `${props.featureName} feedback by ${user.email}`;
- const commonEventProps: Event = {
- message,
- request: {
- url: location.pathname,
- },
- extra: {
- orgFeatures: organization?.features ?? [],
- orgAccess: organization?.access ?? [],
- projectFeatures: project?.features ?? [],
- },
- tags: {
- featureName: props.featureName,
- },
- user,
- level: 'info',
- };
- if (props.children === undefined) {
- const feedbackTypes = props.feedbackTypes ?? defaultFeedbackTypes;
- feedbackClient.captureEvent({
- ...commonEventProps,
- contexts: {
- feedback: {
- additionalInfo: state.additionalInfo?.trim() ? state.additionalInfo : null,
- },
- },
- message: state.additionalInfo?.trim()
- ? `${message} - ${feedbackTypes[state.subject]} - ${state.additionalInfo}`
- : `${message} - ${feedbackTypes[state.subject]}`,
- });
- } else {
- feedbackClient.captureEvent({
- ...commonEventProps,
- ...(submitEventData ?? {}),
- });
- }
- addSuccessMessage(t('Thanks for taking the time to provide us feedback!'));
- closeModal();
- },
- [
- location.pathname,
- closeModal,
- organization?.features,
- organization?.access,
- project?.features,
- user,
- props,
- state,
- ]
- );
- const ModalHeader = useCallback(
- ({children: headerChildren}: {children: React.ReactNode}) => {
- return (
- <Header closeButton>
- <h3>{headerChildren}</h3>
- </Header>
- );
- },
- [Header]
- );
- const ModalFooter = useCallback(
- ({
- onBack,
- onNext,
- submitEventData,
- primaryDisabledReason,
- secondaryAction,
- }: Parameters<ChildrenProps<T>['Footer']>[0]) => {
- return (
- <Footer>
- {secondaryAction && (
- <SecondaryActionWrapper>{secondaryAction}</SecondaryActionWrapper>
- )}
- {onBack && (
- <BackButtonWrapper>
- <Button onClick={onBack}>{t('Back')}</Button>
- </BackButtonWrapper>
- )}
- <ButtonBar gap={1}>
- <Button onClick={closeModal}>{t('Cancel')}</Button>
- <Button
- priority="primary"
- title={
- props.children === undefined
- ? !defined(state.subject)
- ? t('Required fields must be filled out')
- : undefined
- : primaryDisabledReason
- }
- onClick={onNext ?? (() => handleSubmit(submitEventData))}
- disabled={
- props.children === undefined
- ? !defined(state.subject)
- : defined(primaryDisabledReason)
- }
- >
- {onNext ? t('Next') : isScreenSmall ? t('Submit') : t('Submit Feedback')}
- </Button>
- </ButtonBar>
- </Footer>
- );
- },
- [Footer, isScreenSmall, closeModal, handleSubmit, state, props.children]
- );
- const ModalBody = useCallback(
- ({
- children: bodyChildren,
- showSelfHostedMessage = true,
- }: Parameters<ChildrenProps<T>['Body']>[0]) => {
- return (
- <Body>
- {bodyChildren}
- {isSelfHosted && showSelfHostedMessage && (
- <Alert type="info">
- {tct(
- "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.",
- {
- privacyPolicy: <ExternalLink href="https://sentry.io/privacy/" />,
- }
- )}
- </Alert>
- )}
- </Body>
- );
- },
- [Body, isSelfHosted]
- );
- function handleFieldChange<Field extends keyof T>(field: Field, value: T[Field]) {
- const newState = cloneDeep(state);
- newState[field] = value;
- setState(newState);
- }
- if (props.children === undefined) {
- const feedbackTypes = props.feedbackTypes ?? defaultFeedbackTypes;
- return (
- <Fragment>
- <ModalHeader>{t('Submit Feedback')}</ModalHeader>
- <ModalBody>
- <SelectField
- label={t('Type of feedback')}
- name="subject"
- inline={false}
- options={feedbackTypes.map((feedbackType, index) => ({
- value: index,
- label: feedbackType,
- }))}
- placeholder={t('Select type of feedback')}
- value={state.subject}
- onChange={value => setState({...state, subject: value})}
- flexibleControlStateSize
- stacked
- required
- />
- <Field
- label={t('Additional feedback')}
- inline={false}
- required={false}
- flexibleControlStateSize
- stacked
- >
- <Textarea
- name="additional-feedback"
- value={state.additionalInfo}
- rows={5}
- autosize
- placeholder={t('What did you expect?')}
- onChange={event =>
- setState({
- ...state,
- additionalInfo: event.target.value,
- })
- }
- />
- </Field>
- </ModalBody>
- <ModalFooter secondaryAction={props?.secondaryAction} />
- </Fragment>
- );
- }
- return (
- <Fragment>
- {props.children({
- Header: ModalHeader,
- Body: ModalBody,
- Footer: ModalFooter,
- onFieldChange: handleFieldChange,
- state,
- })}
- </Fragment>
- );
- }
- export const modalCss = css`
- width: 100%;
- max-width: 680px;
- `;
- const BackButtonWrapper = styled('div')`
- margin-right: ${space(1)};
- width: 100%;
- `;
- const SecondaryActionWrapper = styled('div')`
- flex: 1;
- align-self: center;
- `;
|