dashboardGrid.tsx 7.3 KB

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