dashboardList.tsx 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. import {Fragment} from 'react';
  2. import {browserHistory} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {Location, Query} from 'history';
  5. import {
  6. createDashboard,
  7. deleteDashboard,
  8. fetchDashboard,
  9. } from 'sentry/actionCreators/dashboards';
  10. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  11. import {Client} from 'sentry/api';
  12. import Button from 'sentry/components/button';
  13. import {openConfirmModal} from 'sentry/components/confirm';
  14. import DropdownMenuControl from 'sentry/components/dropdownMenuControl';
  15. import {MenuItemProps} from 'sentry/components/dropdownMenuItem';
  16. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  17. import Pagination from 'sentry/components/pagination';
  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 {Organization} from 'sentry/types';
  23. import trackAdvancedAnalytics from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  24. import withApi from 'sentry/utils/withApi';
  25. import {DashboardListItem, DisplayType} from 'sentry/views/dashboardsV2/types';
  26. import {cloneDashboard, miniWidget} from '../utils';
  27. import DashboardCard from './dashboardCard';
  28. import GridPreview from './gridPreview';
  29. type Props = {
  30. api: Client;
  31. dashboards: DashboardListItem[] | null;
  32. location: Location;
  33. onDashboardsChange: () => void;
  34. organization: Organization;
  35. pageLinks: string;
  36. };
  37. function DashboardList({
  38. api,
  39. organization,
  40. location,
  41. dashboards,
  42. pageLinks,
  43. onDashboardsChange,
  44. }: Props) {
  45. function handleDelete(dashboard: DashboardListItem) {
  46. deleteDashboard(api, organization.slug, dashboard.id)
  47. .then(() => {
  48. trackAdvancedAnalytics('dashboards_manage.delete', {
  49. organization,
  50. dashboard_id: parseInt(dashboard.id, 10),
  51. });
  52. onDashboardsChange();
  53. addSuccessMessage(t('Dashboard deleted'));
  54. })
  55. .catch(() => {
  56. addErrorMessage(t('Error deleting Dashboard'));
  57. });
  58. }
  59. async function handleDuplicate(dashboard: DashboardListItem) {
  60. try {
  61. const dashboardDetail = await fetchDashboard(api, organization.slug, dashboard.id);
  62. const newDashboard = cloneDashboard(dashboardDetail);
  63. newDashboard.widgets.map(widget => (widget.id = undefined));
  64. await createDashboard(api, organization.slug, newDashboard, true);
  65. trackAdvancedAnalytics('dashboards_manage.duplicate', {
  66. organization,
  67. dashboard_id: parseInt(dashboard.id, 10),
  68. });
  69. onDashboardsChange();
  70. addSuccessMessage(t('Dashboard duplicated'));
  71. } catch (e) {
  72. addErrorMessage(t('Error duplicating Dashboard'));
  73. }
  74. }
  75. function renderDropdownMenu(dashboard: DashboardListItem) {
  76. const menuItems: MenuItemProps[] = [
  77. {
  78. key: 'dashboard-duplicate',
  79. label: t('Duplicate'),
  80. onAction: () => handleDuplicate(dashboard),
  81. },
  82. {
  83. key: 'dashboard-delete',
  84. label: t('Delete'),
  85. priority: 'danger',
  86. onAction: () => {
  87. openConfirmModal({
  88. message: t('Are you sure you want to delete this dashboard?'),
  89. priority: 'danger',
  90. onConfirm: () => handleDelete(dashboard),
  91. });
  92. },
  93. },
  94. ];
  95. return (
  96. <DropdownMenuControl
  97. items={menuItems}
  98. trigger={triggerProps => (
  99. <DropdownTrigger
  100. {...triggerProps}
  101. aria-label={t('Dashboard actions')}
  102. size="xs"
  103. borderless
  104. onClick={e => {
  105. e.stopPropagation();
  106. e.preventDefault();
  107. triggerProps.onClick?.(e);
  108. }}
  109. icon={<IconEllipsis direction="down" size="sm" />}
  110. />
  111. )}
  112. position="bottom-end"
  113. disabledKeys={dashboards && dashboards.length <= 1 ? ['dashboard-delete'] : []}
  114. offset={4}
  115. />
  116. );
  117. }
  118. function renderDndPreview(dashboard) {
  119. return (
  120. <WidgetGrid>
  121. {dashboard.widgetDisplay.map((displayType, i) => {
  122. return displayType === DisplayType.BIG_NUMBER ? (
  123. <BigNumberWidgetWrapper key={`${i}-${displayType}`}>
  124. <WidgetImage src={miniWidget(displayType)} />
  125. </BigNumberWidgetWrapper>
  126. ) : (
  127. <MiniWidgetWrapper key={`${i}-${displayType}`}>
  128. <WidgetImage src={miniWidget(displayType)} />
  129. </MiniWidgetWrapper>
  130. );
  131. })}
  132. </WidgetGrid>
  133. );
  134. }
  135. function renderGridPreview(dashboard) {
  136. return <GridPreview widgetPreview={dashboard.widgetPreview} />;
  137. }
  138. function renderMiniDashboards() {
  139. const isUsingGrid = organization.features.includes('dashboard-grid-layout');
  140. return dashboards?.map((dashboard, index) => {
  141. const widgetRenderer = isUsingGrid ? renderGridPreview : renderDndPreview;
  142. const widgetCount = isUsingGrid
  143. ? dashboard.widgetPreview.length
  144. : dashboard.widgetDisplay.length;
  145. return (
  146. <DashboardCard
  147. key={`${index}-${dashboard.id}`}
  148. title={dashboard.title}
  149. to={{
  150. pathname: `/organizations/${organization.slug}/dashboard/${dashboard.id}/`,
  151. query: {...location.query},
  152. }}
  153. detail={tn('%s widget', '%s widgets', widgetCount)}
  154. dateStatus={
  155. dashboard.dateCreated ? <TimeSince date={dashboard.dateCreated} /> : undefined
  156. }
  157. createdBy={dashboard.createdBy}
  158. renderWidgets={() => widgetRenderer(dashboard)}
  159. renderContextMenu={() => renderDropdownMenu(dashboard)}
  160. />
  161. );
  162. });
  163. }
  164. function renderDashboardGrid() {
  165. if (!dashboards?.length) {
  166. return (
  167. <EmptyStateWarning>
  168. <p>{t('Sorry, no Dashboards match your filters.')}</p>
  169. </EmptyStateWarning>
  170. );
  171. }
  172. return <DashboardGrid>{renderMiniDashboards()}</DashboardGrid>;
  173. }
  174. return (
  175. <Fragment>
  176. {renderDashboardGrid()}
  177. <PaginationRow
  178. pageLinks={pageLinks}
  179. onCursor={(cursor, path, query, direction) => {
  180. const offset = Number(cursor?.split?.(':')?.[1] ?? 0);
  181. const newQuery: Query & {cursor?: string} = {...query, cursor};
  182. const isPrevious = direction === -1;
  183. if (offset <= 0 && isPrevious) {
  184. delete newQuery.cursor;
  185. }
  186. trackAdvancedAnalytics('dashboards_manage.paginate', {organization});
  187. browserHistory.push({
  188. pathname: path,
  189. query: newQuery,
  190. });
  191. }}
  192. />
  193. </Fragment>
  194. );
  195. }
  196. const DashboardGrid = styled('div')`
  197. display: grid;
  198. grid-template-columns: minmax(100px, 1fr);
  199. grid-template-rows: repeat(3, max-content);
  200. gap: ${space(2)};
  201. @media (min-width: ${p => p.theme.breakpoints.small}) {
  202. grid-template-columns: repeat(2, minmax(100px, 1fr));
  203. }
  204. @media (min-width: ${p => p.theme.breakpoints.large}) {
  205. grid-template-columns: repeat(3, minmax(100px, 1fr));
  206. }
  207. `;
  208. const WidgetGrid = styled('div')`
  209. display: grid;
  210. grid-template-columns: repeat(2, minmax(0, 1fr));
  211. grid-auto-flow: row dense;
  212. gap: ${space(0.25)};
  213. @media (min-width: ${p => p.theme.breakpoints.medium}) {
  214. grid-template-columns: repeat(4, minmax(0, 1fr));
  215. }
  216. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  217. grid-template-columns: repeat(6, minmax(0, 1fr));
  218. }
  219. @media (min-width: ${p => p.theme.breakpoints.xxlarge}) {
  220. grid-template-columns: repeat(8, minmax(0, 1fr));
  221. }
  222. `;
  223. const BigNumberWidgetWrapper = styled('div')`
  224. display: flex;
  225. align-items: flex-start;
  226. width: 100%;
  227. height: 100%;
  228. /* 2 cols */
  229. grid-area: span 1 / span 2;
  230. @media (min-width: ${p => p.theme.breakpoints.small}) {
  231. /* 4 cols */
  232. grid-area: span 1 / span 1;
  233. }
  234. @media (min-width: ${p => p.theme.breakpoints.xlarge}) {
  235. /* 6 and 8 cols */
  236. grid-area: span 1 / span 2;
  237. }
  238. `;
  239. const MiniWidgetWrapper = styled('div')`
  240. display: flex;
  241. align-items: flex-start;
  242. width: 100%;
  243. height: 100%;
  244. grid-area: span 2 / span 2;
  245. `;
  246. const WidgetImage = styled('img')`
  247. width: 100%;
  248. height: 100%;
  249. `;
  250. const PaginationRow = styled(Pagination)`
  251. margin-bottom: ${space(3)};
  252. `;
  253. const DropdownTrigger = styled(Button)`
  254. transform: translateX(${space(1)});
  255. `;
  256. export default withApi(DashboardList);