dashboardGrid.tsx 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  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. try {
  98. await updateDashboardFavorite(api, organization.slug, dashboard.id, isFavorited);
  99. onDashboardsChange();
  100. } catch (error) {
  101. throw error;
  102. }
  103. }
  104. function renderDropdownMenu(dashboard: DashboardListItem) {
  105. const menuItems: MenuItemProps[] = [
  106. {
  107. key: 'dashboard-duplicate',
  108. label: t('Duplicate'),
  109. onAction: () => {
  110. openConfirmModal({
  111. message: t('Are you sure you want to duplicate this dashboard?'),
  112. priority: 'primary',
  113. onConfirm: () => handleDuplicate(dashboard),
  114. });
  115. },
  116. },
  117. {
  118. key: 'dashboard-delete',
  119. label: t('Delete'),
  120. priority: 'danger',
  121. onAction: () => {
  122. openConfirmModal({
  123. message: t('Are you sure you want to delete this dashboard?'),
  124. priority: 'danger',
  125. onConfirm: () => handleDelete(dashboard),
  126. });
  127. },
  128. },
  129. ];
  130. return (
  131. <DropdownMenu
  132. items={menuItems}
  133. trigger={triggerProps => (
  134. <DropdownTrigger
  135. {...triggerProps}
  136. aria-label={t('Dashboard actions')}
  137. size="xs"
  138. borderless
  139. onClick={e => {
  140. e.stopPropagation();
  141. e.preventDefault();
  142. triggerProps.onClick?.(e);
  143. }}
  144. icon={<IconEllipsis direction="down" size="sm" />}
  145. />
  146. )}
  147. position="bottom-end"
  148. disabledKeys={dashboards && dashboards.length <= 1 ? ['dashboard-delete'] : []}
  149. offset={4}
  150. />
  151. );
  152. }
  153. function renderGridPreview(dashboard) {
  154. return <GridPreview widgetPreview={dashboard.widgetPreview} />;
  155. }
  156. // TODO(__SENTRY_USING_REACT_ROUTER_SIX): We can remove this later, react
  157. // router 6 handles empty query objects without appending a trailing ?
  158. const queryLocation = {
  159. ...(location.query && Object.keys(location.query).length > 0
  160. ? {query: location.query}
  161. : {}),
  162. };
  163. function renderMiniDashboards() {
  164. // on pagination, render no dashboards to show placeholders while loading
  165. if (
  166. rowCount * columnCount === currentDashboards?.length &&
  167. !isEqual(currentDashboards, dashboards)
  168. ) {
  169. return [];
  170. }
  171. return currentDashboards?.slice(0, rowCount * columnCount).map((dashboard, index) => {
  172. return (
  173. <DashboardCard
  174. key={`${index}-${dashboard.id}`}
  175. title={dashboard.title}
  176. to={{
  177. pathname: `/organizations/${organization.slug}/dashboard/${dashboard.id}/`,
  178. ...queryLocation,
  179. }}
  180. detail={tn('%s widget', '%s widgets', dashboard.widgetPreview.length)}
  181. dateStatus={
  182. dashboard.dateCreated ? <TimeSince date={dashboard.dateCreated} /> : undefined
  183. }
  184. createdBy={dashboard.createdBy}
  185. renderWidgets={() => renderGridPreview(dashboard)}
  186. renderContextMenu={() => renderDropdownMenu(dashboard)}
  187. isFavorited={dashboard.isFavorited}
  188. onFavorite={isFavorited => handleFavorite(dashboard, isFavorited)}
  189. />
  190. );
  191. });
  192. }
  193. function renderDashboardGrid() {
  194. if (!dashboards?.length && !isLoading) {
  195. return (
  196. <EmptyStateWarning>
  197. <p>{t('Sorry, no Dashboards match your filters.')}</p>
  198. </EmptyStateWarning>
  199. );
  200. }
  201. const gridIsBeingResized = rowCount * columnCount !== currentDashboards?.length;
  202. // finds number of dashboards (cached or not) based on if the screen is being resized or not
  203. const numDashboards = gridIsBeingResized
  204. ? currentDashboards?.length ?? 0
  205. : dashboards?.length ?? 0;
  206. return (
  207. <DashboardGridContainer
  208. rows={rowCount}
  209. columns={columnCount}
  210. data-test-id={'dashboard-grid'}
  211. >
  212. {renderMiniDashboards()}
  213. {isLoading &&
  214. rowCount * columnCount > numDashboards &&
  215. new Array(rowCount * columnCount - numDashboards)
  216. .fill(0)
  217. .map((_, index) => <Placeholder key={index} height="270px" />)}
  218. </DashboardGridContainer>
  219. );
  220. }
  221. return <Fragment>{renderDashboardGrid()}</Fragment>;
  222. }
  223. const DashboardGridContainer = styled('div')<{columns: number; rows: number}>`
  224. display: grid;
  225. grid-template-columns: repeat(
  226. ${props => props.columns},
  227. minmax(${MINIMUM_DASHBOARD_CARD_WIDTH}px, 1fr)
  228. );
  229. grid-template-rows: repeat(${props => props.rows}, max-content);
  230. gap: ${DASHBOARD_CARD_GRID_PADDING}px;
  231. `;
  232. const DropdownTrigger = styled(Button)`
  233. transform: translateX(${space(1)});
  234. `;
  235. export default withApi(DashboardGrid);