widgetBuilder.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720
  1. import {useEffect, useState} from 'react';
  2. import {RouteComponentProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import cloneDeep from 'lodash/cloneDeep';
  5. import set from 'lodash/set';
  6. import Button from 'sentry/components/button';
  7. import {generateOrderOptions} from 'sentry/components/dashboards/widgetQueriesForm';
  8. import SearchBar from 'sentry/components/events/searchBar';
  9. import Input from 'sentry/components/forms/controls/input';
  10. import RadioGroup from 'sentry/components/forms/controls/radioGroup';
  11. import Field from 'sentry/components/forms/field';
  12. import SelectControl from 'sentry/components/forms/selectControl';
  13. import * as Layout from 'sentry/components/layouts/thirds';
  14. import LoadingError from 'sentry/components/loadingError';
  15. import PageFiltersContainer from 'sentry/components/organizations/pageFilters/container';
  16. import {PanelAlert} from 'sentry/components/panels';
  17. import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
  18. import {MAX_QUERY_LENGTH} from 'sentry/constants';
  19. import {IconAdd, IconDelete} from 'sentry/icons';
  20. import {t} from 'sentry/locale';
  21. import {PageContent} from 'sentry/styles/organization';
  22. import space from 'sentry/styles/space';
  23. import {
  24. DateString,
  25. Organization,
  26. PageFilters,
  27. SelectValue,
  28. TagCollection,
  29. } from 'sentry/types';
  30. import {defined} from 'sentry/utils';
  31. import {
  32. explodeField,
  33. generateFieldAsString,
  34. getAggregateAlias,
  35. QueryFieldValue,
  36. } from 'sentry/utils/discover/fields';
  37. import Measurements, {
  38. MeasurementCollection,
  39. } from 'sentry/utils/measurements/measurements';
  40. import {SPAN_OP_BREAKDOWN_FIELDS} from 'sentry/utils/performance/spanOperationBreakdowns/constants';
  41. import withPageFilters from 'sentry/utils/withPageFilters';
  42. import withTags from 'sentry/utils/withTags';
  43. import {
  44. generateIssueWidgetFieldOptions,
  45. generateIssueWidgetOrderOptions,
  46. } from 'sentry/views/dashboardsV2/widgetBuilder/issueWidget/utils';
  47. import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
  48. import {IssueSortOptions} from 'sentry/views/issueList/utils';
  49. import {DEFAULT_STATS_PERIOD} from '../data';
  50. import {
  51. DashboardDetails,
  52. DashboardListItem,
  53. DashboardWidgetSource,
  54. Widget,
  55. WidgetQuery,
  56. WidgetType,
  57. } from '../types';
  58. import WidgetCard from '../widgetCard';
  59. import {normalizeQueries} from './eventWidget/utils';
  60. import BuildStep from './buildStep';
  61. import BuildSteps from './buildSteps';
  62. import {ColumnFields} from './columnFields';
  63. import Header from './header';
  64. import {DataSet, DisplayType, displayTypes} from './utils';
  65. import YAxisSelector from './yAxisSelector';
  66. const DATASET_CHOICES: [DataSet, string][] = [
  67. [DataSet.EVENTS, t('All Events (Errors and Transactions)')],
  68. [DataSet.ISSUES, t('Issues (States, Assignment, Time, etc.)')],
  69. // [DataSet.METRICS, t('Metrics (Release Health)')],
  70. ];
  71. const DISPLAY_TYPES_OPTIONS = Object.keys(displayTypes).map(value => ({
  72. label: displayTypes[value],
  73. value,
  74. }));
  75. const QUERIES = {
  76. [DataSet.EVENTS]: {
  77. name: '',
  78. fields: ['count()'],
  79. conditions: '',
  80. orderby: '',
  81. },
  82. [DataSet.ISSUES]: {
  83. name: '',
  84. fields: ['issue', 'assignee', 'title'] as string[],
  85. conditions: '',
  86. orderby: '',
  87. },
  88. // [DataSet.METRICS]: {
  89. // name: '',
  90. // fields: [SessionMetric.SENTRY_SESSIONS_SESSION],
  91. // conditions: '',
  92. // orderby: '',
  93. // },
  94. };
  95. const WIDGET_TYPE_TO_DATA_SET = {
  96. [WidgetType.DISCOVER]: DataSet.EVENTS,
  97. [WidgetType.ISSUE]: DataSet.ISSUES,
  98. // [WidgetType.METRICS]: DataSet.METRICS,
  99. };
  100. type RouteParams = {
  101. orgId: string;
  102. dashboardId?: string;
  103. widgetId?: number;
  104. };
  105. type Props = RouteComponentProps<RouteParams, {}> & {
  106. dashboard: DashboardDetails;
  107. onSave: (Widgets: Widget[]) => void;
  108. organization: Organization;
  109. selection: PageFilters;
  110. tags: TagCollection;
  111. defaultTableColumns?: readonly string[];
  112. defaultTitle?: string;
  113. defaultWidgetQuery?: WidgetQuery;
  114. displayType?: DisplayType;
  115. end?: DateString;
  116. start?: DateString;
  117. statsPeriod?: string | null;
  118. widget?: Widget;
  119. };
  120. type State = {
  121. dashboards: DashboardListItem[];
  122. dataSet: DataSet;
  123. displayType: Widget['displayType'];
  124. interval: Widget['interval'];
  125. loading: boolean;
  126. queries: Widget['queries'];
  127. title: string;
  128. userHasModified: boolean;
  129. errors?: Record<'orderby' | 'conditions' | 'queries', any>;
  130. selectedDashboard?: SelectValue<string>;
  131. };
  132. function WidgetBuilder({
  133. dashboard,
  134. widget,
  135. params,
  136. location,
  137. organization,
  138. selection,
  139. start,
  140. end,
  141. statsPeriod,
  142. defaultWidgetQuery,
  143. displayType,
  144. defaultTitle,
  145. defaultTableColumns,
  146. tags,
  147. }: Props) {
  148. const {widgetId, orgId, dashboardId} = params;
  149. const {source} = location.query;
  150. const isEditing = defined(widget);
  151. const orgSlug = organization.slug;
  152. const goBackLocation = {
  153. pathname: dashboardId
  154. ? `/organizations/${orgId}/dashboard/${dashboardId}/`
  155. : `/organizations/${orgId}/dashboards/new/`,
  156. query: {...location.query, dataSet: undefined},
  157. };
  158. // Construct PageFilters object using statsPeriod/start/end props so we can
  159. // render widget graph using saved timeframe from Saved/Prebuilt Query
  160. const pageFilters: PageFilters = statsPeriod
  161. ? {...selection, datetime: {start: null, end: null, period: statsPeriod, utc: null}}
  162. : start && end
  163. ? {...selection, datetime: {start, end, period: null, utc: null}}
  164. : selection;
  165. // when opening from discover or issues page, the user selects the dashboard in the widget UI
  166. const omitDashboardProp = [
  167. DashboardWidgetSource.DISCOVERV2,
  168. DashboardWidgetSource.ISSUE_DETAILS,
  169. ].includes(source);
  170. const [state, setState] = useState<State>(() => {
  171. if (!widget) {
  172. return {
  173. title: defaultTitle ?? t('Custom Widget'),
  174. displayType: displayType ?? DisplayType.TABLE,
  175. interval: '5m',
  176. queries: [defaultWidgetQuery ? {...defaultWidgetQuery} : {...QUERIES.events}],
  177. errors: undefined,
  178. loading: !!omitDashboardProp,
  179. dashboards: [],
  180. userHasModified: false,
  181. dataSet: DataSet.EVENTS,
  182. };
  183. }
  184. return {
  185. title: widget.title,
  186. displayType: widget.displayType,
  187. interval: widget.interval,
  188. queries: normalizeQueries(widget.displayType, widget.queries),
  189. errors: undefined,
  190. loading: false,
  191. dashboards: [],
  192. userHasModified: false,
  193. dataSet: widget.widgetType
  194. ? WIDGET_TYPE_TO_DATA_SET[widget.widgetType]
  195. : DataSet.EVENTS,
  196. };
  197. });
  198. const [blurTimeout, setBlurTimeout] = useState<null | number>(null);
  199. useEffect(() => {
  200. defaultFields();
  201. }, [state.displayType]);
  202. function defaultFields() {
  203. setState(prevState => {
  204. const newState = cloneDeep(prevState);
  205. const normalized = normalizeQueries(prevState.displayType, prevState.queries);
  206. if (prevState.displayType === DisplayType.TOP_N) {
  207. // TOP N display should only allow a single query
  208. normalized.splice(1);
  209. }
  210. if (!prevState.userHasModified) {
  211. // If the Widget is an issue widget,
  212. if (
  213. prevState.displayType === DisplayType.TABLE &&
  214. widget?.widgetType &&
  215. WIDGET_TYPE_TO_DATA_SET[widget.widgetType] === DataSet.ISSUES
  216. ) {
  217. set(newState, 'queries', widget.queries);
  218. set(newState, 'dataSet', DataSet.ISSUES);
  219. return {...newState, errors: undefined};
  220. }
  221. // Default widget provided by Add to Dashboard from Discover
  222. if (defaultWidgetQuery && defaultTableColumns) {
  223. // If switching to Table visualization, use saved query fields for Y-Axis if user has not made query changes
  224. // This is so the widget can reflect the same columns as the table in Discover without requiring additional user input
  225. if (prevState.displayType === DisplayType.TABLE) {
  226. normalized.forEach(query => {
  227. query.fields = [...defaultTableColumns];
  228. });
  229. } else if (prevState.displayType === displayType) {
  230. // When switching back to original display type, default fields back to the fields provided from the discover query
  231. normalized.forEach(query => {
  232. query.fields = [...defaultWidgetQuery.fields];
  233. query.orderby = defaultWidgetQuery.orderby;
  234. });
  235. }
  236. }
  237. }
  238. if (prevState.dataSet === DataSet.ISSUES) {
  239. set(newState, 'dataSet', DataSet.EVENTS);
  240. }
  241. set(newState, 'queries', normalized);
  242. return {...newState, errors: undefined};
  243. });
  244. }
  245. function handleDataSetChange(newDataSet: string) {
  246. setState(prevState => {
  247. const newState = cloneDeep(prevState);
  248. newState.queries.splice(0, newState.queries.length);
  249. set(newState, 'dataSet', newDataSet);
  250. if (newDataSet === DataSet.ISSUES) {
  251. set(newState, 'displayType', DisplayType.TABLE);
  252. }
  253. newState.queries.push(
  254. ...(widget?.widgetType &&
  255. WIDGET_TYPE_TO_DATA_SET[widget.widgetType] === newDataSet
  256. ? widget.queries
  257. : [QUERIES[newDataSet]])
  258. );
  259. set(newState, 'userHasModified', true);
  260. return {...newState, errors: undefined};
  261. });
  262. }
  263. function handleAddSearchConditions() {
  264. setState(prevState => {
  265. const newState = cloneDeep(prevState);
  266. const query = cloneDeep(QUERIES.events);
  267. query.fields = prevState.queries[0].fields;
  268. newState.queries.push(query);
  269. return newState;
  270. });
  271. }
  272. function handleQueryRemove(index: number) {
  273. setState(prevState => {
  274. const newState = cloneDeep(prevState);
  275. newState.queries.splice(index, 1);
  276. return {...newState, errors: undefined};
  277. });
  278. }
  279. function handleQueryChange(queryIndex: number, newQuery: WidgetQuery) {
  280. setState(prevState => {
  281. const newState = cloneDeep(prevState);
  282. set(newState, `queries.${queryIndex}`, newQuery);
  283. set(newState, 'userHasModified', true);
  284. return {...newState, errors: undefined};
  285. });
  286. }
  287. function handleChangeYAxisOrColumnField(newFields: QueryFieldValue[]) {
  288. const fieldStrings = newFields.map(generateFieldAsString);
  289. const aggregateAliasFieldStrings = fieldStrings.map(getAggregateAlias);
  290. for (const index in state.queries) {
  291. const queryIndex = Number(index);
  292. const query = state.queries[queryIndex];
  293. const descending = query.orderby.startsWith('-');
  294. const orderbyAggregateAliasField = query.orderby.replace('-', '');
  295. const prevAggregateAliasFieldStrings = query.fields.map(field =>
  296. getAggregateAlias(field)
  297. );
  298. const newQuery = cloneDeep(query);
  299. newQuery.fields = fieldStrings;
  300. if (
  301. !aggregateAliasFieldStrings.includes(orderbyAggregateAliasField) &&
  302. query.orderby !== ''
  303. ) {
  304. if (prevAggregateAliasFieldStrings.length === newFields.length) {
  305. // The Field that was used in orderby has changed. Get the new field.
  306. newQuery.orderby = `${descending && '-'}${
  307. aggregateAliasFieldStrings[
  308. prevAggregateAliasFieldStrings.indexOf(orderbyAggregateAliasField)
  309. ]
  310. }`;
  311. } else {
  312. newQuery.orderby = '';
  313. }
  314. }
  315. handleQueryChange(queryIndex, newQuery);
  316. }
  317. }
  318. function getAmendedFieldOptions(measurements: MeasurementCollection) {
  319. return generateFieldOptions({
  320. organization,
  321. tagKeys: Object.values(tags).map(({key}) => key),
  322. measurementKeys: Object.values(measurements).map(({key}) => key),
  323. spanOperationBreakdownKeys: SPAN_OP_BREAKDOWN_FIELDS,
  324. });
  325. }
  326. if (
  327. isEditing &&
  328. (!defined(widgetId) ||
  329. !dashboard.widgets.find(dashboardWidget => dashboardWidget.id === String(widgetId)))
  330. ) {
  331. return (
  332. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  333. <PageContent>
  334. <LoadingError message={t('Widget not found.')} />
  335. </PageContent>
  336. </SentryDocumentTitle>
  337. );
  338. }
  339. const widgetType =
  340. state.dataSet === DataSet.EVENTS
  341. ? WidgetType.DISCOVER
  342. : state.dataSet === DataSet.ISSUES
  343. ? WidgetType.ISSUE
  344. : WidgetType.METRICS;
  345. const canAddSearchConditions =
  346. [
  347. DisplayType.LINE,
  348. DisplayType.AREA,
  349. DisplayType.STACKED_AREA,
  350. DisplayType.BAR,
  351. ].includes(state.displayType) && state.queries.length < 3;
  352. const hideLegendAlias = [
  353. DisplayType.TABLE,
  354. DisplayType.WORLD_MAP,
  355. DisplayType.BIG_NUMBER,
  356. ].includes(state.displayType);
  357. const explodedFields = state.queries[0].fields.map(field => explodeField({field}));
  358. return (
  359. <SentryDocumentTitle title={dashboard.title} orgSlug={orgSlug}>
  360. <PageFiltersContainer
  361. skipLoadLastUsed={organization.features.includes('global-views')}
  362. defaultSelection={{
  363. datetime: {start: null, end: null, utc: false, period: DEFAULT_STATS_PERIOD},
  364. }}
  365. >
  366. <PageContentWithoutPadding>
  367. <Header
  368. orgSlug={orgSlug}
  369. title={state.title}
  370. dashboardTitle={dashboard.title}
  371. goBackLocation={goBackLocation}
  372. onChangeTitle={newTitle => setState({...state, title: newTitle})}
  373. />
  374. <Layout.Body>
  375. <BuildSteps>
  376. <BuildStep
  377. title={t('Choose your visualization')}
  378. description={t(
  379. 'This is a preview of how your widget will appear in the dashboard.'
  380. )}
  381. >
  382. <VisualizationWrapper>
  383. <DisplayTypeOptions
  384. name="displayType"
  385. options={DISPLAY_TYPES_OPTIONS}
  386. value={state.displayType}
  387. onChange={(option: {label: string; value: DisplayType}) => {
  388. setState({...state, displayType: option.value});
  389. }}
  390. />
  391. <WidgetCard
  392. organization={organization}
  393. selection={pageFilters}
  394. widget={{
  395. title: state.title,
  396. displayType: state.displayType,
  397. interval: state.interval,
  398. queries: state.queries,
  399. widgetType,
  400. }}
  401. isEditing={false}
  402. widgetLimitReached={false}
  403. renderErrorMessage={errorMessage =>
  404. typeof errorMessage === 'string' && (
  405. <PanelAlert type="error">{errorMessage}</PanelAlert>
  406. )
  407. }
  408. isSorting={false}
  409. currentWidgetDragging={false}
  410. noLazyLoad
  411. />
  412. </VisualizationWrapper>
  413. </BuildStep>
  414. <BuildStep
  415. title={t('Choose your data set')}
  416. description={t(
  417. 'Monitor specific events such as errors and transactions or metrics based on Release Health.'
  418. )}
  419. >
  420. <DataSetChoices
  421. label="dataSet"
  422. value={state.dataSet}
  423. choices={
  424. state.displayType === DisplayType.TABLE
  425. ? DATASET_CHOICES
  426. : [DATASET_CHOICES[0]]
  427. }
  428. onChange={handleDataSetChange}
  429. />
  430. </BuildStep>
  431. {[DisplayType.TABLE, DisplayType.TOP_N].includes(state.displayType) && (
  432. <BuildStep
  433. title={t('Columns')}
  434. description="Description of what this means"
  435. >
  436. {state.dataSet === DataSet.EVENTS ? (
  437. <Measurements>
  438. {({measurements}) => (
  439. <ColumnFields
  440. displayType={state.displayType}
  441. organization={organization}
  442. widgetType={widgetType}
  443. columns={explodedFields}
  444. errors={state.errors?.queries}
  445. fieldOptions={getAmendedFieldOptions(measurements)}
  446. onChange={handleChangeYAxisOrColumnField}
  447. />
  448. )}
  449. </Measurements>
  450. ) : (
  451. <ColumnFields
  452. displayType={state.displayType}
  453. organization={organization}
  454. widgetType={widgetType}
  455. columns={state.queries[0].fields.map(field =>
  456. explodeField({field})
  457. )}
  458. errors={
  459. state.errors?.queries?.[0]
  460. ? [state.errors?.queries?.[0]]
  461. : undefined
  462. }
  463. fieldOptions={generateIssueWidgetFieldOptions()}
  464. onChange={newFields => {
  465. const fieldStrings = newFields.map(generateFieldAsString);
  466. const newQuery = cloneDeep(state.queries[0]);
  467. newQuery.fields = fieldStrings;
  468. handleQueryChange(0, newQuery);
  469. }}
  470. />
  471. )}
  472. </BuildStep>
  473. )}
  474. {![DisplayType.TABLE].includes(state.displayType) && (
  475. <BuildStep
  476. title={t('Choose your y-axis')}
  477. description="Description of what this means"
  478. >
  479. <Measurements>
  480. {({measurements}) => (
  481. <YAxisSelector
  482. widgetType={widgetType}
  483. displayType={state.displayType}
  484. fields={explodedFields}
  485. fieldOptions={getAmendedFieldOptions(measurements)}
  486. onChange={handleChangeYAxisOrColumnField}
  487. errors={state.errors?.queries}
  488. />
  489. )}
  490. </Measurements>
  491. </BuildStep>
  492. )}
  493. <BuildStep title={t('Query')} description="Description of what this means">
  494. <div>
  495. {state.queries.map((query, queryIndex) => {
  496. return (
  497. <QueryField
  498. key={queryIndex}
  499. inline={false}
  500. flexibleControlStateSize
  501. stacked
  502. error={state.errors?.[queryIndex].conditions}
  503. >
  504. <SearchConditionsWrapper>
  505. <Search
  506. searchSource="widget_builder"
  507. organization={organization}
  508. projectIds={selection.projects}
  509. query={query.conditions}
  510. fields={[]}
  511. onSearch={field => {
  512. // SearchBar will call handlers for both onSearch and onBlur
  513. // when selecting a value from the autocomplete dropdown. This can
  514. // cause state issues for the search bar in our use case. To prevent
  515. // this, we set a timer in our onSearch handler to block our onBlur
  516. // handler from firing if it is within 200ms, ie from clicking an
  517. // autocomplete value.
  518. setBlurTimeout(
  519. window.setTimeout(() => {
  520. setBlurTimeout(null);
  521. }, 200)
  522. );
  523. const newQuery: WidgetQuery = {
  524. ...state.queries[queryIndex],
  525. conditions: field,
  526. };
  527. handleQueryChange(queryIndex, newQuery);
  528. }}
  529. onBlur={field => {
  530. if (!blurTimeout) {
  531. const newQuery: WidgetQuery = {
  532. ...state.queries[queryIndex],
  533. conditions: field,
  534. };
  535. handleQueryChange(queryIndex, newQuery);
  536. }
  537. }}
  538. useFormWrapper={false}
  539. maxQueryLength={MAX_QUERY_LENGTH}
  540. />
  541. {!hideLegendAlias && (
  542. <LegendAliasInput
  543. type="text"
  544. name="name"
  545. required
  546. value={query.name}
  547. placeholder={t('Legend Alias')}
  548. onChange={event => {
  549. const newQuery: WidgetQuery = {
  550. ...state.queries[queryIndex],
  551. name: event.target.value,
  552. };
  553. handleQueryChange(queryIndex, newQuery);
  554. }}
  555. />
  556. )}
  557. {state.queries.length > 1 && (
  558. <Button
  559. size="zero"
  560. borderless
  561. onClick={() => handleQueryRemove(queryIndex)}
  562. icon={<IconDelete />}
  563. title={t('Remove query')}
  564. aria-label={t('Remove query')}
  565. />
  566. )}
  567. </SearchConditionsWrapper>
  568. </QueryField>
  569. );
  570. })}
  571. {canAddSearchConditions && (
  572. <Button
  573. size="small"
  574. icon={<IconAdd isCircled />}
  575. onClick={handleAddSearchConditions}
  576. >
  577. {t('Add query')}
  578. </Button>
  579. )}
  580. </div>
  581. </BuildStep>
  582. {[DisplayType.TABLE, DisplayType.TOP_N].includes(state.displayType) && (
  583. <BuildStep
  584. title={t('Sort by')}
  585. description="Description of what this means"
  586. >
  587. <Field
  588. inline={false}
  589. flexibleControlStateSize
  590. stacked
  591. error={state.errors?.orderby}
  592. >
  593. {state.dataSet === DataSet.EVENTS ? (
  594. <SelectControl
  595. menuPlacement="auto"
  596. value={state.queries[0].orderby}
  597. name="orderby"
  598. options={generateOrderOptions(state.queries[0].fields)}
  599. onChange={(option: SelectValue<string>) => {
  600. const newQuery: WidgetQuery = {
  601. ...state.queries[0],
  602. orderby: option.value,
  603. };
  604. handleQueryChange(0, newQuery);
  605. }}
  606. />
  607. ) : (
  608. <SelectControl
  609. menuPlacement="auto"
  610. value={state.queries[0].orderby || IssueSortOptions.DATE}
  611. name="orderby"
  612. options={generateIssueWidgetOrderOptions(
  613. organization?.features?.includes('issue-list-trend-sort')
  614. )}
  615. onChange={(option: SelectValue<string>) => {
  616. const newQuery: WidgetQuery = {
  617. ...state.queries[0],
  618. orderby: option.value,
  619. };
  620. handleQueryChange(0, newQuery);
  621. }}
  622. />
  623. )}
  624. </Field>
  625. </BuildStep>
  626. )}
  627. </BuildSteps>
  628. </Layout.Body>
  629. </PageContentWithoutPadding>
  630. </PageFiltersContainer>
  631. </SentryDocumentTitle>
  632. );
  633. }
  634. export default withPageFilters(withTags(WidgetBuilder));
  635. const PageContentWithoutPadding = styled(PageContent)`
  636. padding: 0;
  637. `;
  638. const VisualizationWrapper = styled('div')`
  639. display: flex;
  640. flex-direction: column;
  641. margin-right: ${space(2)};
  642. `;
  643. const DataSetChoices = styled(RadioGroup)`
  644. @media (min-width: ${p => p.theme.breakpoints[2]}) {
  645. grid-auto-flow: column;
  646. }
  647. `;
  648. const DisplayTypeOptions = styled(SelectControl)`
  649. margin-bottom: ${space(1)};
  650. `;
  651. const SearchConditionsWrapper = styled('div')`
  652. display: flex;
  653. align-items: center;
  654. > * + * {
  655. margin-left: ${space(1)};
  656. }
  657. `;
  658. const Search = styled(SearchBar)`
  659. flex-grow: 1;
  660. `;
  661. const LegendAliasInput = styled(Input)`
  662. width: 33%;
  663. `;
  664. const QueryField = styled(Field)`
  665. padding-bottom: ${space(1)};
  666. `;