Browse Source

feat(widget-library): Add initial widget library modal (#29846)

Added the basic UI components for the Widget
Library modal (just the library tab). Note, this just
tracks the state of the selected widgets (ie, the
Add Widget button doesn't do anything). The
prebuilt templates are just temporary test widgets.
Shruthi 3 years ago
parent
commit
178f3f687b

+ 10 - 0
static/app/actionCreators/modal.tsx

@@ -3,6 +3,7 @@ import * as React from 'react';
 import ModalActions from 'app/actions/modalActions';
 import type {ModalTypes} from 'app/components/globalModal';
 import type {DashboardWidgetModalOptions} from 'app/components/modals/addDashboardWidgetModal';
+import {DashboardWidgetLibraryModalOptions} from 'app/components/modals/dashboardWidgetLibraryModal';
 import type {DashboardWidgetQuerySelectorModalOptions} from 'app/components/modals/dashboardWidgetQuerySelectorModal';
 import {InviteRow} from 'app/components/modals/inviteMembersModal/types';
 import type {ReprocessEventModalOptions} from 'app/components/modals/reprocessEventModal';
@@ -259,3 +260,12 @@ export async function openDashboardWidgetQuerySelectorModal(
 
   openModal(deps => <Modal {...deps} {...options} />, {backdrop: 'static', modalCss});
 }
+
+export async function openDashboardWidgetLibraryModal(
+  options: DashboardWidgetLibraryModalOptions
+) {
+  const mod = await import('app/components/modals/dashboardWidgetLibraryModal');
+  const {default: Modal, modalCss} = mod;
+
+  openModal(deps => <Modal {...deps} {...options} />, {backdrop: 'static', modalCss});
+}

+ 7 - 0
static/app/components/modals/dashboardWidgetLibraryModal/customTab.tsx

@@ -0,0 +1,7 @@
+import * as React from 'react';
+
+function DashboardWidgetCustomTab() {
+  return <React.Fragment />;
+}
+
+export default DashboardWidgetCustomTab;

+ 120 - 0
static/app/components/modals/dashboardWidgetLibraryModal/index.tsx

@@ -0,0 +1,120 @@
+import * as React from 'react';
+import {useState} from 'react';
+import {css} from '@emotion/react';
+import styled from '@emotion/styled';
+
+import {ModalRenderProps} from 'app/actionCreators/modal';
+import Tag from 'app/components/tagDeprecated';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+import {Organization} from 'app/types';
+import {DashboardDetails} from 'app/views/dashboardsV2/types';
+import {DEFAULT_WIDGETS, WidgetTemplate} from 'app/views/dashboardsV2/widgetLibrary/data';
+
+import Button from '../../button';
+import ButtonBar from '../../buttonBar';
+
+import DashboardWidgetCustomTab from './customTab';
+import DashboardWidgetLibraryTab from './libraryTab';
+
+export type DashboardWidgetLibraryModalOptions = {
+  organization: Organization;
+  dashboard: DashboardDetails;
+};
+
+export enum TAB {
+  Library = 'library',
+  Custom = 'custom',
+}
+
+type Props = ModalRenderProps & DashboardWidgetLibraryModalOptions;
+
+function DashboardWidgetLibraryModal({Header, Body, Footer}: Props) {
+  const [tab, setTab] = useState(TAB.Library);
+  const [selectedWidgets, setSelectedWidgets] = useState<WidgetTemplate[]>([]);
+
+  return (
+    <React.Fragment>
+      <Header closeButton>
+        <h4>{t('Add Widget')}</h4>
+      </Header>
+      <Body>
+        <StyledButtonBar active={tab}>
+          <Button barId={TAB.Library} onClick={() => setTab(TAB.Library)}>
+            {t('Library')}
+          </Button>
+          <Button barId={TAB.Custom} onClick={() => setTab(TAB.Custom)}>
+            {t('Custom')}
+          </Button>
+        </StyledButtonBar>
+        <Title>{t('%s WIDGETS', DEFAULT_WIDGETS.length)}</Title>
+        {tab === TAB.Library ? (
+          <DashboardWidgetLibraryTab
+            selectedWidgets={selectedWidgets}
+            setSelectedWidgets={setSelectedWidgets}
+          />
+        ) : (
+          <DashboardWidgetCustomTab />
+        )}
+      </Body>
+      <Footer>
+        <FooterButtonbar gap={1}>
+          <Button
+            external
+            href="https://docs.sentry.io/product/dashboards/custom-dashboards/#widget-builder"
+          >
+            {t('Read the docs')}
+          </Button>
+          <div>
+            <SelectedBadge data-test-id="selected-badge">
+              {`${selectedWidgets.length} Selected`}
+            </SelectedBadge>
+            <Button
+              data-test-id="add-widget"
+              priority="primary"
+              type="button"
+              onClick={() => {}}
+            >
+              {t('Add Widget')}
+            </Button>
+          </div>
+        </FooterButtonbar>
+      </Footer>
+    </React.Fragment>
+  );
+}
+
+export const modalCss = css`
+  width: 100%;
+  max-width: 700px;
+  margin: 70px auto;
+`;
+
+const StyledButtonBar = styled(ButtonBar)`
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  margin-bottom: ${space(1)};
+`;
+
+const FooterButtonbar = styled(ButtonBar)`
+  justify-content: space-between;
+  width: 100%;
+`;
+
+const Title = styled('h3')`
+  margin-bottom: ${space(1)};
+  padding: 0 !important;
+  font-size: ${p => p.theme.fontSizeSmall};
+  text-transform: uppercase;
+  color: ${p => p.theme.gray300};
+`;
+
+const SelectedBadge = styled(Tag)`
+  padding: 3px ${space(0.75)};
+  display: inline-flex;
+  align-items: center;
+  margin-left: ${space(1)};
+  margin-right: ${space(1)};
+  top: -1px;
+`;
+
+export default DashboardWidgetLibraryModal;

+ 51 - 0
static/app/components/modals/dashboardWidgetLibraryModal/libraryTab.tsx

@@ -0,0 +1,51 @@
+import * as React from 'react';
+import styled from '@emotion/styled';
+
+import space from 'app/styles/space';
+import {DEFAULT_WIDGETS, WidgetTemplate} from 'app/views/dashboardsV2/widgetLibrary/data';
+import WidgetLibraryCard from 'app/views/dashboardsV2/widgetLibrary/widgetCard';
+
+type Props = {
+  selectedWidgets: WidgetTemplate[];
+  setSelectedWidgets: (widgets: WidgetTemplate[]) => void;
+};
+
+function DashboardWidgetLibraryTab({selectedWidgets, setSelectedWidgets}: Props) {
+  return (
+    <React.Fragment>
+      <ScrollGrid>
+        <WidgetLibraryGrid>
+          {DEFAULT_WIDGETS.map(widgetCard => {
+            return (
+              <WidgetLibraryCard
+                key={widgetCard.title}
+                widget={widgetCard}
+                selectedWidgets={selectedWidgets}
+                setSelectedWidgets={setSelectedWidgets}
+              />
+            );
+          })}
+        </WidgetLibraryGrid>
+      </ScrollGrid>
+    </React.Fragment>
+  );
+}
+
+const WidgetLibraryGrid = styled('div')`
+  display: grid;
+  grid-template-columns: repeat(2, minmax(100px, 1fr));
+  grid-template-rows: repeat(2, max-content);
+  grid-gap: ${space(1)};
+`;
+
+const ScrollGrid = styled('div')`
+  max-height: 550px;
+  overflow: scroll;
+  -ms-overflow-style: none;
+  scrollbar-width: none;
+  &::-webkit-scrollbar {
+    display: none;
+  }
+`;
+
+export default DashboardWidgetLibraryTab;

+ 43 - 14
static/app/views/dashboardsV2/controls.tsx

@@ -7,7 +7,7 @@ import Button from 'app/components/button';
 import ButtonBar from 'app/components/buttonBar';
 import Confirm from 'app/components/confirm';
 import Hovercard from 'app/components/hovercard';
-import {IconEdit} from 'app/icons';
+import {IconAdd, IconEdit} from 'app/icons';
 import {t} from 'app/locale';
 import space from 'app/styles/space';
 import {Organization} from 'app/types';
@@ -21,12 +21,22 @@ type Props = {
   onCancel: () => void;
   onCommit: () => void;
   onDelete: () => void;
+  onAddWidget?: () => void;
   dashboardState: DashboardState;
 };
 
 class Controls extends React.Component<Props> {
   render() {
-    const {dashboardState, dashboards, onEdit, onCancel, onCommit, onDelete} = this.props;
+    const {
+      organization,
+      dashboardState,
+      dashboards,
+      onEdit,
+      onCancel,
+      onCommit,
+      onDelete,
+      onAddWidget,
+    } = this.props;
 
     const cancelButton = (
       <Button
@@ -90,18 +100,37 @@ class Controls extends React.Component<Props> {
       <StyledButtonBar gap={1} key="controls">
         <DashboardEditFeature>
           {hasFeature => (
-            <Button
-              data-test-id="dashboard-edit"
-              onClick={e => {
-                e.preventDefault();
-                onEdit();
-              }}
-              priority="primary"
-              icon={<IconEdit size="xs" />}
-              disabled={!hasFeature}
-            >
-              {t('Edit Dashboard')}
-            </Button>
+            <React.Fragment>
+              <Button
+                data-test-id="dashboard-edit"
+                onClick={e => {
+                  e.preventDefault();
+                  onEdit();
+                }}
+                icon={<IconEdit size="xs" />}
+                disabled={!hasFeature}
+                priority={
+                  organization.features.includes('widget-library') ? 'default' : 'primary'
+                }
+              >
+                {t('Edit Dashboard')}
+              </Button>
+              {organization.features.includes('widget-library') ? (
+                <Button
+                  data-test-id="dashboard-add-widget"
+                  priority="primary"
+                  icon={<IconAdd isCircled size="s" />}
+                  onClick={e => {
+                    e.preventDefault();
+                    if (onAddWidget) {
+                      onAddWidget();
+                    }
+                  }}
+                >
+                  {t('Add Widget')}
+                </Button>
+              ) : null}
+            </React.Fragment>
           )}
         </DashboardEditFeature>
       </StyledButtonBar>

+ 8 - 0
static/app/views/dashboardsV2/detail.tsx

@@ -9,6 +9,7 @@ import {
   updateDashboard,
 } from 'app/actionCreators/dashboards';
 import {addSuccessMessage} from 'app/actionCreators/indicator';
+import {openDashboardWidgetLibraryModal} from 'app/actionCreators/modal';
 import {Client} from 'app/api';
 import Breadcrumbs from 'app/components/breadcrumbs';
 import HookOrDefault from 'app/components/hookOrDefault';
@@ -252,6 +253,11 @@ class DashboardDetail extends Component<Props, State> {
     });
   };
 
+  onAddWidget = () => {
+    const {organization, dashboard} = this.props;
+    openDashboardWidgetLibraryModal({organization, dashboard});
+  };
+
   onCommit = () => {
     const {api, organization, location, dashboard, reloadData} = this.props;
     const {modifiedDashboard, dashboardState} = this.state;
@@ -416,6 +422,7 @@ class DashboardDetail extends Component<Props, State> {
                 onEdit={this.onEdit}
                 onCancel={this.onCancel}
                 onCommit={this.onCommit}
+                onAddWidget={this.onAddWidget}
                 onDelete={this.onDelete(dashboard)}
                 dashboardState={dashboardState}
               />
@@ -490,6 +497,7 @@ class DashboardDetail extends Component<Props, State> {
                 onEdit={this.onEdit}
                 onCancel={this.onCancel}
                 onCommit={this.onCommit}
+                onAddWidget={this.onAddWidget}
                 onDelete={this.onDelete(dashboard)}
                 dashboardState={dashboardState}
               />

+ 26 - 0
static/app/views/dashboardsV2/utils.tsx

@@ -2,6 +2,13 @@ import {Query} from 'history';
 import cloneDeep from 'lodash/cloneDeep';
 import pick from 'lodash/pick';
 
+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 {GlobalSelection} from 'app/types';
 import {getUtcDateString} from 'app/utils/dates';
 import EventView from 'app/utils/discover/eventView';
@@ -85,3 +92,22 @@ export function constructWidgetFromQuery(query?: Query): Widget | undefined {
   }
   return undefined;
 }
+
+export function miniWidget(displayType: DisplayType): string {
+  switch (displayType) {
+    case DisplayType.BAR:
+      return WidgetBar;
+    case DisplayType.AREA:
+    case DisplayType.TOP_N:
+      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;
+  }
+}

+ 50 - 0
static/app/views/dashboardsV2/widgetLibrary/data.tsx

@@ -0,0 +1,50 @@
+import {t} from 'app/locale';
+
+import {DisplayType, Widget} from '../types';
+
+export type WidgetTemplate = Widget;
+
+export const DEFAULT_WIDGETS: Readonly<Array<WidgetTemplate>> = [
+  {
+    id: undefined,
+    title: t('Total Errors'),
+    displayType: DisplayType.BIG_NUMBER,
+    interval: '24h',
+    queries: [],
+  },
+  {
+    id: undefined,
+    title: t('All Events'),
+    displayType: DisplayType.AREA,
+    interval: '24h',
+    queries: [],
+  },
+  {
+    id: undefined,
+    title: t('Affected Users'),
+    displayType: DisplayType.LINE,
+    interval: '24h',
+    queries: [],
+  },
+  {
+    id: undefined,
+    title: t('Handled vs. Unhandled'),
+    displayType: DisplayType.LINE,
+    interval: '24h',
+    queries: [],
+  },
+  {
+    id: undefined,
+    title: t('Errors by Country'),
+    displayType: DisplayType.WORLD_MAP,
+    interval: '24h',
+    queries: [],
+  },
+  {
+    id: undefined,
+    title: t('Errors by Browser'),
+    displayType: DisplayType.TABLE,
+    interval: '24h',
+    queries: [],
+  },
+];

+ 108 - 0
static/app/views/dashboardsV2/widgetLibrary/widgetCard.tsx

@@ -0,0 +1,108 @@
+import * as React from 'react';
+import styled from '@emotion/styled';
+
+import Button from 'app/components/button';
+import Card from 'app/components/card';
+import {IconAdd, IconCheckmark} from 'app/icons';
+import {t} from 'app/locale';
+import space from 'app/styles/space';
+
+import {miniWidget} from '../utils';
+
+import {WidgetTemplate} from './data';
+
+type Props = {
+  widget: WidgetTemplate;
+  setSelectedWidgets: (widgets: WidgetTemplate[]) => void;
+  selectedWidgets: WidgetTemplate[];
+};
+
+function WidgetLibraryCard({selectedWidgets, widget, setSelectedWidgets}: Props) {
+  const selectButton = (
+    <StyledButton
+      type="button"
+      icon={<IconAdd size="small" isCircled color="gray300" />}
+      onClick={() => {
+        const updatedWidgets = selectedWidgets.slice().concat(widget);
+        setSelectedWidgets(updatedWidgets);
+      }}
+    >
+      {t('Select')}
+    </StyledButton>
+  );
+
+  const selectedButton = (
+    <StyledButton
+      type="button"
+      icon={<IconCheckmark size="small" isCircled color="gray300" />}
+      onClick={() => {
+        const updatedWidgets = selectedWidgets.filter(selected => widget !== selected);
+        setSelectedWidgets(updatedWidgets);
+      }}
+      priority="primary"
+    >
+      {t('Selected')}
+    </StyledButton>
+  );
+
+  return (
+    <Card>
+      <CardHeader>
+        <CardContent>
+          <Title>{widget.title}</Title>
+        </CardContent>
+      </CardHeader>
+      <CardBody>
+        <WidgetImage src={miniWidget(widget.displayType)} />
+      </CardBody>
+      <CardFooter>
+        {selectedWidgets.includes(widget) ? selectedButton : selectButton}
+      </CardFooter>
+    </Card>
+  );
+}
+
+const CardContent = styled('div')`
+  flex-grow: 1;
+  overflow: hidden;
+  margin-right: ${space(1)};
+`;
+
+const CardHeader = styled('div')`
+  display: flex;
+  padding: ${space(1.5)} ${space(2)};
+`;
+
+const Title = styled('div')`
+  color: ${p => p.theme.textColor};
+`;
+
+const CardBody = styled('div')`
+  background: ${p => p.theme.gray100};
+  padding: ${space(1.5)} ${space(2)};
+  max-height: 150px;
+  min-height: 150px;
+  overflow: hidden;
+`;
+
+const CardFooter = styled('div')`
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: ${space(1)} ${space(2)};
+`;
+
+const StyledButton = styled(Button)`
+  width: 100%;
+  vertical-align: middle;
+  > span:first-child {
+    padding: 8px 16px;
+  }
+`;
+
+const WidgetImage = styled('img')`
+  width: 100%;
+  height: 100%;
+`;
+
+export default WidgetLibraryCard;

+ 60 - 0
tests/js/spec/components/modals/dashboardWidgetLibraryModal.spec.jsx

@@ -0,0 +1,60 @@
+// import {mountWithTheme} from 'sentry-test/enzyme';
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {fireEvent, mountWithTheme, screen} from 'sentry-test/reactTestingLibrary';
+
+import DashboardWidgetLibraryModal from 'app/components/modals/dashboardWidgetLibraryModal';
+
+const stubEl = props => <div>{props.children}</div>;
+
+function mountModal({initialData}) {
+  const routerContext = TestStubs.routerContext();
+  return mountWithTheme(
+    <DashboardWidgetLibraryModal
+      Header={stubEl}
+      Footer={stubEl}
+      Body={stubEl}
+      organization={initialData.organization}
+      dashboard={TestStubs.Dashboard([], {
+        id: '1',
+        title: 'Dashboard 1',
+        dateCreated: '2021-04-19T13:13:23.962105Z',
+        createdBy: {id: '1'},
+        widgetDisplay: [],
+      })}
+    />,
+    {context: routerContext}
+  );
+}
+
+describe('Modals -> DashboardWidgetLibraryModal', function () {
+  const initialData = initializeOrg({
+    organization: {
+      features: ['widget-library'],
+      apdexThreshold: 400,
+    },
+  });
+
+  afterEach(() => {
+    MockApiClient.clearMockResponses();
+  });
+
+  it('renders the widget library modal', function () {
+    // Checking initial modal states
+    const container = mountModal({initialData});
+    expect(screen.getByText('6 WIDGETS')).toBeInTheDocument();
+    expect(screen.getByTestId('selected-badge')).toHaveTextContent('0 Selected');
+    expect(screen.queryAllByText('Select')).toHaveLength(6);
+    expect(screen.queryByText('Selected')).not.toBeInTheDocument();
+
+    // Select some widgets
+    const selectButtons = screen.getAllByRole('button');
+    fireEvent.click(selectButtons[4]);
+    fireEvent.click(selectButtons[5]);
+
+    expect(screen.getByTestId('selected-badge')).toHaveTextContent('2 Selected');
+    expect(screen.queryAllByText('Select')).toHaveLength(4);
+    expect(screen.queryAllByText('Selected')).toHaveLength(2);
+
+    container.unmount();
+  });
+});