Browse Source

feat(dashboards): Shareable links for widgetviewer (#31975)

Opening the Widget Viewer on a widget updates the browser url to include the widgetId being opened.
The updated url can be shared to directly open the Widget Viewer when visiting the url.
edwardgou-sentry 3 years ago
parent
commit
5a76d25557

+ 9 - 2
static/app/actionCreators/modal.tsx

@@ -277,9 +277,16 @@ export async function openDashboardWidgetLibraryModal(
   openModal(deps => <Modal {...deps} {...options} />, {backdrop: 'static', modalCss});
 }
 
-export async function openWidgetViewerModal(options: WidgetViewerModalOptions) {
+export async function openWidgetViewerModal({
+  onClose,
+  ...options
+}: WidgetViewerModalOptions & {onClose?: () => void}) {
   const mod = await import('sentry/components/modals/widgetViewerModal');
   const {default: Modal, modalCss} = mod;
 
-  openModal(deps => <Modal {...deps} {...options} />, {backdrop: 'static', modalCss});
+  openModal(deps => <Modal {...deps} {...options} />, {
+    backdrop: 'static',
+    modalCss,
+    onClose,
+  });
 }

+ 5 - 0
static/app/routes.tsx

@@ -1004,6 +1004,11 @@ function buildRoutes() {
           componentPromise={() => import('sentry/views/dashboardsV2/widgetBuilder')}
           component={SafeLazyLoad}
         />
+        <Route
+          path="widget/:widgetId/"
+          componentPromise={() => import('sentry/views/dashboardsV2/view')}
+          component={SafeLazyLoad}
+        />
       </Route>
     </Fragment>
   );

+ 40 - 2
static/app/views/dashboardsV2/detail.tsx

@@ -9,8 +9,11 @@ import {
   deleteDashboard,
   updateDashboard,
 } from 'sentry/actionCreators/dashboards';
-import {addSuccessMessage} from 'sentry/actionCreators/indicator';
-import {openAddDashboardWidgetModal} from 'sentry/actionCreators/modal';
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import {
+  openAddDashboardWidgetModal,
+  openWidgetViewerModal,
+} from 'sentry/actionCreators/modal';
 import {Client} from 'sentry/api';
 import Breadcrumbs from 'sentry/components/breadcrumbs';
 import HookOrDefault from 'sentry/components/hookOrDefault';
@@ -87,18 +90,53 @@ class DashboardDetail extends Component<Props, State> {
     this.checkStateRoute();
     router.setRouteLeaveHook(route, this.onRouteLeave);
     window.addEventListener('beforeunload', this.onUnload);
+    this.checkIfShouldMountWidgetViewerModal();
   }
 
   componentDidUpdate(prevProps: Props) {
     if (prevProps.location.pathname !== this.props.location.pathname) {
       this.checkStateRoute();
     }
+    this.checkIfShouldMountWidgetViewerModal();
   }
 
   componentWillUnmount() {
     window.removeEventListener('beforeunload', this.onUnload);
   }
 
+  checkIfShouldMountWidgetViewerModal() {
+    const {
+      params: {widgetId},
+      organization,
+      dashboard,
+      location,
+      router,
+    } = this.props;
+    if (location.pathname.match(/\/widget\/\w*\/$/)) {
+      const widget =
+        defined(widgetId) && dashboard.widgets.find(({id}) => id === String(widgetId));
+      if (widget) {
+        openWidgetViewerModal({
+          organization,
+          widget,
+          onClose: () => {
+            router.push({
+              pathname: `/organizations/${organization.slug}/dashboard/${dashboard.id}/`,
+              query: location.query,
+            });
+          },
+        });
+      } else {
+        // Replace the URL if the widget isn't found and raise an error in toast
+        router.replace({
+          pathname: `/organizations/${organization.slug}/dashboard/${dashboard.id}/`,
+          query: location.query,
+        });
+        addErrorMessage(t('Widget not found'));
+      }
+    }
+  }
+
   checkStateRoute() {
     const {organization, params} = this.props;
     const {dashboardId} = params;

+ 4 - 1
static/app/views/dashboardsV2/view.tsx

@@ -20,7 +20,10 @@ import {constructWidgetFromQuery} from './utils';
 
 const ALLOWED_PARAMS = ['start', 'end', 'utc', 'period', 'project', 'environment'];
 
-type Props = RouteComponentProps<{dashboardId: string; orgId: string}, {}> & {
+type Props = RouteComponentProps<
+  {dashboardId: string; orgId: string; widgetId?: number},
+  {}
+> & {
   children: React.ReactNode;
   organization: Organization;
 };

+ 14 - 6
static/app/views/dashboardsV2/widgetCard/index.tsx

@@ -141,8 +141,9 @@ class WidgetCard extends React.Component<Props> {
       tableItemLimit,
       windowWidth,
       noLazyLoad,
+      location,
       showWidgetViewerButton,
-      onEdit,
+      router,
       isEditing,
     } = this.props;
     return (
@@ -156,12 +157,19 @@ class WidgetCard extends React.Component<Props> {
             </Tooltip>
             {showWidgetViewerButton && !isEditing && (
               <OpenWidgetViewerButton
+                aria-label={t('Open Widget Viewer')}
                 onClick={() => {
-                  openWidgetViewerModal({
-                    organization,
-                    widget,
-                    onEdit,
-                  });
+                  if (widget.id) {
+                    router.push({
+                      pathname: `${location.pathname}widget/${widget.id}/`,
+                      query: location.query,
+                    });
+                  } else {
+                    openWidgetViewerModal({
+                      organization,
+                      widget,
+                    });
+                  }
                 }}
               />
             )}

+ 1 - 1
tests/js/spec/views/dashboardsV2/detail.spec.jsx

@@ -618,7 +618,7 @@ describe('Dashboards > Detail', function () {
           organization={initialData.organization}
           params={{orgId: 'org-slug', dashboardId: '1'}}
           router={initialData.router}
-          location={initialData.router.location}
+          location={{...initialData.router.location, pathname: '/mockpath'}}
         />,
         initialData.routerContext
       );

+ 60 - 0
tests/js/spec/views/dashboardsV2/gridLayout/detail.spec.jsx

@@ -594,5 +594,65 @@ describe('Dashboards > Detail', function () {
 
       expect(window.confirm).not.toHaveBeenCalled();
     });
+
+    it('opens the widget viewer modal using the widget id specified in the url', () => {
+      const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
+      const widget = TestStubs.Widget(
+        [{name: '', conditions: 'event.type:error', fields: ['count()']}],
+        {
+          title: 'First Widget',
+          interval: '1d',
+          id: '1',
+          layout: null,
+        }
+      );
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/dashboards/1/',
+        body: TestStubs.Dashboard([widget], {id: '1', title: 'Custom Errors'}),
+      });
+
+      rtlMountWithTheme(
+        <ViewEditDashboard
+          organization={initialData.organization}
+          params={{orgId: 'org-slug', dashboardId: '1', widgetId: '1'}}
+          router={initialData.router}
+          location={{...initialData.router.location, pathname: '/widget/123/'}}
+        />,
+        {context: initialData.routerContext}
+      );
+
+      expect(openWidgetViewerModal).toHaveBeenCalledWith(
+        expect.objectContaining({
+          organization: initialData.organization,
+          widget,
+          onClose: expect.anything(),
+        })
+      );
+    });
+
+    it('redirects user to dashboard url if widget is not found', () => {
+      const openWidgetViewerModal = jest.spyOn(modals, 'openWidgetViewerModal');
+      MockApiClient.addMockResponse({
+        url: '/organizations/org-slug/dashboards/1/',
+        body: TestStubs.Dashboard([], {id: '1', title: 'Custom Errors'}),
+      });
+      rtlMountWithTheme(
+        <ViewEditDashboard
+          organization={initialData.organization}
+          params={{orgId: 'org-slug', dashboardId: '1', widgetId: '123'}}
+          router={initialData.router}
+          location={{...initialData.router.location, pathname: '/widget/123/'}}
+        />,
+        {context: initialData.routerContext}
+      );
+
+      expect(openWidgetViewerModal).not.toHaveBeenCalled();
+      expect(initialData.router.replace).toHaveBeenCalledWith(
+        expect.objectContaining({
+          pathname: '/organizations/org-slug/dashboard/1/',
+          query: {},
+        })
+      );
+    });
   });
 });

+ 32 - 0
tests/js/spec/views/dashboardsV2/widgetCard.spec.tsx

@@ -502,4 +502,36 @@ describe('Dashboards > WidgetCard', function () {
 
     expect(MetricsWidgetQueries).toHaveBeenCalledTimes(1);
   });
+
+  it('opens the widget viewer modal when a widget has no id', async () => {
+    const openWidgetViewerModal = jest.spyOn(modal, 'openWidgetViewerModal');
+    const widget: Widget = {
+      title: 'Widget',
+      interval: '5m',
+      displayType: DisplayType.LINE,
+      widgetType: WidgetType.DISCOVER,
+      queries: [],
+    };
+    mountWithTheme(
+      <WidgetCard
+        api={api}
+        organization={organization}
+        widget={widget}
+        selection={selection}
+        isEditing={false}
+        onDelete={() => undefined}
+        onEdit={() => undefined}
+        onDuplicate={() => undefined}
+        renderErrorMessage={() => undefined}
+        isSorting={false}
+        currentWidgetDragging={false}
+        showContextMenu
+        widgetLimitReached={false}
+        showWidgetViewerButton
+      />
+    );
+
+    userEvent.click(await screen.findByLabelText('Open Widget Viewer'));
+    expect(openWidgetViewerModal).toHaveBeenCalledWith(expect.objectContaining({widget}));
+  });
 });