Browse Source

ref(sampling): Make uniform rule not configurable - (#39918)

Priscila Oliveira 2 years ago
parent
commit
ebd5d88ccf

+ 6 - 156
static/app/views/settings/project/dynamicSampling/dynamicSamping.spec.tsx

@@ -159,8 +159,8 @@ describe('Dynamic Sampling', function () {
     expect(screen.getByTestId('sampling-rule')).toHaveTextContent('If');
     expect(screen.getByTestId('sampling-rule')).toHaveTextContent('All');
     expect(screen.getByTestId('sampling-rule')).toHaveTextContent('100%');
-    expect(screen.getByLabelText('Activate Rule')).toBeInTheDocument();
-    expect(screen.getByLabelText('Actions')).toBeInTheDocument();
+    expect(screen.queryByLabelText('Activate Rule')).not.toBeInTheDocument();
+    expect(screen.queryByLabelText('Actions')).not.toBeInTheDocument();
 
     // Rule Panel Footer
     expect(screen.getByText('Add Rule')).toBeInTheDocument();
@@ -172,76 +172,6 @@ describe('Dynamic Sampling', function () {
     expect(container).toSnapshot();
   });
 
-  it('does not let you delete the base rule', async function () {
-    const {organization, router, project} = initializeOrg({
-      ...initializeOrg(),
-      organization: {
-        ...initializeOrg().organization,
-        features,
-      },
-      projects: [
-        TestStubs.Project({
-          dynamicSampling: {
-            rules: [
-              {
-                sampleRate: 0.2,
-                type: 'trace',
-                active: false,
-                condition: {
-                  op: 'and',
-                  inner: [
-                    {
-                      op: 'glob',
-                      name: 'trace.release',
-                      value: ['1.2.3'],
-                    },
-                  ],
-                },
-                id: 2,
-              },
-              {
-                sampleRate: 0.2,
-                type: 'trace',
-                active: false,
-                condition: {
-                  op: 'and',
-                  inner: [],
-                },
-                id: 1,
-              },
-            ],
-            next_id: 3,
-          },
-        }),
-      ],
-    });
-
-    renderMockRequests({
-      organizationSlug: organization.slug,
-      projectSlug: project.slug,
-    });
-
-    render(
-      <TestComponent router={router} organization={organization} project={project} />
-    );
-
-    // Assert that project breakdown is there (avoids 'act' warnings)
-    expect(await screen.findByText(samplingBreakdownTitle)).toBeInTheDocument();
-
-    userEvent.click(screen.getAllByLabelText('Actions')[0]);
-    expect(screen.getByRole('menuitemradio', {name: 'Delete'})).toHaveAttribute(
-      'aria-disabled',
-      'false'
-    );
-
-    userEvent.click(screen.getAllByLabelText('Actions')[0]);
-    userEvent.click(screen.getAllByLabelText('Actions')[1]);
-    expect(screen.getByRole('menuitemradio', {name: 'Delete'})).toHaveAttribute(
-      'aria-disabled',
-      'true'
-    );
-  });
-
   it('display "update sdk versions" alert and open "recommended next step" modal', async function () {
     const {organization, router, projects} = initializeOrg({
       ...initializeOrg(),
@@ -490,7 +420,10 @@ describe('Dynamic Sampling', function () {
       projects: [
         TestStubs.Project({
           dynamicSampling: {
-            rules: [TestStubs.DynamicSamplingConfig().uniformRule],
+            rules: [
+              TestStubs.DynamicSamplingConfig().uniformRule,
+              TestStubs.DynamicSamplingConfig().specificRule,
+            ],
           },
         }),
       ],
@@ -517,87 +450,4 @@ describe('Dynamic Sampling', function () {
       )
     ).toBeInTheDocument();
   });
-
-  it('does not let the user activate an uniform rule if still processing', async function () {
-    const {organization, router, project} = initializeOrg({
-      ...initializeOrg(),
-      organization: {
-        ...initializeOrg().organization,
-        features,
-      },
-      projects: [
-        TestStubs.Project({
-          dynamicSampling: {
-            rules: [TestStubs.DynamicSamplingConfig().uniformRule],
-          },
-        }),
-      ],
-    });
-
-    renderMockRequests({
-      organizationSlug: organization.slug,
-      projectSlug: project.slug,
-      mockedSdkVersionsResponse: [],
-    });
-
-    render(
-      <TestComponent router={router} organization={organization} project={project} />
-    );
-
-    expect(await screen.findByRole('checkbox', {name: 'Activate Rule'})).toBeDisabled();
-
-    userEvent.hover(screen.getByLabelText('Activate Rule'));
-
-    expect(
-      await screen.findByText(
-        'We are processing sampling information for your project, so you cannot enable the rule yet. Please check again later'
-      )
-    ).toBeInTheDocument();
-  });
-
-  it('does not let user reorder uniform rule', async function () {
-    const {organization, router, project} = initializeOrg({
-      ...initializeOrg(),
-      organization: {
-        ...initializeOrg().organization,
-        features,
-      },
-      projects: [
-        TestStubs.Project({
-          dynamicSampling: {
-            rules: [
-              TestStubs.DynamicSamplingConfig().specificRule,
-              TestStubs.DynamicSamplingConfig().uniformRule,
-            ],
-          },
-        }),
-      ],
-    });
-
-    renderMockRequests({
-      organizationSlug: organization.slug,
-      projectSlug: project.slug,
-    });
-
-    render(
-      <TestComponent
-        organization={organization}
-        project={project}
-        router={router}
-        withModal
-      />
-    );
-
-    const samplingUniformRule = screen.getAllByTestId('sampling-rule')[1];
-
-    expect(
-      within(samplingUniformRule).getByRole('button', {name: 'Drag Rule'})
-    ).toHaveAttribute('aria-disabled', 'true');
-
-    userEvent.hover(within(samplingUniformRule).getByLabelText('Drag Rule'));
-
-    expect(
-      await screen.findByText('Uniform rules cannot be reordered')
-    ).toBeInTheDocument();
-  });
 });

+ 40 - 102
static/app/views/settings/project/dynamicSampling/dynamicSampling.tsx

@@ -1,7 +1,7 @@
 import {Fragment, useCallback, useEffect, useState} from 'react';
-import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 import isEqual from 'lodash/isEqual';
+import partition from 'lodash/partition';
 
 import {
   addErrorMessage,
@@ -14,7 +14,6 @@ import {
   fetchSamplingDistribution,
   fetchSamplingSdkVersions,
 } from 'sentry/actionCreators/serverSideSampling';
-import GuideAnchor from 'sentry/components/assistant/guideAnchor';
 import Button from 'sentry/components/button';
 import ButtonBar from 'sentry/components/buttonBar';
 import FeatureBadge from 'sentry/components/featureBadge';
@@ -49,7 +48,7 @@ import {
   ActiveColumn,
   Column,
   ConditionColumn,
-  GrabColumn,
+  DragColumn,
   OperatorColumn,
   RateColumn,
   Rule,
@@ -58,9 +57,9 @@ import {SamplingBreakdown} from './samplingBreakdown';
 import {SamplingFeedback} from './samplingFeedback';
 import {SamplingFromOtherProject} from './samplingFromOtherProject';
 import {SamplingProjectIncompatibleAlert} from './samplingProjectIncompatibleAlert';
-import {SamplingPromo} from './samplingPromo';
 import {SamplingSDKClientRateChangeAlert} from './samplingSDKClientRateChangeAlert';
 import {SamplingSDKUpgradesAlert} from './samplingSDKUpgradesAlert';
+import {RulesPanelLayout, UniformRule} from './uniformRule';
 import {isUniformRule, SERVER_SIDE_SAMPLING_DOC_LINK} from './utils';
 
 type Props = {
@@ -237,54 +236,26 @@ export function DynamicSampling({project}: Props) {
       addErrorMessage(message);
     }
 
-    if (isUniformRule(rule)) {
-      trackAdvancedAnalyticsEvent(
-        rule.active
-          ? 'sampling.settings.rule.uniform_deactivate'
-          : 'sampling.settings.rule.uniform_activate',
-        {
-          organization,
-          project_id: project.id,
-          sampling_rate: rule.sampleRate,
-        }
-      );
-    } else {
-      const analyticsConditions = rule.condition.inner.map(condition => condition.name);
-      const analyticsConditionsStringified = analyticsConditions.sort().join(', ');
-
-      trackAdvancedAnalyticsEvent(
-        rule.active
-          ? 'sampling.settings.rule.specific_deactivate'
-          : 'sampling.settings.rule.specific_activate',
-        {
-          organization,
-          project_id: project.id,
-          sampling_rate: rule.sampleRate,
-          conditions: analyticsConditions,
-          conditions_stringified: analyticsConditionsStringified,
-        }
-      );
-    }
-  }
-
-  function handleGetStarted() {
-    trackAdvancedAnalyticsEvent('sampling.settings.view_get_started', {
-      organization,
-      project_id: project.id,
-    });
-
-    navigate(`${samplingProjectSettingsPath}rules/uniform/`);
+    const analyticsConditions = rule.condition.inner.map(condition => condition.name);
+    const analyticsConditionsStringified = analyticsConditions.sort().join(', ');
+
+    trackAdvancedAnalyticsEvent(
+      rule.active
+        ? 'sampling.settings.rule.specific_deactivate'
+        : 'sampling.settings.rule.specific_activate',
+      {
+        organization,
+        project_id: project.id,
+        sampling_rate: rule.sampleRate,
+        conditions: analyticsConditions,
+        conditions_stringified: analyticsConditionsStringified,
+      }
+    );
   }
 
   async function handleSortRules({
-    overIndex,
     reorderedItems: ruleIds,
   }: DraggableRuleListUpdateItemsProps) {
-    if (!rules[overIndex].condition.inner.length) {
-      addErrorMessage(t('Specific rules cannot be below uniform rules'));
-      return;
-    }
-
     const sortedRules = ruleIds
       .map(ruleId => rules.find(rule => String(rule.id) === ruleId))
       .filter(rule => !!rule) as SamplingRule[];
@@ -337,14 +308,10 @@ export function DynamicSampling({project}: Props) {
     }
   }
 
-  // Rules without a condition (Else case) always have to be 'pinned' to the bottom of the list
-  // and cannot be sorted
-  const items = rules.map(rule => ({
-    ...rule,
-    id: String(rule.id),
-  }));
+  const [uniformRules, specificRules] = partition(rules, isUniformRule);
 
-  const uniformRule = rules.find(isUniformRule);
+  const uniformRule = uniformRules[0];
+  const items = specificRules.map(rule => ({...rule, id: String(rule.id)}));
 
   return (
     <SentryDocumentTitle title={t('Dynamic Sampling')}>
@@ -411,16 +378,12 @@ export function DynamicSampling({project}: Props) {
 
         {hasAccess && <SamplingBreakdown orgSlug={organization.slug} />}
         {!rules.length ? (
-          <SamplingPromo
-            onGetStarted={handleGetStarted}
-            onReadDocs={handleReadDocs}
-            hasAccess={hasAccess}
-          />
+          <div>wip</div> // TODO(sampling): Define how the onboarding will be
         ) : (
           <RulesPanel>
             <RulesPanelHeader lightText>
               <RulesPanelLayout>
-                <GrabColumn />
+                <DragColumn />
                 <OperatorColumn>{t('Operator')}</OperatorColumn>
                 <ConditionColumn>{t('Condition')}</ConditionColumn>
                 <RateColumn>{t('Rate')}</RateColumn>
@@ -428,6 +391,7 @@ export function DynamicSampling({project}: Props) {
                 <Column />
               </RulesPanelLayout>
             </RulesPanelHeader>
+
             <DraggableRuleList
               disabled={!hasAccess}
               items={items}
@@ -475,17 +439,13 @@ export function DynamicSampling({project}: Props) {
                       operator={
                         itemsRule.id === items[0].id
                           ? SamplingRuleOperator.IF
-                          : isUniformRule(currentRule)
-                          ? SamplingRuleOperator.ELSE
                           : SamplingRuleOperator.ELSE_IF
                       }
                       hideGrabButton={items.length === 1}
                       rule={currentRule}
                       onEditRule={() => {
                         navigate(
-                          isUniformRule(currentRule)
-                            ? `${samplingProjectSettingsPath}rules/uniform/`
-                            : `${samplingProjectSettingsPath}rules/${currentRule.id}/`
+                          `${samplingProjectSettingsPath}rules/${currentRule.id}/`
                         );
                       }}
                       canDemo={canDemo}
@@ -505,6 +465,11 @@ export function DynamicSampling({project}: Props) {
                 );
               }}
             />
+
+            {uniformRule && (
+              <UniformRule rule={uniformRule} singleRule={rules.length === 1} />
+            )}
+
             <RulesPanelFooter>
               <ButtonBar gap={1}>
                 <Button
@@ -514,24 +479,17 @@ export function DynamicSampling({project}: Props) {
                 >
                   {t('Read Docs')}
                 </Button>
-                <GuideAnchor
-                  target="add_conditional_rule"
-                  disabled={!uniformRule?.active || !hasAccess || rules.length !== 1}
+                <AddRuleButton
+                  disabled={!hasAccess}
+                  title={
+                    !hasAccess ? t("You don't have permission to add a rule") : undefined
+                  }
+                  priority="primary"
+                  onClick={() => navigate(`${samplingProjectSettingsPath}rules/new/`)}
+                  icon={<IconAdd isCircled />}
                 >
-                  <AddRuleButton
-                    disabled={!hasAccess}
-                    title={
-                      !hasAccess
-                        ? t("You don't have permission to add a rule")
-                        : undefined
-                    }
-                    priority="primary"
-                    onClick={() => navigate(`${samplingProjectSettingsPath}rules/new/`)}
-                    icon={<IconAdd isCircled />}
-                  >
-                    {t('Add Rule')}
-                  </AddRuleButton>
-                </GuideAnchor>
+                  {t('Add Rule')}
+                </AddRuleButton>
               </ButtonBar>
             </RulesPanelFooter>
           </RulesPanel>
@@ -548,26 +506,6 @@ const RulesPanelHeader = styled(PanelHeader)`
   font-size: ${p => p.theme.fontSizeSmall};
 `;
 
-const RulesPanelLayout = styled('div')<{isContent?: boolean}>`
-  width: 100%;
-  display: grid;
-  grid-template-columns: 1fr 0.5fr 74px;
-
-  @media (min-width: ${p => p.theme.breakpoints.small}) {
-    grid-template-columns: 48px 97px 1fr 0.5fr 77px 74px;
-  }
-
-  ${p =>
-    p.isContent &&
-    css`
-      > * {
-        /* match the height of the ellipsis button */
-        line-height: 34px;
-        border-bottom: 1px solid ${p.theme.border};
-      }
-    `}
-`;
-
 const RulesPanelFooter = styled(PanelFooter)`
   border-top: none;
   padding: ${space(1.5)} ${space(2)};

+ 66 - 96
static/app/views/settings/project/dynamicSampling/rule.tsx

@@ -3,7 +3,6 @@ import {DraggableSyntheticListeners, UseDraggableArguments} from '@dnd-kit/core'
 import {css} from '@emotion/react';
 import styled from '@emotion/styled';
 
-import GuideAnchor from 'sentry/components/assistant/guideAnchor';
 import {openConfirmModal} from 'sentry/components/confirm';
 import DropdownMenuControl from 'sentry/components/dropdownMenuControl';
 import NewBooleanField from 'sentry/components/forms/fields/booleanField';
@@ -12,13 +11,12 @@ import Tooltip from 'sentry/components/tooltip';
 import {IconEllipsis} from 'sentry/icons';
 import {IconGrabbable} from 'sentry/icons/iconGrabbable';
 import {t, tn} from 'sentry/locale';
-import {ServerSideSamplingStore} from 'sentry/stores/serverSideSamplingStore';
 import space from 'sentry/styles/space';
 import {Project} from 'sentry/types';
 import {SamplingRule, SamplingRuleOperator} from 'sentry/types/sampling';
 import {formatPercentage} from 'sentry/utils/formatters';
 
-import {getInnerNameLabel, isUniformRule} from './utils';
+import {getInnerNameLabel} from './utils';
 
 type Props = {
   dragging: boolean;
@@ -63,27 +61,17 @@ export function Rule({
   loadingRecommendedSdkUpgrades,
   canDemo,
 }: Props) {
-  const processingSamplingSdkVersions =
-    (ServerSideSamplingStore.getState().sdkVersions.data ?? []).length === 0;
-  const isUniform = isUniformRule(rule);
-  const canDelete = !noPermission && (!isUniform || canDemo);
-  const canDrag = !noPermission && !isUniform;
-  const canActivate =
-    !processingSamplingSdkVersions &&
-    !noPermission &&
-    (!upgradeSdkForProjects.length || rule.active);
+  const canDelete = !noPermission && canDemo;
+  const canDrag = !noPermission;
+  const canActivate = !noPermission && (!upgradeSdkForProjects.length || rule.active);
 
   return (
     <Fragment>
-      <GrabColumn disabled={!canDrag}>
-        {hideGrabButton ? null : (
+      <DragColumn disabled={!canDrag}>
+        {!hideGrabButton && (
           <Tooltip
             title={
-              noPermission
-                ? t('You do not have permission to reorder rules')
-                : isUniform
-                ? t('Uniform rules cannot be reordered')
-                : undefined
+              noPermission ? t('You do not have permission to reorder rules') : undefined
             }
             containerDisplayMode="flex"
           >
@@ -97,40 +85,33 @@ export function Rule({
             </IconGrabbableWrapper>
           </Tooltip>
         )}
-      </GrabColumn>
+      </DragColumn>
       <OperatorColumn>
         <Operator>
-          {operator === SamplingRuleOperator.IF
-            ? t('If')
-            : operator === SamplingRuleOperator.ELSE_IF
-            ? t('Else if')
-            : t('Else')}
+          {operator === SamplingRuleOperator.IF ? t('If') : t('Else if')}
         </Operator>
       </OperatorColumn>
       <ConditionColumn>
-        {hideGrabButton && !rule.condition.inner.length
-          ? t('All')
-          : rule.condition.inner.map((condition, index) => (
-              <Fragment key={index}>
-                <ConditionName>{getInnerNameLabel(condition.name)}</ConditionName>
-                <ConditionEqualOperator>{'='}</ConditionEqualOperator>
-                {Array.isArray(condition.value) ? (
-                  <div>
-                    {[...condition.value].map((conditionValue, conditionValueIndex) => (
-                      <Fragment key={conditionValue}>
-                        <ConditionValue>{conditionValue}</ConditionValue>
-                        {conditionValueIndex !==
-                          (condition.value as string[]).length - 1 && (
-                          <ConditionSeparator>{'\u002C'}</ConditionSeparator>
-                        )}
-                      </Fragment>
-                    ))}
-                  </div>
-                ) : (
-                  <ConditionValue>{String(condition.value)}</ConditionValue>
-                )}
-              </Fragment>
-            ))}
+        {rule.condition.inner.map((condition, index) => (
+          <Fragment key={index}>
+            <ConditionName>{getInnerNameLabel(condition.name)}</ConditionName>
+            <ConditionEqualOperator>{'='}</ConditionEqualOperator>
+            {Array.isArray(condition.value) ? (
+              <div>
+                {[...condition.value].map((conditionValue, conditionValueIndex) => (
+                  <Fragment key={conditionValue}>
+                    <ConditionValue>{conditionValue}</ConditionValue>
+                    {conditionValueIndex !== (condition.value as string[]).length - 1 && (
+                      <ConditionSeparator>{'\u002C'}</ConditionSeparator>
+                    )}
+                  </Fragment>
+                ))}
+              </div>
+            ) : (
+              <ConditionValue>{String(condition.value)}</ConditionValue>
+            )}
+          </Fragment>
+        ))}
       </ConditionColumn>
       <RateColumn>
         <SampleRate>{formatPercentage(rule.sampleRate)}</SampleRate>
@@ -139,38 +120,28 @@ export function Rule({
         {loadingRecommendedSdkUpgrades ? (
           <ActivateTogglePlaceholder />
         ) : (
-          <GuideAnchor
-            target="sampling_rule_toggle"
-            onFinish={onActivate}
-            disabled={!canActivate || !isUniform}
+          <Tooltip
+            disabled={canActivate}
+            title={
+              !canActivate
+                ? tn(
+                    'To enable the rule, the recommended sdk version have to be updated',
+                    'To enable the rule, the recommended sdk versions have to be updated',
+                    upgradeSdkForProjects.length
+                  )
+                : undefined
+            }
           >
-            <Tooltip
-              disabled={canActivate}
-              title={
-                !canActivate
-                  ? processingSamplingSdkVersions
-                    ? t(
-                        'We are processing sampling information for your project, so you cannot enable the rule yet. Please check again later'
-                      )
-                    : tn(
-                        'To enable the rule, the recommended sdk version have to be updated',
-                        'To enable the rule, the recommended sdk versions have to be updated',
-                        upgradeSdkForProjects.length
-                      )
-                  : undefined
-              }
-            >
-              <ActiveToggle
-                inline={false}
-                hideControlState
-                aria-label={rule.active ? t('Deactivate Rule') : t('Activate Rule')}
-                onClick={onActivate}
-                name="active"
-                disabled={!canActivate}
-                value={rule.active}
-              />
-            </Tooltip>
-          </GuideAnchor>
+            <ActiveToggle
+              inline={false}
+              hideControlState
+              aria-label={rule.active ? t('Deactivate Rule') : t('Activate Rule')}
+              onClick={onActivate}
+              name="active"
+              disabled={!canActivate}
+              value={rule.active}
+            />
+          </Tooltip>
         )}
       </ActiveColumn>
       <Column>
@@ -197,8 +168,6 @@ export function Rule({
               label: t('Delete'),
               details: canDelete
                 ? undefined
-                : isUniform
-                ? t("The uniform rule can't be deleted")
                 : t("You don't have permission to delete rules"),
               onAction: () =>
                 openConfirmModal({
@@ -223,24 +192,22 @@ export const Column = styled('div')`
   word-break: break-all;
 `;
 
-export const GrabColumn = styled(Column)<{disabled?: boolean}>`
-  [role='button'] {
-    cursor: grab;
+export const DragColumn = styled(Column)<{disabled?: boolean}>`
+  display: none;
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    display: flex;
   }
 
   ${p =>
     p.disabled &&
     css`
-      [role='button'] {
-        cursor: not-allowed;
-      }
       color: ${p.theme.disabled};
+      > * {
+        [role='button'] {
+          cursor: not-allowed;
+        }
+      }
     `}
-
-  display: none;
-  @media (min-width: ${p => p.theme.breakpoints.small}) {
-    display: flex;
-  }
 `;
 
 export const OperatorColumn = styled(Column)`
@@ -277,28 +244,31 @@ const IconGrabbableWrapper = styled('div')`
   align-items: center;
   /* match the height of edit and delete buttons */
   height: 34px;
+  button {
+    cursor: grab;
+  }
 `;
 
 const ConditionEqualOperator = styled('div')`
   color: ${p => p.theme.purple300};
 `;
 
-const Operator = styled('div')`
+export const Operator = styled('div')`
   color: ${p => p.theme.active};
 `;
 
-const SampleRate = styled('div')`
+export const SampleRate = styled('div')`
   white-space: pre-wrap;
   word-break: break-all;
 `;
 
-const ActiveToggle = styled(NewBooleanField)`
+export const ActiveToggle = styled(NewBooleanField)`
   padding: 0;
   height: 34px;
   justify-content: center;
 `;
 
-const ActivateTogglePlaceholder = styled(Placeholder)`
+export const ActivateTogglePlaceholder = styled(Placeholder)`
   height: 24px;
   margin-top: ${space(0.5)};
 `;

+ 64 - 0
static/app/views/settings/project/dynamicSampling/uniformRule.tsx

@@ -0,0 +1,64 @@
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {t} from 'sentry/locale';
+import {SamplingRule} from 'sentry/types/sampling';
+import {formatPercentage} from 'sentry/utils/formatters';
+
+import {
+  ActiveColumn,
+  Column,
+  ConditionColumn,
+  DragColumn,
+  Operator,
+  OperatorColumn,
+  RateColumn,
+  SampleRate,
+} from './rule';
+
+type Props = {
+  rule: SamplingRule;
+  singleRule: boolean;
+};
+
+export function UniformRule({singleRule, rule}: Props) {
+  return (
+    <Wrapper isContent data-test-id="sampling-rule">
+      <DragColumn />
+      <OperatorColumn>
+        <Operator>{singleRule ? t('If') : t('Else')}</Operator>
+      </OperatorColumn>
+      <ConditionColumn>{singleRule ? t('All') : null}</ConditionColumn>
+      <RateColumn>
+        <SampleRate>{formatPercentage(rule.sampleRate)}</SampleRate>
+      </RateColumn>
+      <ActiveColumn />
+      <Column />
+    </Wrapper>
+  );
+}
+
+export const RulesPanelLayout = styled('div')<{isContent?: boolean}>`
+  width: 100%;
+  display: grid;
+  grid-template-columns: 1fr 0.5fr 74px;
+
+  @media (min-width: ${p => p.theme.breakpoints.small}) {
+    grid-template-columns: 48px 97px 1fr 0.5fr 77px 74px;
+  }
+
+  ${p =>
+    p.isContent &&
+    css`
+      > * {
+        /* match the height of the ellipsis button */
+        line-height: 34px;
+        border-bottom: 1px solid ${p.theme.border};
+      }
+    `}
+`;
+
+const Wrapper = styled(RulesPanelLayout)`
+  color: ${p => p.theme.disabled};
+  background: ${p => p.theme.backgroundSecondary};
+`;