Browse Source

feat(alert): Add project breadcrumb to wizard and alert builder (#26036)

Adds a project breadcrumb dropdown to the wizard and alert builder page:
David Wang 3 years ago
parent
commit
488fc3140c

+ 61 - 22
static/app/components/breadcrumbs.tsx

@@ -8,6 +8,7 @@ import {IconChevron} from 'app/icons';
 import overflowEllipsis from 'app/styles/overflowEllipsis';
 import space from 'app/styles/space';
 import {Theme} from 'app/utils/theme';
+import BreadcrumbDropdown from 'app/views/settings/components/settingsBreadcrumb/breadcrumbDropdown';
 
 const BreadcrumbList = styled('div')`
   display: flex;
@@ -39,11 +40,28 @@ export type Crumb = {
   key?: string;
 };
 
+export type CrumbDropdown = {
+  /**
+   * Name of the crumb
+   */
+  label: React.ReactNode;
+
+  /**
+   * Items of the crumb dropdown
+   */
+  items: React.ComponentProps<typeof BreadcrumbDropdown>['items'];
+
+  /**
+   * Callback function for when an item is selected
+   */
+  onSelect: React.ComponentProps<typeof BreadcrumbDropdown>['onSelect'];
+};
+
 type Props = React.ComponentPropsWithoutRef<typeof BreadcrumbList> & {
   /**
    * Array of crumbs that will be rendered
    */
-  crumbs: Crumb[];
+  crumbs: (Crumb | CrumbDropdown)[];
 
   /**
    * As a general rule of thumb we don't want the last item to be link as it most likely
@@ -54,6 +72,10 @@ type Props = React.ComponentPropsWithoutRef<typeof BreadcrumbList> & {
   linkLastItem?: boolean;
 };
 
+function isCrumbDropdown(crumb: Crumb | CrumbDropdown): crumb is CrumbDropdown {
+  return (crumb as CrumbDropdown).items !== undefined;
+}
+
 /**
  * Page breadcrumbs used for navigation, not to be confused with sentry's event breadcrumbs
  */
@@ -63,31 +85,48 @@ const Breadcrumbs = ({crumbs, linkLastItem = false, ...props}: Props) => {
   }
 
   if (!linkLastItem) {
-    crumbs[crumbs.length - 1].to = null;
+    const lastCrumb = crumbs[crumbs.length - 1];
+    if (!isCrumbDropdown(lastCrumb)) {
+      lastCrumb.to = null;
+    }
   }
 
   return (
     <BreadcrumbList {...props}>
-      {crumbs.map(({label, to, preserveGlobalSelection, key}, index) => {
-        const labelKey = typeof label === 'string' ? label : '';
-        const mapKey =
-          key ?? typeof to === 'string' ? `${labelKey}${to}` : `${labelKey}${index}`;
-
-        return (
-          <React.Fragment key={mapKey}>
-            {to ? (
-              <BreadcrumbLink to={to} preserveGlobalSelection={preserveGlobalSelection}>
-                {label}
-              </BreadcrumbLink>
-            ) : (
-              <BreadcrumbItem>{label}</BreadcrumbItem>
-            )}
-
-            {index < crumbs.length - 1 && (
-              <BreadcrumbDividerIcon size="xs" direction="right" />
-            )}
-          </React.Fragment>
-        );
+      {crumbs.map((crumb, index) => {
+        if (isCrumbDropdown(crumb)) {
+          const {label, ...crumbProps} = crumb;
+          return (
+            <BreadcrumbDropdown
+              key={index}
+              isLast={index >= crumbs.length - 1}
+              route={{}}
+              name={label}
+              {...crumbProps}
+            />
+          );
+        } else {
+          const {label, to, preserveGlobalSelection, key} = crumb;
+          const labelKey = typeof label === 'string' ? label : '';
+          const mapKey =
+            key ?? typeof to === 'string' ? `${labelKey}${to}` : `${labelKey}${index}`;
+
+          return (
+            <React.Fragment key={mapKey}>
+              {to ? (
+                <BreadcrumbLink to={to} preserveGlobalSelection={preserveGlobalSelection}>
+                  {label}
+                </BreadcrumbLink>
+              ) : (
+                <BreadcrumbItem>{label}</BreadcrumbItem>
+              )}
+
+              {index < crumbs.length - 1 && (
+                <BreadcrumbDividerIcon size="xs" direction="right" />
+              )}
+            </React.Fragment>
+          );
+        }
       })}
     </BreadcrumbList>
   );

+ 67 - 7
static/app/views/alerts/builder/builderBreadCrumbs.tsx

@@ -1,27 +1,87 @@
+import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
+import {Location} from 'history';
 
-import Breadcrumbs, {Crumb} from 'app/components/breadcrumbs';
+import Breadcrumbs, {Crumb, CrumbDropdown} from 'app/components/breadcrumbs';
+import IdBadge from 'app/components/idBadge';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
+import {Project} from 'app/types';
+import {isActiveSuperuser} from 'app/utils/isActiveSuperuser';
+import recreateRoute from 'app/utils/recreateRoute';
+import withProjects from 'app/utils/withProjects';
+import MenuItem from 'app/views/settings/components/settingsBreadcrumb/menuItem';
+import {RouteWithName} from 'app/views/settings/components/settingsBreadcrumb/types';
 
 type Props = {
   hasMetricAlerts: boolean;
   orgSlug: string;
   title: string;
   projectSlug: string;
+  projects: Project[];
+  routes: RouteWithName[];
+  location: Location;
   alertName?: string;
+  canChangeProject?: boolean;
 };
 
 function BuilderBreadCrumbs(props: Props) {
-  const {hasMetricAlerts, orgSlug, title, alertName, projectSlug} = props;
-  const crumbs: Crumb[] = [
+  const {
+    orgSlug,
+    title,
+    alertName,
+    projectSlug,
+    projects,
+    routes,
+    canChangeProject,
+    location,
+  } = props;
+  const project = projects.find(({slug}) => projectSlug === slug);
+  const isSuperuser = isActiveSuperuser();
+
+  const projectCrumbLink = {
+    to: `/settings/${orgSlug}/projects/${projectSlug}/`,
+    label: <IdBadge project={project} avatarSize={18} disableLink />,
+    preserveGlobalSelection: true,
+  };
+  const projectCrumbDropdown = {
+    onSelect: ({value}) => {
+      browserHistory.push(
+        recreateRoute('', {
+          routes,
+          params: {orgId: orgSlug, projectId: value},
+          location,
+        })
+      );
+    },
+    label: <IdBadge project={project} avatarSize={18} disableLink />,
+    items: projects
+      .filter(proj => proj.isMember || isSuperuser)
+      .map((proj, index) => ({
+        index,
+        value: proj.slug,
+        label: (
+          <MenuItem>
+            <IdBadge
+              project={proj}
+              avatarProps={{consistentWidth: true}}
+              avatarSize={18}
+              disableLink
+            />
+          </MenuItem>
+        ),
+        searchKey: proj.slug,
+      })),
+  };
+  const projectCrumb = canChangeProject ? projectCrumbDropdown : projectCrumbLink;
+
+  const crumbs: (Crumb | CrumbDropdown)[] = [
     {
-      to: hasMetricAlerts
-        ? `/organizations/${orgSlug}/alerts/`
-        : `/organizations/${orgSlug}/alerts/rules/`,
+      to: `/organizations/${orgSlug}/alerts/rules/`,
       label: t('Alerts'),
       preserveGlobalSelection: true,
     },
+    projectCrumb,
     {
       label: title,
       ...(alertName
@@ -44,4 +104,4 @@ const StyledBreadcrumbs = styled(Breadcrumbs)`
   margin-bottom: ${space(3)};
 `;
 
-export default BuilderBreadCrumbs;
+export default withProjects(BuilderBreadCrumbs);

+ 4 - 5
static/app/views/alerts/builder/projectProvider.tsx

@@ -25,21 +25,20 @@ function AlertBuilderProjectProvider(props: Props) {
   const {children, params, organization, api, ...other} = props;
   const {projectId} = params;
   return (
-    <Projects orgId={organization.slug} slugs={[projectId]}>
+    <Projects orgId={organization.slug} allProjects>
       {({projects, initiallyLoaded, isIncomplete}) => {
         if (!initiallyLoaded) {
           return <LoadingIndicator />;
         }
-        // if loaded, but project fetching states incomplete, project doesn't exist
-        if (isIncomplete) {
+        const project = (projects as Project[]).find(({slug}) => slug === projectId);
+        // if loaded, but project fetching states incomplete or project can't be found, project doesn't exist
+        if (isIncomplete || !project) {
           return (
             <Alert type="warning">
               {t('The project you were looking for was not found.')}
             </Alert>
           );
         }
-        const project = projects[0] as Project;
-
         // fetch members list for mail action fields
         fetchOrgMembers(api, organization.slug, [project.id]);
 

+ 18 - 5
static/app/views/alerts/wizard/index.tsx

@@ -73,14 +73,18 @@ class AlertWizard extends Component<Props, State> {
   };
 
   renderCreateAlertButton() {
-    const {organization, project, location} = this.props;
+    const {
+      organization,
+      location,
+      params: {projectId},
+    } = this.props;
     const {alertOption} = this.state;
     const metricRuleTemplate = AlertWizardRuleTemplates[alertOption];
     const isMetricAlert = !!metricRuleTemplate;
     const isTransactionDataset = metricRuleTemplate?.dataset === Dataset.TRANSACTIONS;
 
     const to = {
-      pathname: `/organizations/${organization.slug}/alerts/${project.slug}/new/`,
+      pathname: `/organizations/${organization.slug}/alerts/${projectId}/new/`,
       query: {
         ...(metricRuleTemplate && metricRuleTemplate),
         createFromWizard: true,
@@ -131,7 +135,7 @@ class AlertWizard extends Component<Props, State> {
           >
             <CreateAlertButton
               organization={organization}
-              projectSlug={project.slug}
+              projectSlug={projectId}
               disabled={!hasFeature}
               priority="primary"
               to={to}
@@ -150,6 +154,8 @@ class AlertWizard extends Component<Props, State> {
       hasMetricAlerts,
       organization,
       params: {projectId},
+      routes,
+      location,
     } = this.props;
     const {alertOption} = this.state;
     const title = t('Alert Creation Wizard');
@@ -160,15 +166,18 @@ class AlertWizard extends Component<Props, State> {
 
         <Feature features={['organizations:alert-wizard']}>
           <Layout.Header>
-            <Layout.HeaderContent>
+            <StyledHeaderContent>
               <BuilderBreadCrumbs
                 hasMetricAlerts={hasMetricAlerts}
                 orgSlug={organization.slug}
                 projectSlug={projectId}
                 title={t('Select Alert')}
+                routes={routes}
+                location={location}
+                canChangeProject
               />
               <Layout.Title>{t('Select Alert')}</Layout.Title>
-            </Layout.HeaderContent>
+            </StyledHeaderContent>
           </Layout.Header>
           <StyledLayoutBody>
             <Layout.Main fullWidth>
@@ -227,6 +236,10 @@ const StyledLayoutBody = styled(Layout.Body)`
   margin-bottom: -${space(3)};
 `;
 
+const StyledHeaderContent = styled(Layout.HeaderContent)`
+  overflow: visible;
+`;
+
 const Styledh2 = styled('h2')`
   font-weight: normal;
   font-size: ${p => p.theme.fontSizeExtraLarge};

+ 10 - 3
static/app/views/settings/projectAlerts/create.tsx

@@ -107,6 +107,7 @@ class Create extends Component<Props, State> {
       project,
       params: {projectId},
       location,
+      routes,
     } = this.props;
     const {alertType, eventView, wizardTemplate} = this.state;
 
@@ -126,21 +127,23 @@ class Create extends Component<Props, State> {
         <SentryDocumentTitle title={title} projectSlug={projectId} />
 
         <Layout.Header>
-          <Layout.HeaderContent>
+          <StyledHeaderContent>
             <BuilderBreadCrumbs
               hasMetricAlerts={hasMetricAlerts}
               orgSlug={organization.slug}
               alertName={t('Set Conditions')}
               title={wizardAlertType ? t('Select Alert') : title}
               projectSlug={projectId}
+              routes={routes}
+              location={location}
+              canChangeProject
             />
-
             <Layout.Title>
               {wizardAlertType
                 ? `${t('Set Conditions for')} ${AlertWizardAlertNames[wizardAlertType]}`
                 : title}
             </Layout.Title>
-          </Layout.HeaderContent>
+          </StyledHeaderContent>
         </Layout.Header>
         <AlertConditionsBody>
           <Layout.Main fullWidth>
@@ -181,4 +184,8 @@ const AlertConditionsBody = styled(Layout.Body)`
   }
 `;
 
+const StyledHeaderContent = styled(Layout.HeaderContent)`
+  overflow: visible;
+`;
+
 export default Create;

+ 3 - 1
static/app/views/settings/projectAlerts/edit.tsx

@@ -44,7 +44,7 @@ class ProjectAlertsEditor extends Component<Props, State> {
   }
 
   render() {
-    const {hasMetricAlerts, location, organization, project} = this.props;
+    const {hasMetricAlerts, location, organization, project, routes} = this.props;
 
     const alertType = location.pathname.includes('/alerts/metric-rules/')
       ? 'metric'
@@ -64,6 +64,8 @@ class ProjectAlertsEditor extends Component<Props, State> {
               orgSlug={organization.slug}
               title={t('Edit Alert Rule')}
               projectSlug={project.slug}
+              routes={routes}
+              location={location}
             />
             <Layout.Title>{this.getTitle()}</Layout.Title>
           </Layout.HeaderContent>

+ 29 - 0
tests/js/spec/components/breadcrumbs.spec.jsx

@@ -22,6 +22,26 @@ describe('Breadcrumbs', () => {
     />
   );
 
+  const wrapperWithDropdown = shallow(
+    <Breadcrumbs
+      crumbs={[
+        {
+          label: 'dropdown crumb',
+          onSelect: () => {},
+          items: ['item1', 'item2', 'item3'],
+        },
+        {
+          label: 'Test 2',
+          to: '/test2',
+        },
+        {
+          label: 'Test 3',
+          to: null,
+        },
+      ]}
+    />
+  );
+
   it('returns null when 0 crumbs', () => {
     const empty = shallow(<Breadcrumbs crumbs={[]} />);
 
@@ -59,4 +79,13 @@ describe('Breadcrumbs', () => {
     expect(allElements.at(3).is('BreadcrumbDividerIcon')).toBeTruthy();
     expect(allElements.at(5).exists()).toBeFalsy();
   });
+
+  it('renders a crumb dropdown', () => {
+    const allElements = wrapperWithDropdown.find('BreadcrumbList').children();
+    const dropdown = wrapperWithDropdown.find('BreadcrumbDropdown');
+    expect(allElements.at(0).is('BreadcrumbDropdown')).toBeTruthy();
+    expect(allElements.at(1).is('BreadcrumbLink')).toBeTruthy();
+    expect(allElements.at(3).is('BreadcrumbItem')).toBeTruthy();
+    expect(dropdown.exists()).toBeTruthy();
+  });
 });

+ 7 - 1
tests/js/spec/views/settings/projectAlerts/create.spec.jsx

@@ -168,6 +168,8 @@ describe('ProjectAlertsCreate', function () {
         const {wrapper} = createWrapper({
           organization: {features: ['incidents']},
         });
+        await tick();
+        wrapper.update();
         expect(memberActionCreators.fetchOrgMembers).toHaveBeenCalled();
         expect(wrapper.find('IssueEditor')).toHaveLength(0);
         expect(wrapper.find('IncidentRulesCreate')).toHaveLength(0);
@@ -190,8 +192,10 @@ describe('ProjectAlertsCreate', function () {
     });
 
     describe('Without Metric Alerts', function () {
-      it('loads default values', function () {
+      it('loads default values', async function () {
         const {wrapper} = createWrapper();
+        await tick();
+        wrapper.update();
         expect(memberActionCreators.fetchOrgMembers).toHaveBeenCalled();
         expect(wrapper.find('SelectControl[name="environment"]').prop('value')).toBe(
           '__all_environments__'
@@ -213,6 +217,8 @@ describe('ProjectAlertsCreate', function () {
           method: 'POST',
           body: TestStubs.ProjectAlertRule(),
         });
+        await tick();
+        wrapper.update();
 
         expect(memberActionCreators.fetchOrgMembers).toHaveBeenCalled();
         // Change target environment