Browse Source

feat(dashboards): Add a 'Manage Dashboards' page (#25365)

Add a Manage Dashboard page that shows a grid of
mini Dashboards so the user can see available
Dashboards.
Shruthi 3 years ago
parent
commit
7b96d482cc

+ 9 - 3
src/sentry/api/endpoints/organization_dashboards.py

@@ -39,7 +39,9 @@ class OrganizationDashboardsEndpoint(OrganizationEndpoint):
         if not features.has("organizations:dashboards-basic", organization, actor=request.user):
             return Response(status=404)
 
-        dashboards = Dashboard.objects.filter(organization_id=organization.id)
+        dashboards = Dashboard.objects.filter(organization_id=organization.id).select_related(
+            "created_by"
+        )
         query = request.GET.get("query")
         if query:
             dashboards = dashboards.filter(title__icontains=query)
@@ -50,13 +52,17 @@ class OrganizationDashboardsEndpoint(OrganizationEndpoint):
 
         def handle_results(results):
             serialized = []
+            dashboards = []
             for item in results:
                 if isinstance(item, dict):
                     cloned = item.copy()
-                    del cloned["widgets"]
+                    widgets = cloned.pop("widgets", [])
+                    cloned["widgetDisplay"] = [w["displayType"] for w in widgets]
                     serialized.append(cloned)
                 else:
-                    serialized.append(serialize(item, request.user, serializer=list_serializer))
+                    dashboards.append(item)
+
+            serialized.extend(serialize(dashboards, request.user, serializer=list_serializer))
             return serialized
 
         return self.paginate(

+ 23 - 2
src/sentry/api/serializers/models/dashboard.py

@@ -1,4 +1,7 @@
+from collections import defaultdict
+
 from sentry.api.serializers import Serializer, register, serialize
+from sentry.api.serializers.models.user import UserSerializer
 from sentry.models import (
     Dashboard,
     DashboardWidget,
@@ -52,12 +55,30 @@ class DashboardWidgetQuerySerializer(Serializer):
 
 
 class DashboardListSerializer(Serializer):
+    def get_attrs(self, item_list, user):
+        item_dict = {i.id: i for i in item_list}
+
+        widgets = list(
+            DashboardWidget.objects.filter(dashboard_id__in=item_dict.keys())
+            .order_by("order")
+            .values_list("dashboard_id", "order", "display_type")
+        )
+
+        result = defaultdict(lambda: {"widget_display": []})
+        for dashboard_id, _, display_type in widgets:
+            dashboard = item_dict[dashboard_id]
+            display_type = DashboardWidgetDisplayTypes.get_type_name(display_type)
+            result[dashboard]["widget_display"].append(display_type)
+
+        return result
+
     def serialize(self, obj, attrs, user, **kwargs):
         data = {
             "id": str(obj.id),
             "title": obj.title,
             "dateCreated": obj.date_added,
-            "createdBy": str(obj.created_by.id),
+            "createdBy": serialize(obj.created_by, serializer=UserSerializer()),
+            "widgetDisplay": attrs.get("widget_display", []),
         }
         return data
 
@@ -86,7 +107,7 @@ class DashboardDetailsSerializer(Serializer):
             "id": str(obj.id),
             "title": obj.title,
             "dateCreated": obj.date_added,
-            "createdBy": str(obj.created_by.id),
+            "createdBy": serialize(obj.created_by, serializer=UserSerializer()),
             "widgets": attrs["widgets"],
         }
         return data

+ 9 - 0
static/app/routes.tsx

@@ -1167,6 +1167,15 @@ function routes() {
             component={errorHandler(LazyLoad)}
           />
 
+          <Route
+            path="/organizations/:orgId/dashboards/manage/"
+            componentPromise={() =>
+              import(
+                /* webpackChunkName: "ManageDashboards" */ 'app/views/dashboardsV2/manage'
+              )
+            }
+            component={errorHandler(LazyLoad)}
+          />
           <Route
             path="/organizations/:orgId/dashboards/"
             componentPromise={() =>

+ 12 - 2
static/app/views/dashboardsV2/controls.tsx

@@ -39,6 +39,7 @@ class Controls extends React.Component<Props> {
       dashboardState,
       dashboards,
       dashboard,
+      organization,
       onEdit,
       onCreate,
       onCancel,
@@ -114,7 +115,7 @@ class Controls extends React.Component<Props> {
     if (dashboard) {
       currentOption = {
         label: dashboard.title,
-        value: dashboard,
+        value: {...dashboard, widgetDisplay: dashboard.widgets.map(w => w.displayType)},
       };
     } else if (dropdownOptions.length) {
       currentOption = dropdownOptions[0];
@@ -130,7 +131,6 @@ class Controls extends React.Component<Props> {
             options={dropdownOptions}
             value={currentOption}
             onChange={({value}: {value: DashboardListItem}) => {
-              const {organization} = this.props;
               browserHistory.push({
                 pathname: `/organizations/${organization.slug}/dashboards/${value.id}/`,
                 // TODO(mark) should this retain global selection?
@@ -139,6 +139,16 @@ class Controls extends React.Component<Props> {
             }}
           />
         </DashboardSelect>
+        <Feature features={['organizations:dashboards-manage']}>
+          <Button
+            data-test-id="dashboard-manage"
+            to={{
+              pathname: `/organizations/${organization.slug}/dashboards/manage/`,
+            }}
+          >
+            {t('Manage Dashboards')}
+          </Button>
+        </Feature>
         <DashboardEditFeature>
           {hasFeature => (
             <Button

+ 1 - 1
static/app/views/dashboardsV2/data.tsx

@@ -5,7 +5,7 @@ import {DashboardDetails} from './types';
 export const EMPTY_DASHBOARD: DashboardDetails = {
   id: '',
   dateCreated: '',
-  createdBy: '',
+  createdBy: undefined,
   title: t('Untitled dashboard'),
   widgets: [],
 };

+ 132 - 0
static/app/views/dashboardsV2/manage/dashboardCard.tsx

@@ -0,0 +1,132 @@
+import React from 'react';
+import styled from '@emotion/styled';
+
+import ActivityAvatar from 'app/components/activity/item/avatar';
+import Card from 'app/components/card';
+import Link from 'app/components/links/link';
+import TextOverflow from 'app/components/textOverflow';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {User} from 'app/types';
+
+type Props = {
+  title: string;
+  detail: React.ReactNode;
+  to: React.ComponentProps<typeof Link>['to'];
+  renderWidgets: () => React.ReactNode;
+  createdBy?: User;
+  dateStatus?: React.ReactNode;
+  onEventClick?: () => void;
+};
+
+function DashboardCard({
+  title,
+  detail,
+  createdBy,
+  renderWidgets,
+  dateStatus,
+  to,
+  onEventClick,
+}: Props) {
+  function onClick() {
+    onEventClick?.();
+  }
+
+  return (
+    <Link data-test-id={`card-${title}`} onClick={onClick} to={to}>
+      <StyledDashboardCard interactive>
+        <CardHeader>
+          <CardContent>
+            <Title>{title}</Title>
+            <Detail>{detail}</Detail>
+          </CardContent>
+          <AvatarWrapper>
+            {createdBy ? (
+              <ActivityAvatar type="user" user={createdBy} size={34} />
+            ) : (
+              <ActivityAvatar type="system" size={34} />
+            )}
+          </AvatarWrapper>
+        </CardHeader>
+        <CardBody>{renderWidgets()}</CardBody>
+        <CardFooter>
+          <DateSelected>
+            {dateStatus ? (
+              <DateStatus>
+                {t('Created')} {dateStatus}
+              </DateStatus>
+            ) : (
+              <DateStatus />
+            )}
+          </DateSelected>
+        </CardFooter>
+      </StyledDashboardCard>
+    </Link>
+  );
+}
+
+const AvatarWrapper = styled('span')`
+  border: 3px solid ${p => p.theme.border};
+  border-radius: 50%;
+  height: min-content;
+`;
+
+const CardContent = styled('div')`
+  flex-grow: 1;
+  overflow: hidden;
+  margin-right: ${space(1)};
+`;
+
+const StyledDashboardCard = styled(Card)`
+  justify-content: space-between;
+  height: 100%;
+  &:focus,
+  &:hover {
+    top: -1px;
+  }
+`;
+
+const CardHeader = styled('div')`
+  display: flex;
+  padding: ${space(1.5)} ${space(2)};
+`;
+
+const Title = styled(TextOverflow)`
+  color: ${p => p.theme.textColor};
+`;
+
+const Detail = styled(TextOverflow)`
+  font-family: ${p => p.theme.text.familyMono};
+  font-size: ${p => p.theme.fontSizeSmall};
+  color: ${p => p.theme.gray300};
+  line-height: 1.5;
+`;
+
+const CardBody = styled('div')`
+  background: ${p => p.theme.gray100};
+  padding: ${space(1.5)} ${space(2)};
+  max-height: 150px;
+  height: 150px;
+  overflow: hidden;
+`;
+
+const CardFooter = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: ${space(1)} ${space(2)};
+`;
+
+const DateSelected = styled(TextOverflow)`
+  font-size: ${p => p.theme.fontSizeSmall};
+  display: grid;
+  grid-column-gap: ${space(1)};
+  color: ${p => p.theme.textColor};
+`;
+
+const DateStatus = styled('span')`
+  color: ${p => p.theme.purple300};
+  padding-left: ${space(1)};
+`;
+
+export default DashboardCard;

+ 182 - 0
static/app/views/dashboardsV2/manage/dashboardList.tsx

@@ -0,0 +1,182 @@
+import React from 'react';
+import {browserHistory} from 'react-router';
+import styled from '@emotion/styled';
+import {Location, Query} from 'history';
+
+import WidgetArea from 'sentry-images/dashboard/widget-area.svg';
+import WidgetBar from 'sentry-images/dashboard/widget-bar.svg';
+import WidgetBigNumber from 'sentry-images/dashboard/widget-big-number.svg';
+import WidgetLine from 'sentry-images/dashboard/widget-line-1.svg';
+import WidgetTable from 'sentry-images/dashboard/widget-table.svg';
+import WidgetWorldMap from 'sentry-images/dashboard/widget-world-map.svg';
+
+import EmptyStateWarning from 'app/components/emptyStateWarning';
+import Pagination from 'app/components/pagination';
+import TimeSince from 'app/components/timeSince';
+import {t, tn} from 'app/locale';
+import space from 'app/styles/space';
+import {Organization} from 'app/types';
+import {DashboardListItem, DisplayType} from 'app/views/dashboardsV2/types';
+
+import DashboardCard from './dashboardCard';
+
+type Props = {
+  organization: Organization;
+  location: Location;
+  dashboards: DashboardListItem[] | null;
+  pageLinks: string;
+};
+
+function DashboardList({organization, location, dashboards, pageLinks}: Props) {
+  function miniWidget(displayType: DisplayType): string {
+    switch (displayType) {
+      case DisplayType.BAR:
+        return WidgetBar;
+      case DisplayType.AREA:
+        return WidgetArea;
+      case DisplayType.BIG_NUMBER:
+        return WidgetBigNumber;
+      case DisplayType.TABLE:
+        return WidgetTable;
+      case DisplayType.WORLD_MAP:
+        return WidgetWorldMap;
+      case DisplayType.LINE:
+      default:
+        return WidgetLine;
+    }
+  }
+
+  function renderMiniDashboards() {
+    return dashboards?.map((dashboard, index) => {
+      return (
+        <DashboardCard
+          key={`${index}-${dashboard.id}`}
+          title={dashboard.title}
+          to={{
+            pathname: `/organizations/${organization.slug}/dashboards/${dashboard.id}/`,
+            query: {...location.query},
+          }}
+          detail={tn('%s widget', '%s widgets', dashboard.widgetDisplay.length)}
+          dateStatus={
+            dashboard.dateCreated ? <TimeSince date={dashboard.dateCreated} /> : undefined
+          }
+          createdBy={dashboard.createdBy}
+          renderWidgets={() => (
+            <WidgetGrid>
+              {dashboard.widgetDisplay.map((displayType, i) => {
+                return displayType === DisplayType.BIG_NUMBER ? (
+                  <BigNumberWidgetWrapper
+                    key={`${i}-${displayType}`}
+                    src={miniWidget(displayType)}
+                  />
+                ) : (
+                  <MiniWidgetWrapper
+                    key={`${i}-${displayType}`}
+                    src={miniWidget(displayType)}
+                  />
+                );
+              })}
+            </WidgetGrid>
+          )}
+        />
+      );
+    });
+  }
+
+  function renderDashboardGrid() {
+    if (!dashboards?.length) {
+      return (
+        <EmptyStateWarning>
+          <p>{t('Sorry, no Dashboards match your filters.')}</p>
+        </EmptyStateWarning>
+      );
+    }
+    return <DashboardGrid>{renderMiniDashboards()}</DashboardGrid>;
+  }
+
+  return (
+    <React.Fragment>
+      {renderDashboardGrid()}
+      <PaginationRow
+        pageLinks={pageLinks}
+        onCursor={(cursor: string, path: string, query: Query, direction: number) => {
+          const offset = Number(cursor.split(':')[1]);
+
+          const newQuery: Query & {cursor?: string} = {...query, cursor};
+          const isPrevious = direction === -1;
+
+          if (offset <= 0 && isPrevious) {
+            delete newQuery.cursor;
+          }
+
+          browserHistory.push({
+            pathname: path,
+            query: newQuery,
+          });
+        }}
+      />
+    </React.Fragment>
+  );
+}
+
+const DashboardGrid = styled('div')`
+  display: grid;
+  grid-template-columns: minmax(100px, 1fr);
+  grid-gap: ${space(3)};
+
+  @media (min-width: ${p => p.theme.breakpoints[1]}) {
+    grid-template-columns: repeat(2, minmax(100px, 1fr));
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints[2]}) {
+    grid-template-columns: repeat(3, minmax(100px, 1fr));
+  }
+`;
+
+const WidgetGrid = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  grid-auto-flow: row dense;
+  grid-gap: ${space(0.25)};
+
+  @media (min-width: ${p => p.theme.breakpoints[1]}) {
+    grid-template-columns: repeat(4, minmax(0, 1fr));
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints[3]}) {
+    grid-template-columns: repeat(6, minmax(0, 1fr));
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints[4]}) {
+    grid-template-columns: repeat(8, minmax(0, 1fr));
+  }
+`;
+
+const BigNumberWidgetWrapper = styled('img')`
+  width: 100%;
+  height: 100%;
+  /* 2 cols */
+  grid-area: span 1 / span 2;
+
+  @media (min-width: ${p => p.theme.breakpoints[0]}) {
+    /* 4 cols */
+    grid-area: span 1 / span 1;
+  }
+
+  @media (min-width: ${p => p.theme.breakpoints[3]}) {
+    /* 6 and 8 cols */
+    grid-area: span 1 / span 2;
+  }
+`;
+
+const MiniWidgetWrapper = styled('img')`
+  width: 100%;
+  height: 100%;
+  grid-area: span 2 / span 2;
+`;
+
+const PaginationRow = styled(Pagination)`
+  margin-bottom: ${space(3)};
+`;
+
+export default DashboardList;

+ 155 - 0
static/app/views/dashboardsV2/manage/index.tsx

@@ -0,0 +1,155 @@
+import React from 'react';
+import * as ReactRouter from 'react-router';
+import styled from '@emotion/styled';
+import {Location} from 'history';
+import pick from 'lodash/pick';
+
+import Feature from 'app/components/acl/feature';
+import Alert from 'app/components/alert';
+import Breadcrumbs from 'app/components/breadcrumbs';
+import LightWeightNoProjectMessage from 'app/components/lightWeightNoProjectMessage';
+import PageHeading from 'app/components/pageHeading';
+import SearchBar from 'app/components/searchBar';
+import {t} from 'app/locale';
+import {PageContent, PageHeader} from 'app/styles/organization';
+import space from 'app/styles/space';
+import {Organization} from 'app/types';
+import withOrganization from 'app/utils/withOrganization';
+import AsyncView from 'app/views/asyncView';
+
+import {DashboardListItem} from '../types';
+
+import DashboardList from './dashboardList';
+
+type Props = {
+  organization: Organization;
+  location: Location;
+  router: ReactRouter.InjectedRouter;
+} & AsyncView['props'];
+
+type State = {
+  dashboards: DashboardListItem[] | null;
+  dashboardsPageLinks: string;
+} & AsyncView['state'];
+
+class ManageDashboards extends AsyncView<Props, State> {
+  getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
+    const {organization, location} = this.props;
+    return [
+      [
+        'dashboards',
+        `/organizations/${organization.slug}/dashboards/`,
+        {
+          query: {
+            ...pick(location.query, ['cursor', 'query']),
+            per_page: '9',
+          },
+        },
+      ],
+    ];
+  }
+
+  handleSearch = (query: string) => {
+    const {location, router} = this.props;
+
+    router.push({
+      pathname: location.pathname,
+      query: {...location.query, cursor: undefined, query},
+    });
+  };
+
+  getQuery() {
+    const {query} = this.props.location.query;
+
+    return typeof query === 'string' ? query : undefined;
+  }
+
+  renderActions() {
+    return (
+      <StyledActions>
+        <StyledSearchBar
+          defaultQuery=""
+          query={this.getQuery()}
+          placeholder={t('Search Dashboards')}
+          onSearch={this.handleSearch}
+        />
+      </StyledActions>
+    );
+  }
+
+  renderNoAccess() {
+    return (
+      <PageContent>
+        <Alert type="warning">{t("You don't have access to this feature")}</Alert>
+      </PageContent>
+    );
+  }
+
+  renderDashboards() {
+    const {dashboards, dashboardsPageLinks} = this.state;
+    const {organization, location} = this.props;
+    return (
+      <DashboardList
+        dashboards={dashboards}
+        organization={organization}
+        pageLinks={dashboardsPageLinks}
+        location={location}
+      />
+    );
+  }
+
+  getTitle() {
+    return t('Manage Dashboards');
+  }
+
+  renderBody() {
+    const {organization} = this.props;
+
+    return (
+      <Feature
+        organization={organization}
+        features={['dashboards-manage']}
+        renderDisabled={this.renderNoAccess}
+      >
+        <LightWeightNoProjectMessage organization={organization}>
+          <PageContent>
+            <Breadcrumbs
+              crumbs={[
+                {
+                  label: 'Dashboards',
+                  to: `/organizations/${organization.slug}/dashboards/`,
+                },
+                {
+                  label: 'Manage Dashboards',
+                },
+              ]}
+            />
+            <PageHeader>
+              <PageHeading>{t('Manage Dashboards')}</PageHeading>
+            </PageHeader>
+            {this.renderActions()}
+            {this.renderDashboards()}
+          </PageContent>
+        </LightWeightNoProjectMessage>
+      </Feature>
+    );
+  }
+}
+
+const StyledSearchBar = styled(SearchBar)`
+  flex-grow: 1;
+`;
+
+const StyledActions = styled('div')`
+  display: grid;
+  grid-template-columns: auto max-content min-content;
+
+  @media (max-width: ${p => p.theme.breakpoints[0]}) {
+    grid-template-columns: auto;
+  }
+
+  align-items: center;
+  margin-bottom: ${space(3)};
+`;
+
+export default withOrganization(ManageDashboards);

+ 6 - 3
static/app/views/dashboardsV2/types.tsx

@@ -1,3 +1,5 @@
+import {User} from 'app/types';
+
 export enum DisplayType {
   AREA = 'area',
   BAR = 'bar',
@@ -29,8 +31,9 @@ export type Widget = {
 export type DashboardListItem = {
   id: string;
   title: string;
-  dateCreated: string;
-  createdBy: string;
+  dateCreated?: string;
+  createdBy?: User;
+  widgetDisplay: DisplayType[];
 };
 
 /**
@@ -41,7 +44,7 @@ export type DashboardDetails = {
   widgets: Widget[];
   id: string;
   dateCreated: string;
-  createdBy: string;
+  createdBy?: User;
 };
 
 export type DashboardState = 'view' | 'edit' | 'create' | 'pending_delete';

+ 1 - 0
static/images/dashboard/widget-area.svg

@@ -0,0 +1 @@
+<svg width="159" height="81" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="#fff" d="M0 0h157v79H0z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M158 3H3v77h155V3zM2 2v79h157V2H2z" fill="#444674"/><rect x="9" y="10" width="29" height="2" rx="1" fill="#D4D1EC"/><path d="M11.262 55.67l-1.628.64a1 1 0 00-.634.93V73.5h141V49.46a1 1 0 00-1.614-.79l-1.403 1.091a1.015 1.015 0 01-.224.132l-2.18.925a1 1 0 01-.968-.104l-2.128-1.504a.998.998 0 00-.514-.182l-2.531-.159-2.116.133a1.001 1.001 0 01-.979-.596l-2.252-5.128a1 1 0 00-.978-.596l-2.117.133-2.813.044-2.033.16a1 1 0 01-1.028-.683l-2.479-7.475a1 1 0 00-.302-.448l-2.316-1.964a1.003 1.003 0 01-.341-.61l-1.574-10.185c-.179-1.159-1.862-1.117-1.983.05l-1.961 18.881a1 1 0 01-.493.762l-2.082 1.21a.998.998 0 01-.627.127l-1.784-.224a1 1 0 00-1.014.535l-2.002 3.899a1 1 0 01-1.412.396l-1.521-.932a1 1 0 00-.794-.11l-2.157.61a.999.999 0 00-.414.235l-2.636 2.483-2.572 1.98a1 1 0 01-.547.205l-2.36.148a1 1 0 01-.293-.024l-2.017-.475a1 1 0 01-.758-.816l-2.709-16.972-1.414-5.554c-.276-1.084-1.853-.966-1.964.147l-2.04 20.381a1 1 0 01-1.699.611l-.93-.92a1 1 0 00-.933-.263l-2.275.536-2.814.354-2.445.076a1 1 0 01-.657-.219l-1.798-1.44a1 1 0 00-1.36.102l-2.03 2.201a1 1 0 01-.357.248l-2.187.893a1 1 0 01-.828-.033l-2.232-1.122a1 1 0 00-.37-.103l-2.448-.192a1.001 1.001 0 00-.337.03l-1.577.422a1 1 0 01-1.245-.81l-1.507-9.46c-.178-1.114-1.777-1.128-1.973-.017l-1.833 10.391a1 1 0 01-.953.826l-1.803.057a1 1 0 01-.384-.064l-2.427-.915a.999.999 0 00-.416-.062l-2.505.157a1.002 1.002 0 01-.188-.006l-2.55-.32a1 1 0 01-.324-.099l-2.156-1.084a1 1 0 00-.981.047l-2.246 1.41a1 1 0 01-.192.094l-2.713.98-2.635.787a.999.999 0 01-.364.038l-2.311-.181a1 1 0 01-.562-.228l-2.48-2.064a1.006 1.006 0 00-.194-.127l-3.178-1.577a1 1 0 00-1.436.77l-.834 6.547a1 1 0 01-.627.804z" fill="#7A5088"/></svg>

Some files were not shown because too many files changed in this diff