import {useEffect, useRef, useState} from 'react'; import styled from '@emotion/styled'; import type {Query} from 'history'; import debounce from 'lodash/debounce'; import pick from 'lodash/pick'; import {createDashboard} from 'sentry/actionCreators/dashboards'; import {addSuccessMessage} from 'sentry/actionCreators/indicator'; import {openImportDashboardFromFileModal} from 'sentry/actionCreators/modal'; import Feature from 'sentry/components/acl/feature'; import {Alert} from 'sentry/components/alert'; import {Button} from 'sentry/components/button'; import ButtonBar from 'sentry/components/buttonBar'; import {CompactSelect} from 'sentry/components/compactSelect'; import ErrorBoundary from 'sentry/components/errorBoundary'; import FeedbackWidgetButton from 'sentry/components/feedback/widget/feedbackWidgetButton'; import * as Layout from 'sentry/components/layouts/thirds'; import NoProjectMessage from 'sentry/components/noProjectMessage'; import {PageHeadingQuestionTooltip} from 'sentry/components/pageHeadingQuestionTooltip'; import Pagination from 'sentry/components/pagination'; 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, IconGrid, IconList} from 'sentry/icons'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {SelectValue} from 'sentry/types/core'; import {trackAnalytics} from 'sentry/utils/analytics'; import localStorage from 'sentry/utils/localStorage'; import parseLinkHeader from 'sentry/utils/parseLinkHeader'; import {useApiQuery} from 'sentry/utils/queryClient'; import {decodeScalar} from 'sentry/utils/queryString'; import normalizeUrl from 'sentry/utils/url/normalizeUrl'; import useApi from 'sentry/utils/useApi'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; 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'; import {getDashboardTemplates} from '../data'; import {assignDefaultLayout, getInitialColumnDepths} from '../layoutUtils'; import type {DashboardDetails, DashboardListItem} from '../types'; 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'; const SORT_OPTIONS: SelectValue[] = [ {label: t('My Dashboards'), value: 'mydashboards'}, {label: t('Dashboard Name (A-Z)'), value: 'title'}, {label: t('Date Created (Newest)'), value: '-dateCreated'}, {label: t('Date Created (Oldest)'), value: 'dateCreated'}, {label: t('Most Popular'), value: 'mostPopular'}, {label: t('Recently Viewed'), value: 'recentlyViewed'}, ]; const SHOW_TEMPLATES_KEY = 'dashboards-show-templates'; export const LAYOUT_KEY = 'dashboards-overview-layout'; const GRID = 'grid'; const LIST = 'list'; type DashboardsLayout = 'grid' | 'list'; function shouldShowTemplates(): boolean { const shouldShow = localStorage.getItem(SHOW_TEMPLATES_KEY); return shouldShow === 'true' || shouldShow === null; } function getDashboardsOverviewLayout(): DashboardsLayout { const dashboardsLayout = localStorage.getItem(LAYOUT_KEY); return dashboardsLayout === GRID || dashboardsLayout === LIST ? dashboardsLayout : GRID; } function ManageDashboards() { const organization = useOrganization(); const navigate = useNavigate(); const location = useLocation(); const api = useApi(); const dashboardGridRef = useRef(null); const [showTemplates, setShowTemplatesLocal] = useLocalStorageState( SHOW_TEMPLATES_KEY, shouldShowTemplates() ); const [dashboardsLayout, setDashboardsLayout] = useLocalStorageState( LAYOUT_KEY, getDashboardsOverviewLayout() ); const [{rowCount, columnCount}, setGridSize] = useState({ rowCount: DASHBOARD_GRID_DEFAULT_NUM_ROWS, columnCount: DASHBOARD_GRID_DEFAULT_NUM_COLUMNS, }); const { data: dashboards, isLoading, isError, error, getResponseHeader, refetch: refetchDashboards, } = useApiQuery( [ `/organizations/${organization.slug}/dashboards/`, { query: { ...pick(location.query, ['cursor', 'query']), sort: getActiveSort().value, per_page: dashboardsLayout === GRID ? rowCount * columnCount : DASHBOARD_TABLE_NUM_ROWS, }, }, ], {staleTime: 0} ); const dashboardsPageLinks = getResponseHeader?.('Link') ?? ''; function setRowsAndColumns(containerWidth: number) { const numWidgetsFitInRow = Math.floor( containerWidth / (MINIMUM_DASHBOARD_CARD_WIDTH + DASHBOARD_CARD_GRID_PADDING) ); if (numWidgetsFitInRow >= 3) { setGridSize({ rowCount: DASHBOARD_GRID_DEFAULT_NUM_ROWS, columnCount: numWidgetsFitInRow, }); } else if (numWidgetsFitInRow === 0) { setGridSize({ rowCount: DASHBOARD_GRID_DEFAULT_NUM_CARDS, columnCount: 1, }); } else { setGridSize({ rowCount: DASHBOARD_GRID_DEFAULT_NUM_CARDS / numWidgetsFitInRow, columnCount: numWidgetsFitInRow, }); } } useEffect(() => { const dashboardGridObserver = new ResizeObserver( debounce(entries => { entries.forEach(entry => { const currentWidth = entry.contentRect.width; setRowsAndColumns(currentWidth); const paginationObject = parseLinkHeader(dashboardsPageLinks); if ( dashboards?.length && paginationObject.next.results && rowCount * columnCount > dashboards.length ) { refetchDashboards(); } }); }, 10) ); const currentDashboardGrid = dashboardGridRef.current; if (currentDashboardGrid) { dashboardGridObserver.observe(currentDashboardGrid); } return () => { if (currentDashboardGrid) { dashboardGridObserver.unobserve(currentDashboardGrid); } }; }, [columnCount, dashboards?.length, dashboardsPageLinks, refetchDashboards, rowCount]); function getActiveSort() { const urlSort = decodeScalar(location.query.sort, 'mydashboards'); return SORT_OPTIONS.find(item => item.value === urlSort) || SORT_OPTIONS[0]; } function handleSearch(query: string) { trackAnalytics('dashboards_manage.search', { organization, }); navigate({ pathname: location.pathname, query: {...location.query, cursor: undefined, query}, }); } const handleSortChange = (value: string) => { trackAnalytics('dashboards_manage.change_sort', { organization, sort: value, }); navigate({ pathname: location.pathname, query: { ...location.query, cursor: undefined, sort: value, }, }); }; const toggleTemplates = () => { trackAnalytics('dashboards_manage.templates.toggle', { organization, show_templates: !showTemplates, }); setShowTemplatesLocal(!showTemplates); }; function getQuery() { const {query} = location.query; return typeof query === 'string' ? query : undefined; } function renderTemplates() { return ( {getDashboardTemplates(organization).map(dashboard => ( onPreview(dashboard.id)} onAdd={() => onAdd(dashboard)} key={dashboard.title} /> ))} ); } function renderActions() { const activeSort = getActiveSort(); return ( handleSearch(query)} /> onChange={setDashboardsLayout} size="md" value={dashboardsLayout} aria-label={t('Layout Control')} > } /> } /> handleSortChange(opt.value)} position="bottom-end" /> ); } function renderNoAccess() { return ( {t("You don't have access to this feature")} ); } function renderDashboards() { return dashboardsLayout === GRID ? ( refetchDashboards()} isLoading={isLoading} rowCount={rowCount} columnCount={columnCount} /> ) : ( refetchDashboards()} isLoading={isLoading} /> ); } function renderPagination() { return ( { const offset = Number(cursor?.split?.(':')?.[1] ?? 0); const newQuery: Query & {cursor?: string} = {...query, cursor}; const isPrevious = direction === -1; if (offset <= 0 && isPrevious) { delete newQuery.cursor; } trackAnalytics('dashboards_manage.paginate', {organization}); navigate({ pathname: path, query: newQuery, }); }} /> ); } function onCreate() { trackAnalytics('dashboards_manage.create.start', { organization, }); navigate( normalizeUrl({ pathname: `/organizations/${organization.slug}/dashboards/new/`, query: location.query, }) ); } async function onAdd(dashboard: DashboardDetails) { trackAnalytics('dashboards_manage.templates.add', { organization, dashboard_id: dashboard.id, dashboard_title: dashboard.title, was_previewed: false, }); const newDashboard = await createDashboard( api, organization.slug, { ...dashboard, widgets: assignDefaultLayout(dashboard.widgets, getInitialColumnDepths()), }, true ); addSuccessMessage(`${dashboard.title} dashboard template successfully added.`); loadDashboard(newDashboard.id); } function loadDashboard(dashboardId: string) { navigate( normalizeUrl({ pathname: `/organizations/${organization.slug}/dashboards/${dashboardId}/`, query: location.query, }) ); } function onPreview(dashboardId: string) { trackAnalytics('dashboards_manage.templates.preview', { organization, dashboard_id: dashboardId, }); navigate( normalizeUrl({ pathname: `/organizations/${organization.slug}/dashboards/new/${dashboardId}/`, query: location.query, }) ); } return ( {isError ? ( ) : ( {t('Dashboards')} {t('Show Templates')} {showTemplates && renderTemplates()} {renderActions()}
{renderDashboards()}
{renderPagination()}
)}
); } const StyledActions = styled('div')<{listView: boolean}>` display: grid; grid-template-columns: ${p => p.listView ? 'auto max-content max-content' : 'auto max-content'}; gap: ${space(2)}; margin-bottom: ${space(2)}; @media (max-width: ${p => p.theme.breakpoints.small}) { grid-template-columns: auto; } `; const TemplateSwitch = styled('label')` font-weight: ${p => p.theme.fontWeightNormal}; font-size: ${p => p.theme.fontSizeLarge}; display: flex; align-items: center; gap: ${space(1)}; width: max-content; margin: 0; `; const TemplateContainer = styled('div')` display: grid; gap: ${space(2)}; margin-bottom: ${space(0.5)}; @media (min-width: ${p => p.theme.breakpoints.small}) { grid-template-columns: repeat(2, minmax(200px, 1fr)); } @media (min-width: ${p => p.theme.breakpoints.large}) { grid-template-columns: repeat(4, minmax(200px, 1fr)); } `; const PaginationRow = styled(Pagination)` margin-bottom: ${space(3)}; `; export default ManageDashboards;