Browse Source

ref(sampling): Add activation modal groundwork (#36296)

Priscila Oliveira 2 years ago
parent
commit
1632591ef8

+ 3 - 1
static/app/components/alert.tsx

@@ -11,6 +11,7 @@ import {Theme} from 'sentry/utils/theme';
 
 export interface AlertProps extends React.HTMLAttributes<HTMLDivElement> {
   expand?: React.ReactNode;
+  icon?: React.ReactNode;
   opaque?: boolean;
   showIcon?: boolean;
   system?: boolean;
@@ -23,6 +24,7 @@ const DEFAULT_TYPE = 'info';
 function Alert({
   type = DEFAULT_TYPE,
   showIcon = false,
+  icon,
   opaque,
   system,
   expand,
@@ -74,7 +76,7 @@ function Alert({
     >
       {showIcon && (
         <IconWrapper onClick={handleClick} {...iconHoverProps}>
-          {getIcon()}
+          {icon ?? getIcon()}
         </IconWrapper>
       )}
       <ContentWrapper>

+ 7 - 2
static/app/views/settings/project/server-side-sampling/index.tsx

@@ -2,11 +2,16 @@ import Feature from 'sentry/components/acl/feature';
 import FeatureDisabled from 'sentry/components/acl/featureDisabled';
 import {PanelAlert} from 'sentry/components/panels';
 import {t} from 'sentry/locale';
+import {Project} from 'sentry/types';
 import useOrganization from 'sentry/utils/useOrganization';
 
 import {ServerSideSampling} from './serverSideSampling';
 
-export default function ServerSideSamplingContainer() {
+type Props = {
+  project: Project;
+};
+
+export default function ServerSideSamplingContainer({project}: Props) {
   const organization = useOrganization();
 
   return (
@@ -21,7 +26,7 @@ export default function ServerSideSamplingContainer() {
         />
       )}
     >
-      <ServerSideSampling />
+      <ServerSideSampling project={project} />
     </Feature>
   );
 }

+ 107 - 0
static/app/views/settings/project/server-side-sampling/modals/activateModal.tsx

@@ -0,0 +1,107 @@
+import {Fragment, useState} from 'react';
+import styled from '@emotion/styled';
+
+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 CheckboxFancy from 'sentry/components/checkboxFancy/checkboxFancy';
+import FieldRequiredBadge from 'sentry/components/forms/field/fieldRequiredBadge';
+import Link from 'sentry/components/links/link';
+import {IconWarning} from 'sentry/icons';
+import {t, tct} from 'sentry/locale';
+import space from 'sentry/styles/space';
+import {SamplingRule} from 'sentry/types/sampling';
+
+import {SERVER_SIDE_SAMPLING_DOC_LINK} from '../utils';
+
+type Props = ModalRenderProps & {
+  rule: SamplingRule;
+};
+
+export function ActivateModal({Header, Body, Footer, closeModal}: Props) {
+  const [understandConsequences, setUnderstandConsequences] = useState(false);
+
+  function handleActivate() {
+    // TODO(sampling): add activation logic here
+    try {
+      closeModal();
+    } catch {
+      // to nothing
+    }
+  }
+
+  return (
+    <Fragment>
+      <Header closeButton>
+        <h4>{t('Activate Rule')}</h4>
+      </Header>
+      <Body>
+        <Alert type="error" showIcon icon={<IconWarning />}>
+          {tct(
+            'Applying server-side sampling without first updating the Sentry SDK versions could sharply decrease the amount of accepted transactions. [link:Resolve now].',
+            {
+              // TODO(sampling): Add a link to the second step of the wizard once it is implemented
+              link: <Link to="" />,
+            }
+          )}
+        </Alert>
+        <Field>
+          <CheckboxFancy
+            isChecked={understandConsequences}
+            onClick={() => setUnderstandConsequences(!understandConsequences)}
+            aria-label={
+              understandConsequences ? t('Uncheck to disagree') : t('Check to agree')
+            }
+          />
+          <FieldLabel>{t('I understand the consequences\u2026')}</FieldLabel>
+          <FieldRequiredBadge />
+        </Field>
+      </Body>
+      <Footer>
+        <FooterActions>
+          <Button href={SERVER_SIDE_SAMPLING_DOC_LINK} external>
+            {t('Read Docs')}
+          </Button>
+
+          <ButtonBar gap={1}>
+            <Button onClick={closeModal}>{t('Cancel')}</Button>
+            <Button
+              priority="danger"
+              disabled={!understandConsequences}
+              title={
+                !understandConsequences
+                  ? t('Required fields must be filled out')
+                  : undefined
+              }
+              onClick={handleActivate}
+            >
+              {t('Activate Rule')}
+            </Button>
+          </ButtonBar>
+        </FooterActions>
+      </Footer>
+    </Fragment>
+  );
+}
+
+const FooterActions = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  flex: 1;
+  gap: ${space(1)};
+`;
+
+const Field = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(3, max-content);
+  align-items: flex-start;
+  line-height: 1;
+`;
+
+const FieldLabel = styled('div')`
+  margin-left: ${space(1)};
+  /* match the height of the checkbox */
+  line-height: 16px;
+`;

+ 3 - 0
static/app/views/settings/project/server-side-sampling/rule.tsx

@@ -25,6 +25,7 @@ type Props = {
   hideGrabButton: boolean;
   listeners: DraggableSyntheticListeners;
   noPermission: boolean;
+  onActivate: () => void;
   onDeleteRule: () => void;
   onEditRule: () => void;
   operator: SamplingRuleOperator;
@@ -44,6 +45,7 @@ export function Rule({
   noPermission,
   onEditRule,
   onDeleteRule,
+  onActivate,
   listeners,
   operator,
   grabAttributes,
@@ -128,6 +130,7 @@ export function Rule({
           inline={false}
           hideControlState
           aria-label={rule.active ? t('Deactivate Rule') : t('Activate Rule')}
+          onClick={onActivate}
           name="active"
         />
       </ActiveColumn>

+ 118 - 155
static/app/views/settings/project/server-side-sampling/serverSideSampling.tsx

@@ -1,33 +1,25 @@
-import {Fragment, useEffect, useState} from 'react';
+import {Fragment, useState} from 'react';
 import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
 import {openModal} from 'sentry/actionCreators/modal';
 import Button from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
-import LoadingError from 'sentry/components/loadingError';
-import LoadingIndicator from 'sentry/components/loadingIndicator';
 import {Panel, PanelFooter, PanelHeader} from 'sentry/components/panels';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {IconAdd} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import space from 'sentry/styles/space';
 import {Project} from 'sentry/types';
-import {
-  SamplingRuleOperator,
-  SamplingRules,
-  SamplingRuleType,
-} from 'sentry/types/sampling';
-import handleXhrErrorResponse from 'sentry/utils/handleXhrErrorResponse';
-import useApi from 'sentry/utils/useApi';
+import {SamplingRule, SamplingRuleOperator, SamplingRules} from 'sentry/types/sampling';
 import useOrganization from 'sentry/utils/useOrganization';
-import {useParams} from 'sentry/utils/useParams';
 import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
 import TextBlock from 'sentry/views/settings/components/text/textBlock';
 import PermissionAlert from 'sentry/views/settings/organization/permissionAlert';
 
 import {DraggableList} from '../sampling/rules/draggableList';
 
+import {ActivateModal} from './modals/activateModal';
 import {UniformRateModal} from './modals/uniformRateModal';
 import {Promo} from './promo';
 import {
@@ -41,46 +33,20 @@ import {
 } from './rule';
 import {SERVER_SIDE_SAMPLING_DOC_LINK} from './utils';
 
-export function ServerSideSampling() {
-  const api = useApi();
+type Props = {
+  project: Project;
+};
+
+export function ServerSideSampling({project}: Props) {
   const organization = useOrganization();
-  const params = useParams();
   const hasAccess = organization.access.includes('project:write');
+  const dynamicSamplingRules = project.dynamicSampling?.rules ?? [];
 
-  const {orgId: orgSlug, projectId: projectSlug} = params;
-
-  const [rules, setRules] = useState<SamplingRules>([]);
-  const [project, setProject] = useState<Project>();
-  const [loading, setLoading] = useState(true);
-  const [error, setError] = useState<string | undefined>(undefined);
-
-  useEffect(() => {
-    async function fetchRules() {
-      try {
-        // TODO(sampling): no need to fetch project here, settings pages get it via props for free
-        const projectDetails = await api.requestPromise(
-          `/projects/${orgSlug}/${projectSlug}/`
-        );
-
-        const {dynamicSampling} = projectDetails;
-        const samplingRules: SamplingRules = dynamicSampling?.rules ?? [];
+  const [rules, _setRules] = useState<SamplingRules>(dynamicSamplingRules);
 
-        const traceRules = samplingRules.filter(
-          samplingRule => samplingRule.type === SamplingRuleType.TRACE
-        );
-
-        setRules(traceRules);
-        setProject(projectDetails);
-        setLoading(false);
-      } catch (err) {
-        const errorMessage = t('Unable to load sampling rules');
-        handleXhrErrorResponse(errorMessage)(err);
-        setError(errorMessage);
-        setLoading(false);
-      }
-    }
-    fetchRules();
-  }, [api, projectSlug, orgSlug]);
+  function handleActivateToggle(rule: SamplingRule) {
+    openModal(modalProps => <ActivateModal {...modalProps} rule={rule} />);
+  }
 
   function handleGetStarted() {
     openModal(modalProps => (
@@ -111,117 +77,114 @@ export function ServerSideSampling() {
             'These settings can only be edited by users with the organization owner, manager, or admin role.'
           )}
         />
-        {error && <LoadingError message={error} />}
-        {!error && loading && <LoadingIndicator />}
-        {!error && !loading && (
-          <RulesPanel>
-            <RulesPanelHeader lightText>
-              <RulesPanelLayout>
-                <GrabColumn />
-                <OperatorColumn>{t('Operator')}</OperatorColumn>
-                <ConditionColumn>{t('Condition')}</ConditionColumn>
-                <RateColumn>{t('Rate')}</RateColumn>
-                <ActiveColumn>{t('Active')}</ActiveColumn>
-                <Column />
-              </RulesPanelLayout>
-            </RulesPanelHeader>
-            {!rules.length && (
-              <Promo onGetStarted={handleGetStarted} hasAccess={hasAccess} />
-            )}
-            {!!rules.length && (
-              <Fragment>
-                <DraggableList
-                  disabled={!hasAccess}
-                  items={items}
-                  onUpdateItems={() => {}}
-                  wrapperStyle={({isDragging, isSorting, index}) => {
-                    if (isDragging) {
-                      return {
-                        cursor: 'grabbing',
-                      };
-                    }
-                    if (isSorting) {
-                      return {};
-                    }
+        <RulesPanel>
+          <RulesPanelHeader lightText>
+            <RulesPanelLayout>
+              <GrabColumn />
+              <OperatorColumn>{t('Operator')}</OperatorColumn>
+              <ConditionColumn>{t('Condition')}</ConditionColumn>
+              <RateColumn>{t('Rate')}</RateColumn>
+              <ActiveColumn>{t('Active')}</ActiveColumn>
+              <Column />
+            </RulesPanelLayout>
+          </RulesPanelHeader>
+          {!rules.length && (
+            <Promo onGetStarted={handleGetStarted} hasAccess={hasAccess} />
+          )}
+          {!!rules.length && (
+            <Fragment>
+              <DraggableList
+                disabled={!hasAccess}
+                items={items}
+                onUpdateItems={() => {}}
+                wrapperStyle={({isDragging, isSorting, index}) => {
+                  if (isDragging) {
                     return {
-                      transform: 'none',
-                      transformOrigin: '0',
-                      '--box-shadow': 'none',
-                      '--box-shadow-picked-up': 'none',
-                      overflow: 'visible',
-                      position: 'relative',
-                      zIndex: rules.length - index,
-                      cursor: 'default',
+                      cursor: 'grabbing',
                     };
-                  }}
-                  renderItem={({value, listeners, attributes, dragging, sorting}) => {
-                    const itemsRuleIndex = items.findIndex(item => item.id === value);
-
-                    if (itemsRuleIndex === -1) {
-                      return null;
+                  }
+                  if (isSorting) {
+                    return {};
+                  }
+                  return {
+                    transform: 'none',
+                    transformOrigin: '0',
+                    '--box-shadow': 'none',
+                    '--box-shadow-picked-up': 'none',
+                    overflow: 'visible',
+                    position: 'relative',
+                    zIndex: rules.length - index,
+                    cursor: 'default',
+                  };
+                }}
+                renderItem={({value, listeners, attributes, dragging, sorting}) => {
+                  const itemsRuleIndex = items.findIndex(item => item.id === value);
+
+                  if (itemsRuleIndex === -1) {
+                    return null;
+                  }
+
+                  const itemsRule = items[itemsRuleIndex];
+
+                  const currentRule = {
+                    active: itemsRule.active,
+                    condition: itemsRule.condition,
+                    sampleRate: itemsRule.sampleRate,
+                    type: itemsRule.type,
+                    id: Number(itemsRule.id),
+                  };
+
+                  return (
+                    <RulesPanelLayout isContent>
+                      <Rule
+                        operator={
+                          itemsRule.id === items[0].id
+                            ? SamplingRuleOperator.IF
+                            : itemsRule.bottomPinned
+                            ? SamplingRuleOperator.ELSE
+                            : SamplingRuleOperator.ELSE_IF
+                        }
+                        hideGrabButton={items.length === 1}
+                        rule={{
+                          ...currentRule,
+                          bottomPinned: itemsRule.bottomPinned,
+                        }}
+                        onEditRule={() => {}}
+                        onDeleteRule={() => {}}
+                        onActivate={() => handleActivateToggle(currentRule)}
+                        noPermission={!hasAccess}
+                        listeners={listeners}
+                        grabAttributes={attributes}
+                        dragging={dragging}
+                        sorting={sorting}
+                      />
+                    </RulesPanelLayout>
+                  );
+                }}
+              />
+              <RulesPanelFooter>
+                <ButtonBar gap={1}>
+                  <Button href={SERVER_SIDE_SAMPLING_DOC_LINK} external>
+                    {t('Read Docs')}
+                  </Button>
+                  <AddRuleButton
+                    disabled={!hasAccess}
+                    title={
+                      !hasAccess
+                        ? t("You don't have permission to add a rule")
+                        : undefined
                     }
-
-                    const itemsRule = items[itemsRuleIndex];
-
-                    const currentRule = {
-                      active: itemsRule.active,
-                      condition: itemsRule.condition,
-                      sampleRate: itemsRule.sampleRate,
-                      type: itemsRule.type,
-                      id: Number(itemsRule.id),
-                    };
-
-                    return (
-                      <RulesPanelLayout isContent>
-                        <Rule
-                          operator={
-                            itemsRule.id === items[0].id
-                              ? SamplingRuleOperator.IF
-                              : itemsRule.bottomPinned
-                              ? SamplingRuleOperator.ELSE
-                              : SamplingRuleOperator.ELSE_IF
-                          }
-                          hideGrabButton={items.length === 1}
-                          rule={{
-                            ...currentRule,
-                            bottomPinned: itemsRule.bottomPinned,
-                          }}
-                          onEditRule={() => {}}
-                          onDeleteRule={() => {}}
-                          noPermission={!hasAccess}
-                          listeners={listeners}
-                          grabAttributes={attributes}
-                          dragging={dragging}
-                          sorting={sorting}
-                        />
-                      </RulesPanelLayout>
-                    );
-                  }}
-                />
-                <RulesPanelFooter>
-                  <ButtonBar gap={1}>
-                    <Button href={SERVER_SIDE_SAMPLING_DOC_LINK} external>
-                      {t('Read Docs')}
-                    </Button>
-                    <AddRuleButton
-                      disabled={!hasAccess}
-                      title={
-                        !hasAccess
-                          ? t("You don't have permission to add a rule")
-                          : undefined
-                      }
-                      priority="primary"
-                      onClick={() => {}}
-                      icon={<IconAdd isCircled />}
-                    >
-                      {t('Add Rule')}
-                    </AddRuleButton>
-                  </ButtonBar>
-                </RulesPanelFooter>
-              </Fragment>
-            )}
-          </RulesPanel>
-        )}
+                    priority="primary"
+                    onClick={() => {}}
+                    icon={<IconAdd isCircled />}
+                  >
+                    {t('Add Rule')}
+                  </AddRuleButton>
+                </ButtonBar>
+              </RulesPanelFooter>
+            </Fragment>
+          )}
+        </RulesPanel>
       </Fragment>
     </SentryDocumentTitle>
   );

+ 112 - 0
tests/js/spec/views/settings/project/server-side-sampling/activateModal.spec.tsx

@@ -0,0 +1,112 @@
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+  render,
+  screen,
+  userEvent,
+  waitForElementToBeRemoved,
+  within,
+} from 'sentry-test/reactTestingLibrary';
+import {textWithMarkupMatcher} from 'sentry-test/utils';
+
+import GlobalModal from 'sentry/components/globalModal';
+import {OrganizationContext} from 'sentry/views/organizationContext';
+import {RouteContext} from 'sentry/views/routeContext';
+import ServerSideSampling from 'sentry/views/settings/project/server-side-sampling';
+import {SERVER_SIDE_SAMPLING_DOC_LINK} from 'sentry/views/settings/project/server-side-sampling/utils';
+
+describe('Server-side Sampling - Activate Modal', function () {
+  const {organization, project, router} = initializeOrg({
+    ...initializeOrg(),
+    organization: {
+      ...initializeOrg().organization,
+      features: ['server-side-sampling'],
+    },
+    projects: [
+      TestStubs.Project({
+        dynamicSampling: {
+          rules: [
+            {
+              sampleRate: 0.2,
+              type: 'trace',
+              condition: {
+                op: 'and',
+                inner: [
+                  {
+                    op: 'glob',
+                    name: 'trace.release',
+                    value: ['1.2.3'],
+                  },
+                ],
+              },
+              id: 40,
+            },
+          ],
+          next_id: 41,
+        },
+      }),
+    ],
+  });
+
+  it('renders modal', async function () {
+    render(
+      <RouteContext.Provider
+        value={{
+          router,
+          location: router.location,
+          params: {
+            orgId: organization.slug,
+            projectId: project.slug,
+          },
+          routes: [],
+        }}
+      >
+        <GlobalModal />
+        <OrganizationContext.Provider value={organization}>
+          <ServerSideSampling project={project} />
+        </OrganizationContext.Provider>
+      </RouteContext.Provider>
+    );
+
+    // Rules Panel Content
+    userEvent.click(screen.getByLabelText('Activate Rule'));
+
+    const dialog = await screen.findByRole('dialog');
+
+    // Dialog Header
+    expect(screen.getByRole('heading', {name: 'Activate Rule'})).toBeInTheDocument();
+
+    // Dialog Content
+    expect(
+      screen.getByText(
+        textWithMarkupMatcher(
+          'Applying server-side sampling without first updating the Sentry SDK versions could sharply decrease the amount of accepted transactions. Resolve now.'
+        )
+      )
+    ).toBeInTheDocument();
+    expect(screen.getByRole('checkbox', {name: 'Check to agree'})).not.toBeChecked();
+    expect(screen.getByText(/I understand the consequences/)).toBeInTheDocument();
+
+    // Dialog Footer
+    expect(within(dialog).getByRole('button', {name: 'Read Docs'})).toHaveAttribute(
+      'href',
+      SERVER_SIDE_SAMPLING_DOC_LINK
+    );
+    expect(screen.getByRole('button', {name: 'Cancel'})).toBeEnabled();
+    expect(screen.getByRole('button', {name: 'Activate Rule'})).toBeDisabled();
+
+    // Agree with consequences
+    userEvent.click(screen.getByRole('checkbox', {name: 'Check to agree'}));
+    expect(
+      screen.getByRole('checkbox', {name: 'Uncheck to disagree'})
+    ).toBeInTheDocument();
+    expect(screen.getByRole('button', {name: 'Activate Rule'})).toBeEnabled();
+
+    // Submit form
+    userEvent.click(screen.getByRole('button', {name: 'Activate Rule'}));
+
+    // Dialog should close
+    await waitForElementToBeRemoved(() =>
+      screen.queryByRole('heading', {name: 'Activate Rule'})
+    );
+  });
+});

+ 17 - 19
tests/js/spec/views/settings/project/server-side-sampling/index.spec.tsx

@@ -1,26 +1,26 @@
 import {initializeOrg} from 'sentry-test/initializeOrg';
 import {render, screen} from 'sentry-test/reactTestingLibrary';
 
+import {Project} from 'sentry/types';
 import {OrganizationContext} from 'sentry/views/organizationContext';
 import {RouteContext} from 'sentry/views/routeContext';
 import ServerSideSampling from 'sentry/views/settings/project/server-side-sampling';
 import {SERVER_SIDE_SAMPLING_DOC_LINK} from 'sentry/views/settings/project/server-side-sampling/utils';
 
-describe('Server-side Sampling', function () {
-  const {organization, project, router} = initializeOrg({
+function getMockData(project?: Project) {
+  return initializeOrg({
     ...initializeOrg(),
     organization: {
       ...initializeOrg().organization,
       features: ['server-side-sampling'],
     },
+    projects: [project],
   });
+}
 
-  it('renders onboarding promo', async function () {
-    MockApiClient.addMockResponse({
-      url: '/projects/org-slug/project-slug/',
-      method: 'GET',
-      body: TestStubs.Project(),
-    });
+describe('Server-side Sampling', function () {
+  it('renders onboarding promo', function () {
+    const {router, organization, project} = getMockData();
 
     const {container} = render(
       <RouteContext.Provider
@@ -35,7 +35,7 @@ describe('Server-side Sampling', function () {
         }}
       >
         <OrganizationContext.Provider value={organization}>
-          <ServerSideSampling />
+          <ServerSideSampling project={project} />
         </OrganizationContext.Provider>
       </RouteContext.Provider>
     );
@@ -51,7 +51,7 @@ describe('Server-side Sampling', function () {
     ).toBeInTheDocument();
 
     expect(
-      await screen.findByRole('heading', {name: 'No sampling rules active yet'})
+      screen.getByRole('heading', {name: 'No sampling rules active yet'})
     ).toBeInTheDocument();
 
     expect(
@@ -68,11 +68,9 @@ describe('Server-side Sampling', function () {
     expect(container).toSnapshot();
   });
 
-  it('renders rules panel', async function () {
-    MockApiClient.addMockResponse({
-      url: '/projects/org-slug/project-slug/',
-      method: 'GET',
-      body: TestStubs.Project({
+  it('renders rules panel', function () {
+    const {router, organization, project} = getMockData(
+      TestStubs.Project({
         dynamicSampling: {
           rules: [
             {
@@ -93,8 +91,8 @@ describe('Server-side Sampling', function () {
           ],
           next_id: 41,
         },
-      }),
-    });
+      })
+    );
 
     const {container} = render(
       <RouteContext.Provider
@@ -109,13 +107,13 @@ describe('Server-side Sampling', function () {
         }}
       >
         <OrganizationContext.Provider value={organization}>
-          <ServerSideSampling />
+          <ServerSideSampling project={project} />
         </OrganizationContext.Provider>
       </RouteContext.Provider>
     );
 
     // Rule Panel Header
-    expect(await screen.findByText('Operator')).toBeInTheDocument();
+    expect(screen.getByText('Operator')).toBeInTheDocument();
     expect(screen.getByText('Condition')).toBeInTheDocument();
     expect(screen.getByText('Rate')).toBeInTheDocument();
     expect(screen.getByText('Active')).toBeInTheDocument();