index.tsx 10 KB

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