dashboardGrid.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259
  1. import {Fragment, useEffect, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import isEqual from 'lodash/isEqual';
  5. import {
  6. createDashboard,
  7. deleteDashboard,
  8. fetchDashboard,
  9. updateDashboardFavorite,
  10. } from 'sentry/actionCreators/dashboards';
  11. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  12. import type {Client} from 'sentry/api';
  13. import {Button} from 'sentry/components/button';
  14. import {openConfirmModal} from 'sentry/components/confirm';
  15. import type {MenuItemProps} from 'sentry/components/dropdownMenu';
  16. import {DropdownMenu} from 'sentry/components/dropdownMenu';
  17. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  18. import Placeholder from 'sentry/components/placeholder';
  19. import TimeSince from 'sentry/components/timeSince';
  20. import {IconEllipsis} from 'sentry/icons';
  21. import {t, tn} from 'sentry/locale';
  22. import {space} from 'sentry/styles/space';
  23. import type {Organization} from 'sentry/types/organization';
  24. import {trackAnalytics} from 'sentry/utils/analytics';
  25. import withApi from 'sentry/utils/withApi';
  26. import {
  27. DASHBOARD_CARD_GRID_PADDING,
  28. MINIMUM_DASHBOARD_CARD_WIDTH,
  29. } from 'sentry/views/dashboards/manage/settings';
  30. import type {DashboardListItem} from 'sentry/views/dashboards/types';
  31. import {cloneDashboard} from '../utils';
  32. import DashboardCard from './dashboardCard';
  33. import GridPreview from './gridPreview';
  34. type Props = {
  35. api: Client;
  36. columnCount: number;
  37. dashboards: DashboardListItem[] | undefined;
  38. location: Location;
  39. onDashboardsChange: () => void;
  40. organization: Organization;
  41. rowCount: number;
  42. isLoading?: boolean;
  43. };
  44. function DashboardGrid({
  45. api,
  46. organization,
  47. location,
  48. dashboards,
  49. onDashboardsChange,
  50. rowCount,
  51. columnCount,
  52. isLoading,
  53. }: Props) {
  54. // this acts as a cache for the dashboards being passed in. It preserves the previously populated dashboard list
  55. // to be able to show the 'previous' dashboards on resize
  56. const [currentDashboards, setCurrentDashboards] = useState<
  57. DashboardListItem[] | undefined
  58. >(dashboards);
  59. useEffect(() => {
  60. if (dashboards?.length) {
  61. setCurrentDashboards(dashboards);
  62. }
  63. }, [dashboards]);
  64. function handleDelete(dashboard: DashboardListItem) {
  65. deleteDashboard(api, organization.slug, dashboard.id)
  66. .then(() => {
  67. trackAnalytics('dashboards_manage.delete', {
  68. organization,
  69. dashboard_id: parseInt(dashboard.id, 10),
  70. view_type: 'grid',
  71. });
  72. onDashboardsChange();
  73. addSuccessMessage(t('Dashboard deleted'));
  74. })
  75. .catch(() => {
  76. addErrorMessage(t('Error deleting Dashboard'));
  77. });
  78. }
  79. async function handleDuplicate(dashboard: DashboardListItem) {
  80. try {
  81. const dashboardDetail = await fetchDashboard(api, organization.slug, dashboard.id);
  82. const newDashboard = cloneDashboard(dashboardDetail);
  83. newDashboard.widgets.map(widget => (widget.id = undefined));
  84. await createDashboard(api, organization.slug, newDashboard, true);
  85. trackAnalytics('dashboards_manage.duplicate', {
  86. organization,
  87. dashboard_id: parseInt(dashboard.id, 10),
  88. view_type: 'grid',
  89. });
  90. onDashboardsChange();
  91. addSuccessMessage(t('Dashboard duplicated'));
  92. } catch (e) {
  93. addErrorMessage(t('Error duplicating Dashboard'));
  94. }
  95. }
  96. async function handleFavorite(dashboard: DashboardListItem, isFavorited: boolean) {
  97. await updateDashboardFavorite(api, organization.slug, dashboard.id, isFavorited);
  98. onDashboardsChange();
  99. trackAnalytics('dashboards_manage.toggle_favorite', {
  100. organization,
  101. dashboard_id: dashboard.id,
  102. favorited: isFavorited,
  103. });
  104. }
  105. function renderDropdownMenu(dashboard: DashboardListItem) {
  106. const menuItems: MenuItemProps[] = [
  107. {
  108. key: 'dashboard-duplicate',
  109. label: t('Duplicate'),
  110. onAction: () => {
  111. openConfirmModal({
  112. message: t('Are you sure you want to duplicate this dashboard?'),
  113. priority: 'primary',
  114. onConfirm: () => handleDuplicate(dashboard),
  115. });
  116. },
  117. },
  118. {
  119. key: 'dashboard-delete',
  120. label: t('Delete'),
  121. priority: 'danger',
  122. onAction: () => {
  123. openConfirmModal({
  124. message: t('Are you sure you want to delete this dashboard?'),
  125. priority: 'danger',
  126. onConfirm: () => handleDelete(dashboard),
  127. });
  128. },
  129. },
  130. ];
  131. return (
  132. <DropdownMenu
  133. items={menuItems}
  134. trigger={triggerProps => (
  135. <DropdownTrigger
  136. {...triggerProps}
  137. aria-label={t('Dashboard actions')}
  138. size="xs"
  139. borderless
  140. onClick={e => {
  141. e.stopPropagation();
  142. e.preventDefault();
  143. triggerProps.onClick?.(e);
  144. }}
  145. icon={<IconEllipsis direction="down" size="sm" />}
  146. />
  147. )}
  148. position="bottom-end"
  149. disabledKeys={dashboards && dashboards.length <= 1 ? ['dashboard-delete'] : []}
  150. offset={4}
  151. />
  152. );
  153. }
  154. function renderGridPreview(dashboard: any) {
  155. return <GridPreview widgetPreview={dashboard.widgetPreview} />;
  156. }
  157. // TODO(__SENTRY_USING_REACT_ROUTER_SIX): We can remove this later, react
  158. // router 6 handles empty query objects without appending a trailing ?
  159. const queryLocation = {
  160. ...(location.query && Object.keys(location.query).length > 0
  161. ? {query: location.query}
  162. : {}),
  163. };
  164. function renderMiniDashboards() {
  165. // on pagination, render no dashboards to show placeholders while loading
  166. if (
  167. rowCount * columnCount === currentDashboards?.length &&
  168. !isEqual(currentDashboards, dashboards)
  169. ) {
  170. return [];
  171. }
  172. return currentDashboards?.slice(0, rowCount * columnCount).map((dashboard, index) => {
  173. return (
  174. <DashboardCard
  175. key={`${index}-${dashboard.id}`}
  176. title={dashboard.title}
  177. to={{
  178. pathname: `/organizations/${organization.slug}/dashboard/${dashboard.id}/`,
  179. ...queryLocation,
  180. }}
  181. detail={tn('%s widget', '%s widgets', dashboard.widgetPreview.length)}
  182. dateStatus={
  183. dashboard.dateCreated ? <TimeSince date={dashboard.dateCreated} /> : undefined
  184. }
  185. createdBy={dashboard.createdBy}
  186. renderWidgets={() => renderGridPreview(dashboard)}
  187. renderContextMenu={() => renderDropdownMenu(dashboard)}
  188. isFavorited={dashboard.isFavorited}
  189. onFavorite={isFavorited => handleFavorite(dashboard, isFavorited)}
  190. />
  191. );
  192. });
  193. }
  194. function renderDashboardGrid() {
  195. if (!dashboards?.length && !isLoading) {
  196. return (
  197. <EmptyStateWarning>
  198. <p>{t('Sorry, no Dashboards match your filters.')}</p>
  199. </EmptyStateWarning>
  200. );
  201. }
  202. const gridIsBeingResized = rowCount * columnCount !== currentDashboards?.length;
  203. // finds number of dashboards (cached or not) based on if the screen is being resized or not
  204. const numDashboards = gridIsBeingResized
  205. ? currentDashboards?.length ?? 0
  206. : dashboards?.length ?? 0;
  207. return (
  208. <DashboardGridContainer
  209. rows={rowCount}
  210. columns={columnCount}
  211. data-test-id={'dashboard-grid'}
  212. >
  213. {renderMiniDashboards()}
  214. {isLoading &&
  215. rowCount * columnCount > numDashboards &&
  216. new Array(rowCount * columnCount - numDashboards)
  217. .fill(0)
  218. .map((_, index) => <Placeholder key={index} height="210px" />)}
  219. </DashboardGridContainer>
  220. );
  221. }
  222. return <Fragment>{renderDashboardGrid()}</Fragment>;
  223. }
  224. const DashboardGridContainer = styled('div')<{columns: number; rows: number}>`
  225. display: grid;
  226. grid-template-columns: repeat(
  227. ${props => props.columns},
  228. minmax(${MINIMUM_DASHBOARD_CARD_WIDTH}px, 1fr)
  229. );
  230. grid-template-rows: repeat(${props => props.rows}, max-content);
  231. gap: ${DASHBOARD_CARD_GRID_PADDING}px;
  232. `;
  233. const DropdownTrigger = styled(Button)`
  234. transform: translateX(${space(1)});
  235. `;
  236. export default withApi(DashboardGrid);