index.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import * as React from 'react';
  2. import styled from '@emotion/styled';
  3. import {LocationDescriptor} from 'history';
  4. import cloneDeep from 'lodash/cloneDeep';
  5. import pick from 'lodash/pick';
  6. import set from 'lodash/set';
  7. import {validateWidget} from 'sentry/actionCreators/dashboards';
  8. import {addSuccessMessage} from 'sentry/actionCreators/indicator';
  9. import WidgetQueryFields from 'sentry/components/dashboards/widgetQueryFields';
  10. import SelectControl from 'sentry/components/forms/selectControl';
  11. import * as Layout from 'sentry/components/layouts/thirds';
  12. import {PanelAlert} from 'sentry/components/panels';
  13. import {t} from 'sentry/locale';
  14. import {PageContent} from 'sentry/styles/organization';
  15. import space from 'sentry/styles/space';
  16. import {Organization, PageFilters, TagCollection} from 'sentry/types';
  17. import {defined} from 'sentry/utils';
  18. import {explodeField, generateFieldAsString} from 'sentry/utils/discover/fields';
  19. import Measurements from 'sentry/utils/measurements/measurements';
  20. import withOrganization from 'sentry/utils/withOrganization';
  21. import withPageFilters from 'sentry/utils/withPageFilters';
  22. import withTags from 'sentry/utils/withTags';
  23. import AsyncView from 'sentry/views/asyncView';
  24. import WidgetCard from 'sentry/views/dashboardsV2/widgetCard';
  25. import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
  26. import {
  27. DashboardDetails,
  28. DisplayType,
  29. Widget,
  30. WidgetQuery,
  31. WidgetType,
  32. } from '../../types';
  33. import BuildStep from '../buildStep';
  34. import BuildSteps from '../buildSteps';
  35. import ChooseDataSetStep from '../choseDataStep';
  36. import Header from '../header';
  37. import {DataSet, displayTypes} from '../utils';
  38. import Queries from './queries';
  39. import {mapErrors, normalizeQueries} from './utils';
  40. const newQuery = {
  41. name: '',
  42. fields: ['count()'],
  43. conditions: '',
  44. orderby: '',
  45. };
  46. type Props = AsyncView['props'] & {
  47. dashboardTitle: DashboardDetails['title'];
  48. goBackLocation: LocationDescriptor;
  49. isEditing: boolean;
  50. onAdd: (widget: Widget) => void;
  51. onChangeDataSet: (dataSet: DataSet) => void;
  52. onDelete: () => void;
  53. onUpdate: (nextWidget: Widget) => void;
  54. organization: Organization;
  55. selection: PageFilters;
  56. tags: TagCollection;
  57. widget?: Widget;
  58. };
  59. type State = AsyncView['state'] & {
  60. displayType: DisplayType;
  61. interval: string;
  62. queries: Widget['queries'];
  63. title: string;
  64. widgetType: WidgetType;
  65. widgetErrors?: Record<string, any>;
  66. };
  67. class EventWidget extends AsyncView<Props, State> {
  68. shouldReload = true;
  69. getDefaultState() {
  70. const {widget} = this.props;
  71. if (!widget) {
  72. return {
  73. ...super.getDefaultState(),
  74. title: t('Custom %s Widget', displayTypes[DisplayType.AREA]),
  75. displayType: DisplayType.AREA,
  76. widgetType: WidgetType.DISCOVER,
  77. interval: '5m',
  78. queries: [{...newQuery}],
  79. };
  80. }
  81. return {
  82. ...super.getDefaultState(),
  83. title: widget.title,
  84. displayType: widget.displayType,
  85. widgetType: widget.widgetType,
  86. interval: widget.interval,
  87. queries: normalizeQueries(widget.displayType, widget.queries),
  88. widgetErrors: undefined,
  89. };
  90. }
  91. getFirstQueryError(field: string) {
  92. const {widgetErrors} = this.state;
  93. if (!widgetErrors) {
  94. return undefined;
  95. }
  96. const [key, value] =
  97. Object.entries(widgetErrors).find(
  98. (widgetErrorKey, _) => String(widgetErrorKey) === field
  99. ) ?? [];
  100. if (defined(key) && defined(value)) {
  101. return {[key]: value};
  102. }
  103. return undefined;
  104. }
  105. handleFieldChange = <F extends keyof State>(field: F, value: State[F]) => {
  106. this.setState(state => {
  107. const newState = cloneDeep(state);
  108. if (field === 'displayType') {
  109. set(newState, 'queries', normalizeQueries(value as DisplayType, state.queries));
  110. if (
  111. state.title === t('Custom %s Widget', state.displayType) ||
  112. state.title === t('Custom %s Widget', DisplayType.AREA)
  113. ) {
  114. return {
  115. ...newState,
  116. title: t('Custom %s Widget', displayTypes[value]),
  117. widgetErrors: undefined,
  118. };
  119. }
  120. set(newState, field, value);
  121. }
  122. return {...newState, widgetErrors: undefined};
  123. });
  124. };
  125. handleRemoveQuery = (index: number) => {
  126. this.setState(state => {
  127. const newState = cloneDeep(state);
  128. newState.queries.splice(index, 1);
  129. return {...newState, widgetErrors: undefined};
  130. });
  131. };
  132. handleAddQuery = () => {
  133. this.setState(state => {
  134. const newState = cloneDeep(state);
  135. newState.queries.push(cloneDeep(newQuery));
  136. return newState;
  137. });
  138. };
  139. handleChangeQuery = (index: number, query: WidgetQuery) => {
  140. this.setState(state => {
  141. const newState = cloneDeep(state);
  142. set(newState, `queries.${index}`, query);
  143. return {...newState, widgetErrors: undefined};
  144. });
  145. };
  146. handleSave = async (event: React.FormEvent) => {
  147. event.preventDefault();
  148. this.setState({loading: true});
  149. const {organization, onAdd, isEditing, onUpdate, widget} = this.props;
  150. try {
  151. const widgetData: Widget = pick(this.state, [
  152. 'title',
  153. 'displayType',
  154. 'widgetType',
  155. 'interval',
  156. 'queries',
  157. ]);
  158. await validateWidget(this.api, organization.slug, widgetData);
  159. if (isEditing) {
  160. (onUpdate as (nextWidget: Widget) => void)({
  161. id: (widget as Widget).id,
  162. ...widgetData,
  163. });
  164. addSuccessMessage(t('Updated widget'));
  165. return;
  166. }
  167. onAdd(widgetData);
  168. addSuccessMessage(t('Added widget'));
  169. } catch (err) {
  170. const widgetErrors = mapErrors(err?.responseJSON ?? {}, {});
  171. this.setState({widgetErrors});
  172. } finally {
  173. this.setState({loading: false});
  174. }
  175. };
  176. renderLoading() {
  177. return this.renderBody();
  178. }
  179. renderBody() {
  180. const {
  181. organization,
  182. onChangeDataSet,
  183. selection,
  184. tags,
  185. isEditing,
  186. goBackLocation,
  187. dashboardTitle,
  188. onDelete,
  189. } = this.props;
  190. const {title, displayType, queries, interval, widgetType, widgetErrors} = this.state;
  191. const orgSlug = organization.slug;
  192. const explodedFields = queries[0].fields.map(field => explodeField({field}));
  193. function fieldOptions(measurementKeys: string[]) {
  194. return generateFieldOptions({
  195. organization,
  196. tagKeys: Object.values(tags).map(({key}) => key),
  197. measurementKeys,
  198. });
  199. }
  200. return (
  201. <StyledPageContent>
  202. <Header
  203. dashboardTitle={dashboardTitle}
  204. orgSlug={orgSlug}
  205. title={title}
  206. isEditing={isEditing}
  207. onChangeTitle={newTitle => this.handleFieldChange('title', newTitle)}
  208. onSave={this.handleSave}
  209. onDelete={onDelete}
  210. goBackLocation={goBackLocation}
  211. />
  212. <Layout.Body>
  213. <BuildSteps>
  214. <ChooseDataSetStep value={DataSet.EVENTS} onChange={onChangeDataSet} />
  215. <BuildStep
  216. title={t('Choose your visualization')}
  217. description={t(
  218. 'This is a preview of how your widget will appear in the dashboard.'
  219. )}
  220. >
  221. <VisualizationWrapper>
  222. <SelectControl
  223. name="displayType"
  224. options={Object.keys(displayTypes).map(value => ({
  225. label: displayTypes[value],
  226. value,
  227. }))}
  228. value={displayType}
  229. onChange={(option: {label: string; value: DisplayType}) => {
  230. this.handleFieldChange('displayType', option.value);
  231. }}
  232. error={widgetErrors?.displayType}
  233. />
  234. <WidgetCard
  235. api={this.api}
  236. organization={organization}
  237. selection={selection}
  238. widget={{title, queries, displayType, interval, widgetType}}
  239. isEditing={false}
  240. widgetLimitReached={false}
  241. onDelete={() => undefined}
  242. onEdit={() => undefined}
  243. onDuplicate={() => undefined}
  244. renderErrorMessage={errorMessage =>
  245. typeof errorMessage === 'string' && (
  246. <PanelAlert type="error">{errorMessage}</PanelAlert>
  247. )
  248. }
  249. isSorting={false}
  250. currentWidgetDragging={false}
  251. />
  252. </VisualizationWrapper>
  253. </BuildStep>
  254. <BuildStep
  255. title={t('Begin your search')}
  256. description={t('Add another query to compare projects, tags, etc.')}
  257. >
  258. <Queries
  259. queries={queries}
  260. selectedProjectIds={selection.projects}
  261. organization={organization}
  262. displayType={displayType}
  263. onRemoveQuery={this.handleRemoveQuery}
  264. onAddQuery={this.handleAddQuery}
  265. onChangeQuery={this.handleChangeQuery}
  266. errors={widgetErrors?.queries}
  267. />
  268. </BuildStep>
  269. <Measurements>
  270. {({measurements}) => {
  271. const measurementKeys = Object.values(measurements).map(({key}) => key);
  272. const amendedFieldOptions = fieldOptions(measurementKeys);
  273. const buildStepContent = (
  274. <WidgetQueryFields
  275. widgetType={WidgetType.DISCOVER}
  276. style={{padding: 0}}
  277. errors={this.getFirstQueryError('fields')}
  278. displayType={displayType}
  279. fieldOptions={amendedFieldOptions}
  280. fields={explodedFields}
  281. organization={organization}
  282. onChange={fields => {
  283. const fieldStrings = fields.map(field =>
  284. generateFieldAsString(field)
  285. );
  286. queries.forEach((query, queryIndex) => {
  287. const clonedQuery = cloneDeep(query);
  288. clonedQuery.fields = fieldStrings;
  289. this.handleChangeQuery(queryIndex, clonedQuery);
  290. });
  291. }}
  292. />
  293. );
  294. return (
  295. <BuildStep
  296. title={
  297. displayType === DisplayType.TABLE
  298. ? t('Choose your columns')
  299. : t('Choose your y-axis')
  300. }
  301. description={t(
  302. 'We’ll use this to determine what gets graphed in the y-axis and any additional overlays.'
  303. )}
  304. >
  305. {buildStepContent}
  306. </BuildStep>
  307. );
  308. }}
  309. </Measurements>
  310. </BuildSteps>
  311. </Layout.Body>
  312. </StyledPageContent>
  313. );
  314. }
  315. }
  316. export default withOrganization(withPageFilters(withTags(EventWidget)));
  317. const StyledPageContent = styled(PageContent)`
  318. padding: 0;
  319. `;
  320. const VisualizationWrapper = styled('div')`
  321. display: grid;
  322. gap: ${space(1.5)};
  323. `;