diff --git a/static/app/views/dashboards/manage/dashboardList.spec.tsx b/static/app/views/dashboards/manage/dashboardGrid.spec.tsx similarity index 94% rename from static/app/views/dashboards/manage/dashboardList.spec.tsx rename to static/app/views/dashboards/manage/dashboardGrid.spec.tsx index 6fff72b93e553d..7e6dfef17c5214 100644 --- a/static/app/views/dashboards/manage/dashboardList.spec.tsx +++ b/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( - + {renderMiniDashboards()} {isLoading && rowCount * columnCount > numDashboards && new Array(rowCount * columnCount - numDashboards) .fill(0) .map((_, index) => )} - + ); } return {renderDashboardGrid()}; } -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); diff --git a/static/app/views/dashboards/manage/dashboardTable.spec.tsx b/static/app/views/dashboards/manage/dashboardTable.spec.tsx new file mode 100644 index 00000000000000..76cee2e71e959b --- /dev/null +++ b/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( + + ); + + expect(screen.getByTestId('empty-state')).toBeInTheDocument(); + expect( + await screen.findByText('Sorry, no Dashboards match your filters.') + ).toBeInTheDocument(); + }); + + it('renders dashboard list', function () { + render( + + ); + + expect(screen.getByText('Dashboard 1')).toBeInTheDocument(); + expect(screen.getByText('Dashboard 2')).toBeInTheDocument(); + }); + + it('returns landing page url for dashboards', function () { + render( + , + {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( + , + {router} + ); + + expect(screen.getByRole('link', {name: 'Dashboard 1'})).toHaveAttribute( + 'href', + '/organizations/org-slug/dashboard/1/?statsPeriod=7d' + ); + }); + + it('can delete dashboards', async function () { + render( + , + {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( + + ); + + expect(screen.getAllByTestId('dashboard-delete')[0]).toHaveAttribute( + 'aria-disabled', + 'true' + ); + }); + + it('can duplicate dashboards', async function () { + render( + + ); + + 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( + + ); + + await userEvent.click(screen.getAllByTestId('dashboard-duplicate')[1]); + + await waitFor(() => { + expect(postMock).toHaveBeenCalled(); + // Should not update, and not throw error + expect(dashboardUpdateMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/static/app/views/dashboards/manage/dashboardTable.tsx b/static/app/views/dashboards/manage/dashboardTable.tsx new file mode 100644 index 00000000000000..17e05e42dab697 --- /dev/null +++ b/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, + dataRow: DashboardListItem + ) => { + if (column.key === ResponseKeys.NAME) { + return ( + + {dataRow[ResponseKeys.NAME]} + + ); + } + + if (column.key === ResponseKeys.WIDGETS) { + return dataRow[ResponseKeys.WIDGETS].length; + } + + if (column.key === ResponseKeys.OWNER) { + return dataRow[ResponseKeys.OWNER] ? ( + + ) : ( + + ); + } + + if (column.key === ResponseKeys.CREATED) { + return ( + + + {dataRow[ResponseKeys.CREATED] ? ( + + + + ) : ( + + )} + + + { + e.stopPropagation(); + handleDuplicate(dataRow); + }} + aria-label={t('Duplicate Dashboard')} + data-test-id={'dashboard-duplicate'} + icon={} + size="sm" + /> + { + 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={} + size="sm" + disabled={dashboards && dashboards.length <= 1} + /> + + + ); + } + + return {dataRow[column.key]}; + }; + + return ( + +

{t('Sorry, no Dashboards match your filters.')}

+ + } + /> + ); +} + +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; +`; diff --git a/static/app/views/dashboards/manage/index.spec.tsx b/static/app/views/dashboards/manage/index.spec.tsx index bd5c6757327880..8d3b8007389d58 100644 --- a/static/app/views/dashboards/manage/index.spec.tsx +++ b/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(, { ...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(); }); }); diff --git a/static/app/views/dashboards/manage/index.tsx b/static/app/views/dashboards/manage/index.tsx index d12d5165c15ec9..b25ef8b6ea31cd 100644 --- a/static/app/views/dashboards/manage/index.tsx +++ b/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 */} - - + icon={} + /> - - + icon={} + /> + ) : ( + refetchDashboards()} + isLoading={isLoading} + /> ); } diff --git a/static/app/views/dashboards/manage/settings.tsx b/static/app/views/dashboards/manage/settings.tsx index 22bcb25fa44da9..6e4fbd97b17944 100644 --- a/static/app/views/dashboards/manage/settings.tsx +++ b/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;