index.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363
  1. import {browserHistory, InjectedRouter} from 'react-router';
  2. import styled from '@emotion/styled';
  3. import pick from 'lodash/pick';
  4. import {createDashboard} from 'sentry/actionCreators/dashboards';
  5. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  6. import {Client} from 'sentry/api';
  7. import Feature from 'sentry/components/acl/feature';
  8. import Alert from 'sentry/components/alert';
  9. import Button from 'sentry/components/button';
  10. import ButtonBar from 'sentry/components/buttonBar';
  11. import CompactSelect from 'sentry/components/forms/compactSelect';
  12. import {Title} from 'sentry/components/layouts/thirds';
  13. import LoadingIndicator from 'sentry/components/loadingIndicator';
  14. import NoProjectMessage from 'sentry/components/noProjectMessage';
  15. import SearchBar from 'sentry/components/searchBar';
  16. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  17. import Switch from 'sentry/components/switchButton';
  18. import {IconAdd} from 'sentry/icons';
  19. import {t} from 'sentry/locale';
  20. import {PageContent} from 'sentry/styles/organization';
  21. import space from 'sentry/styles/space';
  22. import {Organization, SelectValue} from 'sentry/types';
  23. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  24. import {decodeScalar} from 'sentry/utils/queryString';
  25. import withApi from 'sentry/utils/withApi';
  26. import withOrganization from 'sentry/utils/withOrganization';
  27. import AsyncView from 'sentry/views/asyncView';
  28. import {DASHBOARDS_TEMPLATES} from '../data';
  29. import {assignDefaultLayout, getInitialColumnDepths} from '../layoutUtils';
  30. import {DashboardDetails, DashboardListItem} from '../types';
  31. import DashboardList from './dashboardList';
  32. import TemplateCard from './templateCard';
  33. import {setShowTemplates, shouldShowTemplates} from './utils';
  34. const SORT_OPTIONS: SelectValue<string>[] = [
  35. {label: t('My Dashboards'), value: 'mydashboards'},
  36. {label: t('Dashboard Name (A-Z)'), value: 'title'},
  37. {label: t('Date Created (Newest)'), value: '-dateCreated'},
  38. {label: t('Date Created (Oldest)'), value: 'dateCreated'},
  39. {label: t('Most Popular'), value: 'mostPopular'},
  40. {label: t('Recently Viewed'), value: 'recentlyViewed'},
  41. ];
  42. type Props = {
  43. api: Client;
  44. location: Location;
  45. organization: Organization;
  46. router: InjectedRouter;
  47. } & AsyncView['props'];
  48. type State = {
  49. dashboards: DashboardListItem[] | null;
  50. dashboardsPageLinks: string;
  51. showTemplates: boolean;
  52. } & AsyncView['state'];
  53. class ManageDashboards extends AsyncView<Props, State> {
  54. getDefaultState() {
  55. return {
  56. ...super.getDefaultState(),
  57. showTemplates: shouldShowTemplates(),
  58. };
  59. }
  60. getEndpoints(): ReturnType<AsyncView['getEndpoints']> {
  61. const {organization, location} = this.props;
  62. return [
  63. [
  64. 'dashboards',
  65. `/organizations/${organization.slug}/dashboards/`,
  66. {
  67. query: {
  68. ...pick(location.query, ['cursor', 'query']),
  69. sort: this.getActiveSort().value,
  70. per_page: '9',
  71. },
  72. },
  73. ],
  74. ];
  75. }
  76. getActiveSort() {
  77. const {location} = this.props;
  78. const urlSort = decodeScalar(location.query.sort, 'mydashboards');
  79. return SORT_OPTIONS.find(item => item.value === urlSort) || SORT_OPTIONS[0];
  80. }
  81. onDashboardsChange() {
  82. this.reloadData();
  83. }
  84. handleSearch(query: string) {
  85. const {location, router, organization} = this.props;
  86. trackAdvancedAnalyticsEvent('dashboards_manage.search', {
  87. organization,
  88. });
  89. router.push({
  90. pathname: location.pathname,
  91. query: {...location.query, cursor: undefined, query},
  92. });
  93. }
  94. handleSortChange = (value: string) => {
  95. const {location, organization} = this.props;
  96. trackAdvancedAnalyticsEvent('dashboards_manage.change_sort', {
  97. organization,
  98. sort: value,
  99. });
  100. browserHistory.push({
  101. pathname: location.pathname,
  102. query: {
  103. ...location.query,
  104. cursor: undefined,
  105. sort: value,
  106. },
  107. });
  108. };
  109. toggleTemplates = () => {
  110. const {showTemplates} = this.state;
  111. const {organization} = this.props;
  112. trackAdvancedAnalyticsEvent('dashboards_manage.templates.toggle', {
  113. organization,
  114. show_templates: !showTemplates,
  115. });
  116. this.setState({showTemplates: !showTemplates}, () => {
  117. setShowTemplates(!showTemplates);
  118. });
  119. };
  120. getQuery() {
  121. const {query} = this.props.location.query;
  122. return typeof query === 'string' ? query : undefined;
  123. }
  124. renderTemplates() {
  125. return (
  126. <TemplateContainer>
  127. {DASHBOARDS_TEMPLATES.map(dashboard => (
  128. <TemplateCard
  129. title={dashboard.title}
  130. description={dashboard.description}
  131. onPreview={() => this.onPreview(dashboard.id)}
  132. onAdd={() => this.onAdd(dashboard)}
  133. key={dashboard.title}
  134. />
  135. ))}
  136. </TemplateContainer>
  137. );
  138. }
  139. renderActions() {
  140. const activeSort = this.getActiveSort();
  141. return (
  142. <StyledActions>
  143. <SearchBar
  144. defaultQuery=""
  145. query={this.getQuery()}
  146. placeholder={t('Search Dashboards')}
  147. onSearch={query => this.handleSearch(query)}
  148. />
  149. <CompactSelect
  150. triggerProps={{prefix: t('Sort By')}}
  151. value={activeSort.value}
  152. options={SORT_OPTIONS}
  153. onChange={opt => this.handleSortChange(opt.value)}
  154. placement="bottom right"
  155. />
  156. </StyledActions>
  157. );
  158. }
  159. renderNoAccess() {
  160. return (
  161. <PageContent>
  162. <Alert type="warning">{t("You don't have access to this feature")}</Alert>
  163. </PageContent>
  164. );
  165. }
  166. renderDashboards() {
  167. const {dashboards, dashboardsPageLinks} = this.state;
  168. const {organization, location, api} = this.props;
  169. return (
  170. <DashboardList
  171. api={api}
  172. dashboards={dashboards}
  173. organization={organization}
  174. pageLinks={dashboardsPageLinks}
  175. location={location}
  176. onDashboardsChange={() => this.onDashboardsChange()}
  177. />
  178. );
  179. }
  180. getTitle() {
  181. return t('Dashboards');
  182. }
  183. onCreate() {
  184. const {organization, location} = this.props;
  185. trackAdvancedAnalyticsEvent('dashboards_manage.create.start', {
  186. organization,
  187. });
  188. browserHistory.push({
  189. pathname: `/organizations/${organization.slug}/dashboards/new/`,
  190. query: location.query,
  191. });
  192. }
  193. async onAdd(dashboard: DashboardDetails) {
  194. const {organization, api} = this.props;
  195. trackAdvancedAnalyticsEvent('dashboards_manage.templates.add', {
  196. organization,
  197. dashboard_id: dashboard.id,
  198. dashboard_title: dashboard.title,
  199. was_previewed: false,
  200. });
  201. await createDashboard(
  202. api,
  203. organization.slug,
  204. {
  205. ...dashboard,
  206. widgets: assignDefaultLayout(dashboard.widgets, getInitialColumnDepths()),
  207. },
  208. true
  209. );
  210. this.onDashboardsChange();
  211. addSuccessMessage(`${dashboard.title} dashboard template successfully added.`);
  212. }
  213. onPreview(dashboardId: string) {
  214. const {organization, location} = this.props;
  215. trackAdvancedAnalyticsEvent('dashboards_manage.templates.preview', {
  216. organization,
  217. dashboard_id: dashboardId,
  218. });
  219. browserHistory.push({
  220. pathname: `/organizations/${organization.slug}/dashboards/new/${dashboardId}/`,
  221. query: location.query,
  222. });
  223. }
  224. renderLoading() {
  225. return (
  226. <PageContent>
  227. <LoadingIndicator />
  228. </PageContent>
  229. );
  230. }
  231. renderBody() {
  232. const {showTemplates} = this.state;
  233. const {organization} = this.props;
  234. return (
  235. <Feature
  236. organization={organization}
  237. features={['dashboards-edit']}
  238. renderDisabled={this.renderNoAccess}
  239. >
  240. <SentryDocumentTitle title={t('Dashboards')} orgSlug={organization.slug}>
  241. <StyledPageContent>
  242. <NoProjectMessage organization={organization}>
  243. <PageContent>
  244. <StyledPageHeader>
  245. <StyledTitle>{t('Dashboards')}</StyledTitle>
  246. <ButtonBar gap={1.5}>
  247. <TemplateSwitch>
  248. {t('Show Templates')}
  249. <Switch
  250. isActive={showTemplates}
  251. size="lg"
  252. toggle={this.toggleTemplates}
  253. />
  254. </TemplateSwitch>
  255. <Button
  256. data-test-id="dashboard-create"
  257. onClick={event => {
  258. event.preventDefault();
  259. this.onCreate();
  260. }}
  261. priority="primary"
  262. icon={<IconAdd isCircled />}
  263. >
  264. {t('Create Dashboard')}
  265. </Button>
  266. </ButtonBar>
  267. </StyledPageHeader>
  268. {showTemplates && this.renderTemplates()}
  269. {this.renderActions()}
  270. {this.renderDashboards()}
  271. </PageContent>
  272. </NoProjectMessage>
  273. </StyledPageContent>
  274. </SentryDocumentTitle>
  275. </Feature>
  276. );
  277. }
  278. }
  279. const StyledTitle = styled(Title)`
  280. width: auto;
  281. `;
  282. const StyledPageContent = styled(PageContent)`
  283. padding: 0;
  284. `;
  285. const StyledPageHeader = styled('div')`
  286. display: flex;
  287. align-items: flex-end;
  288. justify-content: space-between;
  289. margin-bottom: ${space(2)};
  290. `;
  291. const StyledActions = styled('div')`
  292. display: grid;
  293. grid-template-columns: auto max-content;
  294. gap: ${space(2)};
  295. margin-bottom: ${space(2)};
  296. @media (max-width: ${p => p.theme.breakpoints.small}) {
  297. grid-template-columns: auto;
  298. }
  299. `;
  300. const TemplateSwitch = styled('div')`
  301. font-size: ${p => p.theme.fontSizeLarge};
  302. display: grid;
  303. align-items: center;
  304. grid-auto-flow: column;
  305. gap: ${space(1)};
  306. width: max-content;
  307. `;
  308. const TemplateContainer = styled('div')`
  309. display: grid;
  310. gap: ${space(2)};
  311. margin-bottom: ${space(2)};
  312. @media (min-width: ${p => p.theme.breakpoints.small}) {
  313. grid-template-columns: repeat(2, minmax(200px, 1fr));
  314. }
  315. @media (min-width: ${p => p.theme.breakpoints.large}) {
  316. grid-template-columns: repeat(4, minmax(200px, 1fr));
  317. }
  318. `;
  319. export default withApi(withOrganization(ManageDashboards));