Browse Source

feat(dashboards): Create table for landing page table view (#80945)

As part of creating table view for the dashboards landing page this PR
creates the actual table with most of the fields that are present in the
mockup (only column left to add is permissions/access column). Switching
the toggle to list view will bring you to this page view. I've defaulted
to 25 items per page (i'm not sure if there's a different amount we want
to use). It's still under the feature flag `dashboards-table-view` which
I only have access to atm.

<img width="1512" alt="image"
src="https://github.com/user-attachments/assets/89530817-cd94-4128-ad3a-fa1ea05851d6">
Nikki Kapadia 3 months ago
parent
commit
f32b2d2b8c

+ 14 - 11
static/app/views/dashboards/manage/dashboardList.spec.tsx → static/app/views/dashboards/manage/dashboardGrid.spec.tsx

@@ -13,10 +13,10 @@ import {
   within,
 } from 'sentry-test/reactTestingLibrary';
 
-import DashboardList from 'sentry/views/dashboards/manage/dashboardList';
+import DashboardGrid from 'sentry/views/dashboards/manage/dashboardGrid';
 import {type DashboardListItem, DisplayType} from 'sentry/views/dashboards/types';
 
-describe('Dashboards - DashboardList', function () {
+describe('Dashboards - DashboardGrid', function () {
   let dashboards: DashboardListItem[];
   let deleteMock: jest.Mock;
   let dashboardUpdateMock: jest.Mock;
@@ -100,9 +100,9 @@ describe('Dashboards - DashboardList', function () {
     dashboardUpdateMock = jest.fn();
   });
 
-  it('renders an empty list', function () {
+  it('renders an empty list', async function () {
     render(
-      <DashboardList
+      <DashboardGrid
         onDashboardsChange={jest.fn()}
         organization={organization}
         dashboards={[]}
@@ -113,11 +113,14 @@ describe('Dashboards - DashboardList', function () {
     );
 
     expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+    expect(
+      await screen.findByText('Sorry, no Dashboards match your filters.')
+    ).toBeInTheDocument();
   });
 
   it('renders dashboard list', function () {
     render(
-      <DashboardList
+      <DashboardGrid
         onDashboardsChange={jest.fn()}
         organization={organization}
         dashboards={dashboards}
@@ -133,7 +136,7 @@ describe('Dashboards - DashboardList', function () {
 
   it('returns landing page url for dashboards', function () {
     render(
-      <DashboardList
+      <DashboardGrid
         onDashboardsChange={jest.fn()}
         organization={organization}
         dashboards={dashboards}
@@ -156,7 +159,7 @@ describe('Dashboards - DashboardList', function () {
 
   it('persists global selection headers', function () {
     render(
-      <DashboardList
+      <DashboardGrid
         onDashboardsChange={jest.fn()}
         organization={organization}
         dashboards={dashboards}
@@ -175,7 +178,7 @@ describe('Dashboards - DashboardList', function () {
 
   it('can delete dashboards', async function () {
     render(
-      <DashboardList
+      <DashboardGrid
         organization={organization}
         dashboards={dashboards}
         location={{...LocationFixture(), query: {}}}
@@ -213,7 +216,7 @@ describe('Dashboards - DashboardList', function () {
       }),
     ];
     render(
-      <DashboardList
+      <DashboardGrid
         organization={organization}
         dashboards={singleDashboard}
         location={LocationFixture()}
@@ -232,7 +235,7 @@ describe('Dashboards - DashboardList', function () {
 
   it('can duplicate dashboards', async function () {
     render(
-      <DashboardList
+      <DashboardGrid
         organization={organization}
         dashboards={dashboards}
         location={{...LocationFixture(), query: {}}}
@@ -259,7 +262,7 @@ describe('Dashboards - DashboardList', function () {
     });
 
     render(
-      <DashboardList
+      <DashboardGrid
         organization={organization}
         dashboards={dashboards}
         location={{...LocationFixture(), query: {}}}

+ 9 - 5
static/app/views/dashboards/manage/dashboardList.tsx → static/app/views/dashboards/manage/dashboardGrid.tsx

@@ -45,7 +45,7 @@ type Props = {
   isLoading?: boolean;
 };
 
-function DashboardList({
+function DashboardGrid({
   api,
   organization,
   location,
@@ -203,21 +203,25 @@ function DashboardList({
       : dashboards?.length ?? 0;
 
     return (
-      <DashboardGrid rows={rowCount} columns={columnCount}>
+      <DashboardGridContainer
+        rows={rowCount}
+        columns={columnCount}
+        data-test-id={'dashboard-grid'}
+      >
         {renderMiniDashboards()}
         {isLoading &&
           rowCount * columnCount > numDashboards &&
           new Array(rowCount * columnCount - numDashboards)
             .fill(0)
             .map((_, index) => <Placeholder key={index} height="270px" />)}
-      </DashboardGrid>
+      </DashboardGridContainer>
     );
   }
 
   return <Fragment>{renderDashboardGrid()}</Fragment>;
 }
 
-const DashboardGrid = styled('div')<{columns: number; rows: number}>`
+const DashboardGridContainer = styled('div')<{columns: number; rows: number}>`
   display: grid;
   grid-template-columns: repeat(
     ${props => props.columns},
@@ -231,4 +235,4 @@ const DropdownTrigger = styled(Button)`
   transform: translateX(${space(1)});
 `;
 
-export default withApi(DashboardList);
+export default withApi(DashboardGrid);

+ 270 - 0
static/app/views/dashboards/manage/dashboardTable.spec.tsx

@@ -0,0 +1,270 @@
+import {DashboardListItemFixture} from 'sentry-fixture/dashboard';
+import {LocationFixture} from 'sentry-fixture/locationFixture';
+import {OrganizationFixture} from 'sentry-fixture/organization';
+import {UserFixture} from 'sentry-fixture/user';
+
+import {initializeOrg} from 'sentry-test/initializeOrg';
+import {
+  render,
+  renderGlobalModal,
+  screen,
+  userEvent,
+  waitFor,
+  within,
+} from 'sentry-test/reactTestingLibrary';
+
+import DashboardTable from 'sentry/views/dashboards/manage/dashboardTable';
+import {type DashboardListItem, DisplayType} from 'sentry/views/dashboards/types';
+
+describe('Dashboards - DashboardTable', function () {
+  let dashboards: DashboardListItem[];
+  let deleteMock: jest.Mock;
+  let dashboardUpdateMock: jest.Mock;
+  let createMock: jest.Mock;
+  const organization = OrganizationFixture({
+    features: [
+      'global-views',
+      'dashboards-basic',
+      'dashboards-edit',
+      'discover-query',
+      'dashboards-table-view',
+    ],
+  });
+
+  const {router} = initializeOrg();
+
+  beforeEach(function () {
+    MockApiClient.clearMockResponses();
+
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/projects/',
+      body: [],
+    });
+    dashboards = [
+      DashboardListItemFixture({
+        id: '1',
+        title: 'Dashboard 1',
+        dateCreated: '2021-04-19T13:13:23.962105Z',
+        createdBy: UserFixture({id: '1'}),
+      }),
+      DashboardListItemFixture({
+        id: '2',
+        title: 'Dashboard 2',
+        dateCreated: '2021-04-19T13:13:23.962105Z',
+        createdBy: UserFixture({id: '1'}),
+        widgetPreview: [
+          {
+            displayType: DisplayType.LINE,
+            layout: null,
+          },
+          {
+            displayType: DisplayType.TABLE,
+            layout: null,
+          },
+        ],
+      }),
+    ];
+    deleteMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/2/',
+      method: 'DELETE',
+      statusCode: 200,
+    });
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/2/',
+      method: 'GET',
+      statusCode: 200,
+      body: {
+        id: '2',
+        title: 'Dashboard Demo',
+        widgets: [
+          {
+            id: '1',
+            title: 'Errors',
+            displayType: 'big_number',
+            interval: '5m',
+          },
+          {
+            id: '2',
+            title: 'Transactions',
+            displayType: 'big_number',
+            interval: '5m',
+          },
+          {
+            id: '3',
+            title: 'p50 of /api/cat',
+            displayType: 'big_number',
+            interval: '5m',
+          },
+        ],
+      },
+    });
+    createMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/',
+      method: 'POST',
+      statusCode: 200,
+    });
+    dashboardUpdateMock = jest.fn();
+  });
+
+  it('renders an empty list', async function () {
+    render(
+      <DashboardTable
+        onDashboardsChange={jest.fn()}
+        organization={organization}
+        dashboards={[]}
+        location={router.location}
+      />
+    );
+
+    expect(screen.getByTestId('empty-state')).toBeInTheDocument();
+    expect(
+      await screen.findByText('Sorry, no Dashboards match your filters.')
+    ).toBeInTheDocument();
+  });
+
+  it('renders dashboard list', function () {
+    render(
+      <DashboardTable
+        onDashboardsChange={jest.fn()}
+        organization={organization}
+        dashboards={dashboards}
+        location={router.location}
+      />
+    );
+
+    expect(screen.getByText('Dashboard 1')).toBeInTheDocument();
+    expect(screen.getByText('Dashboard 2')).toBeInTheDocument();
+  });
+
+  it('returns landing page url for dashboards', function () {
+    render(
+      <DashboardTable
+        onDashboardsChange={jest.fn()}
+        organization={organization}
+        dashboards={dashboards}
+        location={router.location}
+      />,
+      {router}
+    );
+
+    expect(screen.getByRole('link', {name: 'Dashboard 1'})).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/dashboard/1/'
+    );
+    expect(screen.getByRole('link', {name: 'Dashboard 2'})).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/dashboard/2/'
+    );
+  });
+
+  it('persists global selection headers', function () {
+    render(
+      <DashboardTable
+        onDashboardsChange={jest.fn()}
+        organization={organization}
+        dashboards={dashboards}
+        location={{...LocationFixture(), query: {statsPeriod: '7d'}}}
+      />,
+      {router}
+    );
+
+    expect(screen.getByRole('link', {name: 'Dashboard 1'})).toHaveAttribute(
+      'href',
+      '/organizations/org-slug/dashboard/1/?statsPeriod=7d'
+    );
+  });
+
+  it('can delete dashboards', async function () {
+    render(
+      <DashboardTable
+        organization={organization}
+        dashboards={dashboards}
+        location={{...LocationFixture(), query: {}}}
+        onDashboardsChange={dashboardUpdateMock}
+      />,
+      {router}
+    );
+    renderGlobalModal();
+
+    await userEvent.click(screen.getAllByTestId('dashboard-delete')[1]);
+
+    expect(deleteMock).not.toHaveBeenCalled();
+
+    await userEvent.click(
+      within(screen.getByRole('dialog')).getByRole('button', {name: /confirm/i})
+    );
+
+    await waitFor(() => {
+      expect(deleteMock).toHaveBeenCalled();
+      expect(dashboardUpdateMock).toHaveBeenCalled();
+    });
+  });
+
+  it('cannot delete last dashboard', function () {
+    const singleDashboard = [
+      DashboardListItemFixture({
+        id: '1',
+        title: 'Dashboard 1',
+        dateCreated: '2021-04-19T13:13:23.962105Z',
+        createdBy: UserFixture({id: '1'}),
+        widgetPreview: [],
+      }),
+    ];
+    render(
+      <DashboardTable
+        organization={organization}
+        dashboards={singleDashboard}
+        location={LocationFixture()}
+        onDashboardsChange={dashboardUpdateMock}
+      />
+    );
+
+    expect(screen.getAllByTestId('dashboard-delete')[0]).toHaveAttribute(
+      'aria-disabled',
+      'true'
+    );
+  });
+
+  it('can duplicate dashboards', async function () {
+    render(
+      <DashboardTable
+        organization={organization}
+        dashboards={dashboards}
+        location={{...LocationFixture(), query: {}}}
+        onDashboardsChange={dashboardUpdateMock}
+      />
+    );
+
+    await userEvent.click(screen.getAllByTestId('dashboard-duplicate')[1]);
+
+    await waitFor(() => {
+      expect(createMock).toHaveBeenCalled();
+      expect(dashboardUpdateMock).toHaveBeenCalled();
+    });
+  });
+
+  it('does not throw an error if the POST fails during duplication', async function () {
+    const postMock = MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/',
+      method: 'POST',
+      statusCode: 404,
+    });
+
+    render(
+      <DashboardTable
+        organization={organization}
+        dashboards={dashboards}
+        location={{...LocationFixture(), query: {}}}
+        onDashboardsChange={dashboardUpdateMock}
+      />
+    );
+
+    await userEvent.click(screen.getAllByTestId('dashboard-duplicate')[1]);
+
+    await waitFor(() => {
+      expect(postMock).toHaveBeenCalled();
+      // Should not update, and not throw error
+      expect(dashboardUpdateMock).not.toHaveBeenCalled();
+    });
+  });
+});

+ 224 - 0
static/app/views/dashboards/manage/dashboardTable.tsx

@@ -0,0 +1,224 @@
+import styled from '@emotion/styled';
+import type {Location} from 'history';
+
+import {
+  createDashboard,
+  deleteDashboard,
+  fetchDashboard,
+} from 'sentry/actionCreators/dashboards';
+import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
+import type {Client} from 'sentry/api';
+import {ActivityAvatar} from 'sentry/components/activity/item/avatar';
+import {Button} from 'sentry/components/button';
+import {openConfirmModal} from 'sentry/components/confirm';
+import EmptyStateWarning from 'sentry/components/emptyStateWarning';
+import GridEditable, {
+  COL_WIDTH_UNDEFINED,
+  type GridColumnOrder,
+} from 'sentry/components/gridEditable';
+import Link from 'sentry/components/links/link';
+import TimeSince from 'sentry/components/timeSince';
+import {IconCopy, IconDelete} from 'sentry/icons';
+import {t} from 'sentry/locale';
+import {space} from 'sentry/styles/space';
+import type {Organization} from 'sentry/types/organization';
+import {trackAnalytics} from 'sentry/utils/analytics';
+import withApi from 'sentry/utils/withApi';
+import type {DashboardListItem} from 'sentry/views/dashboards/types';
+
+import {cloneDashboard} from '../utils';
+
+type Props = {
+  api: Client;
+  dashboards: DashboardListItem[] | undefined;
+  location: Location;
+  onDashboardsChange: () => void;
+  organization: Organization;
+  isLoading?: boolean;
+};
+
+enum ResponseKeys {
+  NAME = 'title',
+  WIDGETS = 'widgetDisplay',
+  OWNER = 'createdBy',
+  CREATED = 'dateCreated',
+}
+
+function DashboardTable({
+  api,
+  organization,
+  location,
+  dashboards,
+  onDashboardsChange,
+  isLoading,
+}: Props) {
+  const columnOrder = [
+    {key: ResponseKeys.NAME, name: t('Name'), width: COL_WIDTH_UNDEFINED},
+    {key: ResponseKeys.WIDGETS, name: t('Widgets'), width: COL_WIDTH_UNDEFINED},
+    {key: ResponseKeys.OWNER, name: t('Owner'), width: COL_WIDTH_UNDEFINED},
+    {key: ResponseKeys.CREATED, name: t('Created'), width: COL_WIDTH_UNDEFINED},
+  ];
+
+  function handleDelete(dashboard: DashboardListItem) {
+    deleteDashboard(api, organization.slug, dashboard.id)
+      .then(() => {
+        trackAnalytics('dashboards_manage.delete', {
+          organization,
+          dashboard_id: parseInt(dashboard.id, 10),
+        });
+        onDashboardsChange();
+        addSuccessMessage(t('Dashboard deleted'));
+      })
+      .catch(() => {
+        addErrorMessage(t('Error deleting Dashboard'));
+      });
+  }
+
+  async function handleDuplicate(dashboard: DashboardListItem) {
+    try {
+      const dashboardDetail = await fetchDashboard(api, organization.slug, dashboard.id);
+      const newDashboard = cloneDashboard(dashboardDetail);
+      newDashboard.widgets.map(widget => (widget.id = undefined));
+      await createDashboard(api, organization.slug, newDashboard, true);
+      trackAnalytics('dashboards_manage.duplicate', {
+        organization,
+        dashboard_id: parseInt(dashboard.id, 10),
+      });
+      onDashboardsChange();
+      addSuccessMessage(t('Dashboard duplicated'));
+    } catch (e) {
+      addErrorMessage(t('Error duplicating Dashboard'));
+    }
+  }
+
+  // TODO(__SENTRY_USING_REACT_ROUTER_SIX): We can remove this later, react
+  // router 6 handles empty query objects without appending a trailing ?
+  const queryLocation = {
+    ...(location.query && Object.keys(location.query).length > 0
+      ? {query: location.query}
+      : {}),
+  };
+
+  const renderBodyCell = (
+    column: GridColumnOrder<string>,
+    dataRow: DashboardListItem
+  ) => {
+    if (column.key === ResponseKeys.NAME) {
+      return (
+        <Link
+          to={{
+            pathname: `/organizations/${organization.slug}/dashboard/${dataRow.id}/`,
+            ...queryLocation,
+          }}
+        >
+          {dataRow[ResponseKeys.NAME]}
+        </Link>
+      );
+    }
+
+    if (column.key === ResponseKeys.WIDGETS) {
+      return dataRow[ResponseKeys.WIDGETS].length;
+    }
+
+    if (column.key === ResponseKeys.OWNER) {
+      return dataRow[ResponseKeys.OWNER] ? (
+        <ActivityAvatar type="user" user={dataRow[ResponseKeys.OWNER]} size={26} />
+      ) : (
+        <ActivityAvatar type="system" size={26} />
+      );
+    }
+
+    if (column.key === ResponseKeys.CREATED) {
+      return (
+        <DateActionsContainer>
+          <DateSelected>
+            {dataRow[ResponseKeys.CREATED] ? (
+              <DateStatus>
+                <TimeSince date={dataRow[ResponseKeys.CREATED]} />
+              </DateStatus>
+            ) : (
+              <DateStatus />
+            )}
+          </DateSelected>
+          <ActionsIconWrapper>
+            <StyledButton
+              onClick={e => {
+                e.stopPropagation();
+                handleDuplicate(dataRow);
+              }}
+              aria-label={t('Duplicate Dashboard')}
+              data-test-id={'dashboard-duplicate'}
+              icon={<IconCopy />}
+              size="sm"
+            />
+            <StyledButton
+              onClick={e => {
+                e.stopPropagation();
+                openConfirmModal({
+                  message: t('Are you sure you want to delete this dashboard?'),
+                  priority: 'danger',
+                  onConfirm: () => handleDelete(dataRow),
+                });
+              }}
+              aria-label={t('Delete Dashboard')}
+              data-test-id={'dashboard-delete'}
+              icon={<IconDelete />}
+              size="sm"
+              disabled={dashboards && dashboards.length <= 1}
+            />
+          </ActionsIconWrapper>
+        </DateActionsContainer>
+      );
+    }
+
+    return <span>{dataRow[column.key]}</span>;
+  };
+
+  return (
+    <GridEditable
+      data={dashboards ?? []}
+      columnOrder={columnOrder}
+      columnSortBy={[]}
+      grid={{
+        renderBodyCell,
+      }}
+      isLoading={isLoading}
+      emptyMessage={
+        <EmptyStateWarning>
+          <p>{t('Sorry, no Dashboards match your filters.')}</p>
+        </EmptyStateWarning>
+      }
+    />
+  );
+}
+
+export default withApi(DashboardTable);
+
+const DateSelected = styled('div')`
+  font-size: ${p => p.theme.fontSizeMedium};
+  display: grid;
+  grid-column-gap: ${space(1)};
+  color: ${p => p.theme.textColor};
+  ${p => p.theme.overflowEllipsis};
+`;
+
+const DateStatus = styled('span')`
+  color: ${p => p.theme.textColor};
+  padding-left: ${space(1)};
+`;
+
+const DateActionsContainer = styled('div')`
+  display: flex;
+  gap: ${space(4)};
+  justify-content: space-between;
+  align-items: center;
+`;
+
+const ActionsIconWrapper = styled('div')`
+  display: flex;
+`;
+
+const StyledButton = styled(Button)`
+  border: none;
+  box-shadow: none;
+`;

+ 8 - 0
static/app/views/dashboards/manage/index.spec.tsx

@@ -216,6 +216,12 @@ describe('Dashboards > Detail', function () {
   });
 
   it('toggles between grid and list view', async function () {
+    MockApiClient.addMockResponse({
+      url: '/organizations/org-slug/dashboards/',
+      body: [DashboardListItemFixture({title: 'Test Dashboard 1'})],
+      headers: {Link: getPaginationPageLink({numRows: 15, pageSize: 9, offset: 0})},
+    });
+
     render(<ManageDashboards />, {
       ...RouteComponentPropsFixture(),
       organization: {
@@ -228,10 +234,12 @@ describe('Dashboards > Detail', function () {
     await userEvent.click(await screen.findByTestId('list'));
 
     expect(localStorage.setItem).toHaveBeenCalledWith(LAYOUT_KEY, '"list"');
+    expect(await screen.findByTestId('grid-editable')).toBeInTheDocument();
 
     expect(await screen.findByTestId('grid')).toBeInTheDocument();
     await userEvent.click(await screen.findByTestId('grid'));
 
     expect(localStorage.setItem).toHaveBeenCalledWith(LAYOUT_KEY, '"grid"');
+    expect(await screen.findByTestId('dashboard-grid')).toBeInTheDocument();
   });
 });

+ 21 - 12
static/app/views/dashboards/manage/index.tsx

@@ -22,7 +22,7 @@ import SearchBar from 'sentry/components/searchBar';
 import {SegmentedControl} from 'sentry/components/segmentedControl';
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import Switch from 'sentry/components/switchButton';
-import {IconAdd, IconDashboard, IconList} from 'sentry/icons';
+import {IconAdd, IconGrid, IconList} from 'sentry/icons';
 import {t} from 'sentry/locale';
 import {space} from 'sentry/styles/space';
 import type {SelectValue} from 'sentry/types/core';
@@ -38,6 +38,7 @@ import {useLocation} from 'sentry/utils/useLocation';
 import {useNavigate} from 'sentry/utils/useNavigate';
 import useOrganization from 'sentry/utils/useOrganization';
 import {DashboardImportButton} from 'sentry/views/dashboards/manage/dashboardImport';
+import DashboardTable from 'sentry/views/dashboards/manage/dashboardTable';
 import {MetricsRemovedAlertsWidgetsAlert} from 'sentry/views/metrics/metricsRemovedAlertsWidgetsAlert';
 import RouteError from 'sentry/views/routeError';
 
@@ -45,12 +46,13 @@ import {getDashboardTemplates} from '../data';
 import {assignDefaultLayout, getInitialColumnDepths} from '../layoutUtils';
 import type {DashboardDetails, DashboardListItem} from '../types';
 
-import DashboardList from './dashboardList';
+import DashboardGrid from './dashboardGrid';
 import {
   DASHBOARD_CARD_GRID_PADDING,
   DASHBOARD_GRID_DEFAULT_NUM_CARDS,
   DASHBOARD_GRID_DEFAULT_NUM_COLUMNS,
   DASHBOARD_GRID_DEFAULT_NUM_ROWS,
+  DASHBOARD_TABLE_NUM_ROWS,
   MINIMUM_DASHBOARD_CARD_WIDTH,
 } from './settings';
 import TemplateCard from './templateCard';
@@ -116,7 +118,8 @@ function ManageDashboards() {
         query: {
           ...pick(location.query, ['cursor', 'query']),
           sort: getActiveSort().value,
-          per_page: rowCount * columnCount,
+          per_page:
+            dashboardsLayout === GRID ? rowCount * columnCount : DASHBOARD_TABLE_NUM_ROWS,
         },
       },
     ],
@@ -264,17 +267,14 @@ function ManageDashboards() {
               key="grid"
               textValue="grid"
               aria-label={t('Grid View')}
-            >
-              {/* TODO (nikkikapadia): replace this icon with correct one once made */}
-              <IconDashboard />
-            </SegmentedControl.Item>
+              icon={<IconGrid />}
+            />
             <SegmentedControl.Item
               key="list"
               textValue="list"
               aria-label={t('List View')}
-            >
-              <IconList />
-            </SegmentedControl.Item>
+              icon={<IconList />}
+            />
           </SegmentedControl>
         </Feature>
         <CompactSelect
@@ -297,8 +297,8 @@ function ManageDashboards() {
   }
 
   function renderDashboards() {
-    return (
-      <DashboardList
+    return dashboardsLayout === GRID ? (
+      <DashboardGrid
         api={api}
         dashboards={dashboards}
         organization={organization}
@@ -308,6 +308,15 @@ function ManageDashboards() {
         rowCount={rowCount}
         columnCount={columnCount}
       />
+    ) : (
+      <DashboardTable
+        api={api}
+        dashboards={dashboards}
+        organization={organization}
+        location={location}
+        onDashboardsChange={() => refetchDashboards()}
+        isLoading={isLoading}
+      />
     );
   }
 

+ 2 - 0
static/app/views/dashboards/manage/settings.tsx

@@ -5,3 +5,5 @@ export const DASHBOARD_CARD_GRID_PADDING = Number(space(2).replace('px', ''));
 export const DASHBOARD_GRID_DEFAULT_NUM_ROWS = 3;
 export const DASHBOARD_GRID_DEFAULT_NUM_COLUMNS = 3;
 export const DASHBOARD_GRID_DEFAULT_NUM_CARDS = 8;
+
+export const DASHBOARD_TABLE_NUM_ROWS = 25;