Browse Source

Sentry Functions: Display Sentry Functions on integrations page (#37625)

* feat(integrations): modification to integrations modal and integrations page

* style(integrations): clean up sentry function rows to look more like other integrations

* style(integrations): clean up sentry function details to look more like other integrations

* ref(integrations): remove extraneous component, refactor spacing

* feat(integrations): add analytics to integration modal for sentry fx
Vignesh P 2 years ago
parent
commit
e57f3df381

+ 30 - 0
static/app/actionCreators/sentryFunctions.tsx

@@ -0,0 +1,30 @@
+import {Client} from 'sentry/api';
+import {t, tct} from 'sentry/locale';
+import {Organization, SentryFunction} from 'sentry/types';
+
+import {
+  addErrorMessage,
+  addLoadingMessage,
+  addSuccessMessage,
+  clearIndicators,
+} from './indicator';
+
+export async function removeSentryFunction(
+  client: Client,
+  org: Organization,
+  sentryFn: SentryFunction
+) {
+  addLoadingMessage();
+  try {
+    await client.requestPromise(
+      `/organizations/${org.slug}/functions/${sentryFn.slug}/`,
+      {
+        method: 'DELETE',
+      }
+    );
+    addSuccessMessage(tct('[name] successfully deleted.', {name: sentryFn.name}));
+  } catch (err) {
+    clearIndicators();
+    addErrorMessage(err?.responseJSON?.detail || t('Unknown Error'));
+  }
+}

+ 24 - 4
static/app/components/modals/createNewIntegrationModal.tsx

@@ -84,6 +84,20 @@ function CreateNewIntegrationModal({
     ],
   ] as [string, ReactNode, ReactNode][];
 
+  if (organization.features.includes('sentry-functions')) {
+    choices.push([
+      'sentry-fx',
+      <RadioChoiceHeader data-test-id="sentry-function" key="header-sentryfx">
+        {t('Sentry Function')}
+      </RadioChoiceHeader>,
+      <RadioChoiceDescription key="description-sentry-function">
+        {t(
+          'A Sentry Function is a new type of integration leveraging the power of cloud functions.'
+        )}
+      </RadioChoiceDescription>,
+    ]);
+  }
+
   return (
     <Fragment>
       <Header>
@@ -107,12 +121,18 @@ function CreateNewIntegrationModal({
         <Button
           priority="primary"
           size="sm"
-          to={`/settings/${organization.slug}/developer-settings/${
-            option === 'public' ? 'new-public' : 'new-internal'
-          }/`}
+          to={
+            option === 'sentry-fx'
+              ? `/settings/${organization.slug}/developer-settings/sentry-functions/new/`
+              : `/settings/${organization.slug}/developer-settings/${
+                  option === 'public' ? 'new-public' : 'new-internal'
+                }/`
+          }
           onClick={() => {
             trackIntegrationAnalytics(
-              option === 'public'
+              option === 'sentry-fx'
+                ? PlatformEvents.CHOSE_SENTRY_FX
+                : option === 'public'
                 ? PlatformEvents.CHOSE_PUBLIC
                 : PlatformEvents.CHOSE_INTERNAL,
               {

+ 2 - 0
static/app/utils/analytics/integrations/platformAnalyticsEvents.ts

@@ -8,6 +8,7 @@ export enum PlatformEvents {
   INTERNAL_DOCS = 'integrations.platform_internal_docs_clicked',
   CHOSE_PUBLIC = 'integrations.platform_create_modal_public_clicked',
   PUBLIC_DOCS = 'integrations.platform_public_docs_clicked',
+  CHOSE_SENTRY_FX = 'integrations.platform_create_modal_sentry_fx_clicked',
 }
 
 export type PlatformEventParameters = {
@@ -25,6 +26,7 @@ export const platformEventMap: Record<PlatformEvents, string> = {
     'Integrations: Platform Internal Integration Docs Clicked',
   [PlatformEvents.CHOSE_PUBLIC]: 'Integrations: Platform Chose Public Integration',
   [PlatformEvents.PUBLIC_DOCS]: 'Integrations: Platform Public Integration Docs Clicked',
+  [PlatformEvents.CHOSE_SENTRY_FX]: 'Integrations: Platform Chose Sentry FX',
 };
 
 export const platformEventLinkMap: Partial<Record<PlatformEvents, string>> = {

+ 66 - 6
static/app/views/settings/organizationDeveloperSettings/index.tsx

@@ -3,12 +3,13 @@ import {RouteComponentProps} from 'react-router';
 import styled from '@emotion/styled';
 
 import {removeSentryApp} from 'sentry/actionCreators/sentryApps';
+import {removeSentryFunction} from 'sentry/actionCreators/sentryFunctions';
 import ExternalLink from 'sentry/components/links/externalLink';
 import NavTabs from 'sentry/components/navTabs';
 import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
 import {t, tct} from 'sentry/locale';
 import space from 'sentry/styles/space';
-import {Organization, SentryApp} from 'sentry/types';
+import {Organization, SentryApp, SentryFunction} from 'sentry/types';
 import {
   platformEventLinkMap,
   PlatformEvents,
@@ -23,11 +24,13 @@ import EmptyMessage from 'sentry/views/settings/components/emptyMessage';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import SentryApplicationRow from 'sentry/views/settings/organizationDeveloperSettings/sentryApplicationRow';
 
+import SentryFunctionRow from './sentryFunctionRow';
+
 type Props = Omit<AsyncView['props'], 'params'> & {
   organization: Organization;
 } & RouteComponentProps<{orgId: string}, {}>;
 
-type Tab = 'public' | 'internal';
+type Tab = 'public' | 'internal' | 'sentryfx';
 type State = AsyncView['state'] & {
   applications: SentryApp[];
   tab: Tab;
@@ -39,12 +42,14 @@ class OrganizationDeveloperSettings extends AsyncView<Props, State> {
   getDefaultState(): State {
     const {location} = this.props;
     const value =
-      (['public', 'internal'] as const).find(tab => tab === location?.query?.type) ||
-      'internal';
+      (['public', 'internal', 'sentryfx'] as const).find(
+        tab => tab === location?.query?.type
+      ) || 'internal';
 
     return {
       ...super.getDefaultState(),
       applications: [],
+      sentryFunctions: [],
       tab: value,
     };
   }
@@ -60,8 +65,14 @@ class OrganizationDeveloperSettings extends AsyncView<Props, State> {
 
   getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
     const {orgId} = this.props.params;
-
-    return [['applications', `/organizations/${orgId}/sentry-apps/`]];
+    const {organization} = this.props;
+    const returnValue: [string, string, any?, any?][] = [
+      ['applications', `/organizations/${orgId}/sentry-apps/`],
+    ];
+    if (organization.features.includes('sentry-functions')) {
+      returnValue.push(['sentryFunctions', `/organizations/${orgId}/functions/`]);
+    }
+    return returnValue;
   }
 
   removeApp = (app: SentryApp) => {
@@ -74,10 +85,54 @@ class OrganizationDeveloperSettings extends AsyncView<Props, State> {
     );
   };
 
+  removeFunction = (organization: Organization, sentryFunction: SentryFunction) => {
+    const functionsToKeep = this.state.sentryFunctions?.filter(
+      fn => fn.name !== sentryFunction.name
+    );
+    if (!functionsToKeep) {
+      return;
+    }
+    removeSentryFunction(this.api, organization, sentryFunction).then(
+      () => {
+        this.setState({sentryFunctions: functionsToKeep});
+      },
+      () => {}
+    );
+  };
+
   onTabChange = (value: Tab) => {
     this.setState({tab: value});
   };
 
+  renderSentryFunction = (sentryFunction: SentryFunction) => {
+    const {organization} = this.props;
+    return (
+      <SentryFunctionRow
+        key={organization.slug + sentryFunction.name}
+        organization={organization}
+        sentryFunction={sentryFunction}
+        onRemoveFunction={this.removeFunction}
+      />
+    );
+  };
+
+  renderSentryFunctions() {
+    const {sentryFunctions} = this.state;
+
+    return (
+      <Panel>
+        <PanelHeader>{t('Sentry Functions')}</PanelHeader>
+        <PanelBody>
+          {sentryFunctions?.length ? (
+            sentryFunctions.map(this.renderSentryFunction)
+          ) : (
+            <EmptyMessage>{t('No Sentry Functions have been created yet.')}</EmptyMessage>
+          )}
+        </PanelBody>
+      </Panel>
+    );
+  }
+
   renderApplicationRow = (app: SentryApp) => {
     const {organization} = this.props;
     return (
@@ -134,6 +189,8 @@ class OrganizationDeveloperSettings extends AsyncView<Props, State> {
 
   renderTabContent(tab: Tab) {
     switch (tab) {
+      case 'sentryfx':
+        return this.renderSentryFunctions();
       case 'internal':
         return this.renderInternalIntegrations();
       case 'public':
@@ -147,6 +204,9 @@ class OrganizationDeveloperSettings extends AsyncView<Props, State> {
       ['internal', t('Internal Integration')],
       ['public', t('Public Integration')],
     ] as [id: Tab, label: string][];
+    if (organization.features.includes('sentry-functions')) {
+      tabs.push(['sentryfx', t('Sentry Function')]);
+    }
 
     return (
       <div>

+ 2 - 33
static/app/views/settings/organizationDeveloperSettings/sentryFunctionDetails.tsx

@@ -9,15 +9,13 @@ import {
 } from 'sentry/actionCreators/indicator';
 import Feature from 'sentry/components/acl/feature';
 import AsyncComponent from 'sentry/components/asyncComponent';
-import Button from 'sentry/components/button';
 import Form from 'sentry/components/forms/form';
 import JsonForm from 'sentry/components/forms/jsonForm';
 import FormModel from 'sentry/components/forms/model';
 import {Field} from 'sentry/components/forms/type';
 import {Panel, PanelBody, PanelHeader} from 'sentry/components/panels';
-import {t, tct} from 'sentry/locale';
+import {t} from 'sentry/locale';
 import {SentryFunction} from 'sentry/types';
-import useApi from 'sentry/utils/useApi';
 
 import SentryFunctionEnvironmentVariables from './sentryFunctionsEnvironmentVariables';
 import SentryFunctionSubscriptions from './sentryFunctionSubscriptions';
@@ -95,7 +93,6 @@ const formFields: Field[] = [
 ];
 
 function SentryFunctionDetails(props: Props) {
-  const api = useApi();
   const form = useRef(new SentryFunctionFormModel());
   const {orgId, functionSlug} = props.params;
   const {sentryFunction} = props;
@@ -136,7 +133,6 @@ function SentryFunctionDetails(props: Props) {
   const handleSubmitSuccess = data => {
     addSuccessMessage(t('Sentry Function successfully saved.', data.name));
     const baseUrl = `/settings/${orgId}/developer-settings/sentry-functions/`;
-    // TODO: should figure out where to redirect this
     const url = `${baseUrl}${data.slug}/`;
     if (sentryFunction) {
       addSuccessMessage(t('%s successfully saved.', data.name));
@@ -150,27 +146,11 @@ function SentryFunctionDetails(props: Props) {
     form.current.setValue('code', value);
   }
 
-  async function handleDelete() {
-    try {
-      await api.requestPromise(endpoint, {
-        method: 'DELETE',
-      });
-      addSuccessMessage(t('Sentry Function successfully deleted.'));
-      // TODO: Not sure where to redirect to, so just redirect to the unbuilt Sentry Functions page
-      browserHistory.push(`/settings/${orgId}/developer-settings/sentry-functions/`);
-    } catch (err) {
-      addErrorMessage(err?.responseJSON?.detail || t('Unknown Error'));
-    }
-  }
-
   return (
     <div>
       <Feature features={['organizations:sentry-functions']}>
-        <h1>{t('Sentry Function Details')}</h1>
         <h2>
-          {sentryFunction
-            ? tct('Editing [name]', {name: sentryFunction.name})
-            : t('New Function')}
+          {sentryFunction ? t('Editing Sentry Function') : t('Create Sentry Function')}
         </h2>
         <Form
           apiMethod={method}
@@ -220,17 +200,6 @@ function SentryFunctionDetails(props: Props) {
             </PanelBody>
           </Panel>
         </Form>
-        {sentryFunction && (
-          <Button
-            onClick={handleDelete}
-            title={t('Delete Sentry Function')}
-            aria-label={t('Delete Sentry Function')}
-            type="button"
-            priority="danger"
-          >
-            {t('Delete Sentry Function')}
-          </Button>
-        )}
       </Feature>
     </div>
   );

+ 38 - 0
static/app/views/settings/organizationDeveloperSettings/sentryFunctionRow/actionButtons.tsx

@@ -0,0 +1,38 @@
+import styled from '@emotion/styled';
+
+import Button from 'sentry/components/button';
+import {IconDelete} from 'sentry/icons';
+import space from 'sentry/styles/space';
+import {Organization, SentryFunction} from 'sentry/types';
+
+type Props = {
+  onDelete: (org: Organization, sentryFn: SentryFunction) => void;
+  org: Organization;
+  sentryFn: SentryFunction;
+};
+
+const ActionButtons = ({org, sentryFn, onDelete}: Props) => {
+  const deleteButton = (
+    <StyledButton
+      size="sm"
+      icon={<IconDelete />}
+      aria-label="Delete"
+      onClick={() => onDelete(org, sentryFn)}
+    />
+  );
+  return <ButtonHolder>{deleteButton}</ButtonHolder>;
+};
+
+const StyledButton = styled(Button)`
+  color: ${p => p.theme.subText};
+`;
+
+const ButtonHolder = styled('div')`
+  flex-direction: row;
+  display: flex;
+  & > * {
+    margin-left: ${space(0.5)};
+  }
+`;
+
+export default ActionButtons;

+ 68 - 0
static/app/views/settings/organizationDeveloperSettings/sentryFunctionRow/index.tsx

@@ -0,0 +1,68 @@
+import {Link} from 'react-router';
+import styled from '@emotion/styled';
+
+import {PanelItem} from 'sentry/components/panels';
+import {IconInput} from 'sentry/icons';
+import space from 'sentry/styles/space';
+import {Organization, SentryFunction} from 'sentry/types';
+
+import ActionButtons from '../sentryFunctionRow/actionButtons';
+
+type Props = {
+  onRemoveFunction: (org: Organization, sentryFn: SentryFunction) => void;
+  organization: Organization;
+  sentryFunction: SentryFunction;
+};
+
+export default function SentryFunctionRow(props: Props) {
+  const {onRemoveFunction, organization, sentryFunction} = props;
+
+  return (
+    <SentryFunctionHolder>
+      <StyledFlex>
+        <IconInput size="xl" />
+        <SentryFunctionBox>
+          <SentryFunctionName>
+            <Link
+              to={`/settings/${organization.slug}/developer-settings/sentry-functions/${sentryFunction.slug}/`}
+            >
+              {sentryFunction.name}
+            </Link>
+          </SentryFunctionName>
+        </SentryFunctionBox>
+        <Box>
+          <ActionButtons
+            org={organization}
+            sentryFn={sentryFunction}
+            onDelete={onRemoveFunction}
+          />
+        </Box>
+      </StyledFlex>
+    </SentryFunctionHolder>
+  );
+}
+
+const Flex = styled('div')`
+  display: flex;
+`;
+
+const Box = styled('div')``;
+
+const SentryFunctionHolder = styled(PanelItem)`
+  flex-direction: column;
+  padding: ${space(0.5)};
+`;
+
+const StyledFlex = styled(Flex)`
+  justify-content: center;
+  padding: ${space(1)};
+`;
+
+const SentryFunctionBox = styled('div')`
+  padding: 0 15px;
+  flex: 1;
+`;
+
+const SentryFunctionName = styled('div')`
+  margin-top: 10px;
+`;