dashboardTable.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import {useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import type {Location} from 'history';
  4. import cloneDeep from 'lodash/cloneDeep';
  5. import {
  6. createDashboard,
  7. deleteDashboard,
  8. fetchDashboard,
  9. updateDashboardFavorite,
  10. updateDashboardPermissions,
  11. } from 'sentry/actionCreators/dashboards';
  12. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  13. import type {Client} from 'sentry/api';
  14. import {ActivityAvatar} from 'sentry/components/activity/item/avatar';
  15. import UserAvatar from 'sentry/components/avatar/userAvatar';
  16. import {Button} from 'sentry/components/button';
  17. import {openConfirmModal} from 'sentry/components/confirm';
  18. import EmptyStateWarning from 'sentry/components/emptyStateWarning';
  19. import GridEditable, {
  20. COL_WIDTH_UNDEFINED,
  21. type GridColumnOrder,
  22. } from 'sentry/components/gridEditable';
  23. import SortLink from 'sentry/components/gridEditable/sortLink';
  24. import Link from 'sentry/components/links/link';
  25. import TimeSince from 'sentry/components/timeSince';
  26. import {IconCopy, IconDelete, IconStar} from 'sentry/icons';
  27. import {t} from 'sentry/locale';
  28. import {space} from 'sentry/styles/space';
  29. import type {Organization} from 'sentry/types/organization';
  30. import {trackAnalytics} from 'sentry/utils/analytics';
  31. import {decodeScalar} from 'sentry/utils/queryString';
  32. import withApi from 'sentry/utils/withApi';
  33. import EditAccessSelector from 'sentry/views/dashboards/editAccessSelector';
  34. import type {
  35. DashboardDetails,
  36. DashboardListItem,
  37. DashboardPermissions,
  38. } from 'sentry/views/dashboards/types';
  39. import {cloneDashboard} from '../utils';
  40. type Props = {
  41. api: Client;
  42. dashboards: DashboardListItem[] | undefined;
  43. location: Location;
  44. onDashboardsChange: () => void;
  45. organization: Organization;
  46. isLoading?: boolean;
  47. };
  48. enum ResponseKeys {
  49. NAME = 'title',
  50. WIDGETS = 'widgetDisplay',
  51. OWNER = 'createdBy',
  52. ACCESS = 'permissions',
  53. CREATED = 'dateCreated',
  54. FAVORITE = 'isFavorited',
  55. }
  56. const SortKeys = {
  57. title: {asc: 'title', desc: '-title'},
  58. dateCreated: {asc: 'dateCreated', desc: '-dateCreated'},
  59. createdBy: {asc: 'mydashboards', desc: 'mydashboards'},
  60. };
  61. type FavoriteButtonProps = {
  62. api: Client;
  63. dashboardId: string;
  64. isFavorited: boolean;
  65. onDashboardsChange: () => void;
  66. organization: Organization;
  67. };
  68. function FavoriteButton({
  69. isFavorited,
  70. api,
  71. organization,
  72. dashboardId,
  73. onDashboardsChange,
  74. }: FavoriteButtonProps) {
  75. const [favorited, setFavorited] = useState(isFavorited);
  76. return (
  77. <Button
  78. aria-label={t('Favorite Button')}
  79. size="zero"
  80. borderless
  81. icon={
  82. <IconStar
  83. color={favorited ? 'yellow300' : 'gray300'}
  84. isSolid={favorited}
  85. aria-label={favorited ? t('UnFavorite') : t('Favorite')}
  86. size="sm"
  87. />
  88. }
  89. onClick={async () => {
  90. try {
  91. setFavorited(!favorited);
  92. await updateDashboardFavorite(api, organization.slug, dashboardId, !favorited);
  93. onDashboardsChange();
  94. trackAnalytics('dashboards_manage.toggle_favorite', {
  95. organization,
  96. dashboard_id: dashboardId,
  97. favorited: !favorited,
  98. });
  99. } catch (error) {
  100. // If the api call fails, revert the state
  101. setFavorited(favorited);
  102. }
  103. }}
  104. />
  105. );
  106. }
  107. function DashboardTable({
  108. api,
  109. organization,
  110. location,
  111. dashboards,
  112. onDashboardsChange,
  113. isLoading,
  114. }: Props) {
  115. const columnOrder: Array<GridColumnOrder<ResponseKeys>> = [
  116. {key: ResponseKeys.NAME, name: t('Name'), width: COL_WIDTH_UNDEFINED},
  117. {key: ResponseKeys.WIDGETS, name: t('Widgets'), width: COL_WIDTH_UNDEFINED},
  118. {key: ResponseKeys.OWNER, name: t('Owner'), width: COL_WIDTH_UNDEFINED},
  119. {key: ResponseKeys.ACCESS, name: t('Access'), width: COL_WIDTH_UNDEFINED},
  120. {key: ResponseKeys.CREATED, name: t('Created'), width: COL_WIDTH_UNDEFINED},
  121. ];
  122. function handleDelete(dashboard: DashboardListItem) {
  123. deleteDashboard(api, organization.slug, dashboard.id)
  124. .then(() => {
  125. trackAnalytics('dashboards_manage.delete', {
  126. organization,
  127. dashboard_id: parseInt(dashboard.id, 10),
  128. view_type: 'table',
  129. });
  130. onDashboardsChange();
  131. addSuccessMessage(t('Dashboard deleted'));
  132. })
  133. .catch(() => {
  134. addErrorMessage(t('Error deleting Dashboard'));
  135. });
  136. }
  137. async function handleDuplicate(dashboard: DashboardListItem) {
  138. try {
  139. const dashboardDetail = await fetchDashboard(api, organization.slug, dashboard.id);
  140. const newDashboard = cloneDashboard(dashboardDetail);
  141. newDashboard.widgets.map(widget => (widget.id = undefined));
  142. await createDashboard(api, organization.slug, newDashboard, true);
  143. trackAnalytics('dashboards_manage.duplicate', {
  144. organization,
  145. dashboard_id: parseInt(dashboard.id, 10),
  146. view_type: 'table',
  147. });
  148. onDashboardsChange();
  149. addSuccessMessage(t('Dashboard duplicated'));
  150. } catch (e) {
  151. addErrorMessage(t('Error duplicating Dashboard'));
  152. }
  153. }
  154. // TODO(__SENTRY_USING_REACT_ROUTER_SIX): We can remove this later, react
  155. // router 6 handles empty query objects without appending a trailing ?
  156. const queryLocation = {
  157. ...(location.query && Object.keys(location.query).length > 0
  158. ? {query: location.query}
  159. : {}),
  160. };
  161. function renderHeadCell(column: GridColumnOrder<string>) {
  162. if (column.key in SortKeys) {
  163. const urlSort = decodeScalar(location.query.sort, 'mydashboards');
  164. const isCurrentSort =
  165. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  166. urlSort === SortKeys[column.key].asc || urlSort === SortKeys[column.key].desc;
  167. const sortDirection =
  168. !isCurrentSort || column.key === 'createdBy'
  169. ? undefined
  170. : urlSort.startsWith('-')
  171. ? 'desc'
  172. : 'asc';
  173. return (
  174. <SortLink
  175. align={'left'}
  176. title={column.name}
  177. direction={sortDirection}
  178. canSort
  179. generateSortLink={() => {
  180. const newSort = isCurrentSort
  181. ? sortDirection === 'asc'
  182. ? // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  183. SortKeys[column.key].desc
  184. : // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  185. SortKeys[column.key].asc
  186. : // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  187. SortKeys[column.key].asc;
  188. return {
  189. ...location,
  190. query: {...location.query, sort: newSort},
  191. };
  192. }}
  193. />
  194. );
  195. }
  196. return column.name;
  197. }
  198. const renderBodyCell = (
  199. column: GridColumnOrder<string>,
  200. dataRow: DashboardListItem
  201. ) => {
  202. if (column.key === ResponseKeys.FAVORITE) {
  203. return (
  204. <FavoriteButton
  205. isFavorited={dataRow[ResponseKeys.FAVORITE] ?? false}
  206. api={api}
  207. organization={organization}
  208. dashboardId={dataRow.id}
  209. onDashboardsChange={onDashboardsChange}
  210. key={dataRow.id}
  211. />
  212. );
  213. }
  214. if (column.key === ResponseKeys.NAME) {
  215. return (
  216. <Link
  217. to={{
  218. pathname: `/organizations/${organization.slug}/dashboard/${dataRow.id}/`,
  219. ...queryLocation,
  220. }}
  221. >
  222. {dataRow[ResponseKeys.NAME]}
  223. </Link>
  224. );
  225. }
  226. if (column.key === ResponseKeys.WIDGETS) {
  227. return dataRow[ResponseKeys.WIDGETS].length;
  228. }
  229. if (column.key === ResponseKeys.OWNER) {
  230. return dataRow[ResponseKeys.OWNER] ? (
  231. <BodyCellContainer>
  232. <UserAvatar hasTooltip user={dataRow[ResponseKeys.OWNER]} size={26} />
  233. </BodyCellContainer>
  234. ) : (
  235. <ActivityAvatar type="system" size={26} />
  236. );
  237. }
  238. if (column.key === ResponseKeys.ACCESS) {
  239. /* Handles POST request for Edit Access Selector Changes */
  240. const onChangeEditAccess = (newDashboardPermissions: DashboardPermissions) => {
  241. const dashboardCopy = cloneDeep(dataRow);
  242. dashboardCopy.permissions = newDashboardPermissions;
  243. updateDashboardPermissions(api, organization.slug, dashboardCopy).then(
  244. (newDashboard: DashboardDetails) => {
  245. onDashboardsChange();
  246. addSuccessMessage(t('Dashboard Edit Access updated.'));
  247. return newDashboard;
  248. }
  249. );
  250. };
  251. return (
  252. <EditAccessSelector
  253. dashboard={dataRow}
  254. onChangeEditAccess={onChangeEditAccess}
  255. listOnly
  256. />
  257. );
  258. }
  259. if (column.key === ResponseKeys.CREATED) {
  260. return (
  261. <BodyCellContainer>
  262. <DateSelected>
  263. {dataRow[ResponseKeys.CREATED] ? (
  264. <DateStatus>
  265. <TimeSince date={dataRow[ResponseKeys.CREATED]} />
  266. </DateStatus>
  267. ) : (
  268. <DateStatus />
  269. )}
  270. </DateSelected>
  271. <ActionsIconWrapper>
  272. <StyledButton
  273. onClick={e => {
  274. e.stopPropagation();
  275. openConfirmModal({
  276. message: t('Are you sure you want to duplicate this dashboard?'),
  277. priority: 'primary',
  278. onConfirm: () => handleDuplicate(dataRow),
  279. });
  280. }}
  281. aria-label={t('Duplicate Dashboard')}
  282. data-test-id={'dashboard-duplicate'}
  283. icon={<IconCopy />}
  284. size="sm"
  285. />
  286. <StyledButton
  287. onClick={e => {
  288. e.stopPropagation();
  289. openConfirmModal({
  290. message: t('Are you sure you want to delete this dashboard?'),
  291. priority: 'danger',
  292. onConfirm: () => handleDelete(dataRow),
  293. });
  294. }}
  295. aria-label={t('Delete Dashboard')}
  296. data-test-id={'dashboard-delete'}
  297. icon={<IconDelete />}
  298. size="sm"
  299. disabled={dashboards && dashboards.length <= 1}
  300. />
  301. </ActionsIconWrapper>
  302. </BodyCellContainer>
  303. );
  304. }
  305. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  306. return <span>{dataRow[column.key]}</span>;
  307. };
  308. return (
  309. <GridEditable
  310. data={dashboards ?? []}
  311. // necessary for edit access dropdown
  312. bodyStyle={{overflow: 'visible'}}
  313. columnOrder={columnOrder}
  314. columnSortBy={[]}
  315. grid={{
  316. renderBodyCell,
  317. renderHeadCell: column => renderHeadCell(column),
  318. // favorite column
  319. renderPrependColumns: (isHeader: boolean, dataRow?: any) => {
  320. if (!organization.features.includes('dashboards-favourite')) {
  321. return [];
  322. }
  323. const favoriteColumn = {
  324. key: ResponseKeys.FAVORITE,
  325. name: t('Favorite'),
  326. };
  327. if (isHeader) {
  328. return [
  329. <StyledIconStar
  330. color="yellow300"
  331. isSolid
  332. aria-label={t('Favorite Column')}
  333. key="favorite-header"
  334. />,
  335. ];
  336. }
  337. if (!dataRow) {
  338. return [];
  339. }
  340. return [renderBodyCell(favoriteColumn, dataRow) as any];
  341. },
  342. prependColumnWidths: organization.features.includes('dashboards-favourite')
  343. ? ['max-content']
  344. : [],
  345. }}
  346. isLoading={isLoading}
  347. emptyMessage={
  348. <EmptyStateWarning>
  349. <p>{t('Sorry, no Dashboards match your filters.')}</p>
  350. </EmptyStateWarning>
  351. }
  352. />
  353. );
  354. }
  355. export default withApi(DashboardTable);
  356. const DateSelected = styled('div')`
  357. font-size: ${p => p.theme.fontSizeMedium};
  358. display: grid;
  359. grid-column-gap: ${space(1)};
  360. color: ${p => p.theme.textColor};
  361. ${p => p.theme.overflowEllipsis};
  362. `;
  363. const DateStatus = styled('span')`
  364. color: ${p => p.theme.textColor};
  365. padding-left: ${space(1)};
  366. `;
  367. const BodyCellContainer = styled('div')`
  368. display: flex;
  369. gap: ${space(4)};
  370. justify-content: space-between;
  371. align-items: center;
  372. `;
  373. const ActionsIconWrapper = styled('div')`
  374. display: flex;
  375. `;
  376. const StyledButton = styled(Button)`
  377. border: none;
  378. box-shadow: none;
  379. `;
  380. const StyledIconStar = styled(IconStar)`
  381. margin-left: ${space(0.25)};
  382. `;