dashboardGrid.tsx 7.6 KB

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