metricsExtractionRuleForm.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779
  1. import {Fragment, useCallback, useId, useMemo, useState} from 'react';
  2. import {components} from 'react-select';
  3. import {css, useTheme} from '@emotion/react';
  4. import styled from '@emotion/styled';
  5. import {Observer} from 'mobx-react';
  6. import Alert from 'sentry/components/alert';
  7. import {Button} from 'sentry/components/button';
  8. import SearchBar from 'sentry/components/events/searchBar';
  9. import SelectField from 'sentry/components/forms/fields/selectField';
  10. import Form, {type FormProps} from 'sentry/components/forms/form';
  11. import FormField from 'sentry/components/forms/formField';
  12. import type FormModel from 'sentry/components/forms/model';
  13. import ExternalLink from 'sentry/components/links/externalLink';
  14. import {Tooltip} from 'sentry/components/tooltip';
  15. import {IconAdd, IconClose, IconQuestion, IconWarning} from 'sentry/icons';
  16. import {t, tct} from 'sentry/locale';
  17. import {space} from 'sentry/styles/space';
  18. import type {SelectValue} from 'sentry/types/core';
  19. import type {MetricAggregation, MetricsExtractionCondition} from 'sentry/types/metrics';
  20. import {DiscoverDatasets} from 'sentry/utils/discover/types';
  21. import useOrganization from 'sentry/utils/useOrganization';
  22. import {SpanIndexedField, SpanMetricsField} from 'sentry/views/insights/types';
  23. import {useSpanFieldSupportedTags} from 'sentry/views/performance/utils/useSpanFieldSupportedTags';
  24. import {openExtractionRuleEditModal} from 'sentry/views/settings/projectMetrics/metricsExtractionRuleEditModal';
  25. import {useMetricsExtractionRules} from 'sentry/views/settings/projectMetrics/utils/useMetricsExtractionRules';
  26. export type AggregateGroup = 'count' | 'count_unique' | 'min_max' | 'percentiles';
  27. export interface FormData {
  28. aggregates: AggregateGroup[];
  29. conditions: MetricsExtractionCondition[];
  30. spanAttribute: string | null;
  31. tags: string[];
  32. unit: string;
  33. }
  34. interface Props extends Omit<FormProps, 'onSubmit'> {
  35. initialData: FormData;
  36. projectId: string | number;
  37. cardinality?: Record<string, number>;
  38. isEdit?: boolean;
  39. onSubmit?: (
  40. data: FormData,
  41. onSubmitSuccess: (data: FormData) => void,
  42. onSubmitError: (error: any) => void,
  43. event: React.FormEvent,
  44. model: FormModel
  45. ) => void;
  46. }
  47. const HIGH_CARDINALITY_TAGS = new Set([
  48. SpanIndexedField.HTTP_RESPONSE_CONTENT_LENGTH,
  49. SpanIndexedField.SPAN_DURATION,
  50. SpanIndexedField.SPAN_SELF_TIME,
  51. SpanIndexedField.SPAN_GROUP,
  52. SpanIndexedField.ID,
  53. SpanIndexedField.SPAN_AI_PIPELINE_GROUP,
  54. SpanIndexedField.TRANSACTION_ID,
  55. SpanIndexedField.PROJECT_ID,
  56. SpanIndexedField.PROFILE_ID,
  57. SpanIndexedField.REPLAY_ID,
  58. SpanIndexedField.TIMESTAMP,
  59. SpanIndexedField.USER,
  60. SpanIndexedField.USER_ID,
  61. SpanIndexedField.USER_EMAIL,
  62. SpanIndexedField.USER_USERNAME,
  63. SpanIndexedField.INP,
  64. SpanIndexedField.INP_SCORE,
  65. SpanIndexedField.INP_SCORE_WEIGHT,
  66. SpanIndexedField.TOTAL_SCORE,
  67. SpanIndexedField.CACHE_ITEM_SIZE,
  68. SpanIndexedField.MESSAGING_MESSAGE_ID,
  69. SpanIndexedField.MESSAGING_MESSAGE_BODY_SIZE,
  70. SpanIndexedField.MESSAGING_MESSAGE_RECEIVE_LATENCY,
  71. SpanIndexedField.MESSAGING_MESSAGE_RETRY_COUNT,
  72. SpanMetricsField.AI_TOTAL_TOKENS_USED,
  73. SpanMetricsField.AI_PROMPT_TOKENS_USED,
  74. SpanMetricsField.AI_COMPLETION_TOKENS_USED,
  75. SpanMetricsField.AI_INPUT_MESSAGES,
  76. SpanMetricsField.HTTP_DECODED_RESPONSE_CONTENT_LENGTH,
  77. SpanMetricsField.HTTP_RESPONSE_TRANSFER_SIZE,
  78. SpanMetricsField.CACHE_ITEM_SIZE,
  79. SpanMetricsField.CACHE_KEY,
  80. SpanMetricsField.THREAD_ID,
  81. SpanMetricsField.SENTRY_FRAMES_FROZEN,
  82. SpanMetricsField.SENTRY_FRAMES_SLOW,
  83. SpanMetricsField.SENTRY_FRAMES_TOTAL,
  84. SpanMetricsField.FRAMES_DELAY,
  85. SpanMetricsField.URL_FULL,
  86. SpanMetricsField.USER_AGENT_ORIGINAL,
  87. SpanMetricsField.FRAMES_DELAY,
  88. ]);
  89. const isHighCardinalityTag = (tag: string): boolean => {
  90. return HIGH_CARDINALITY_TAGS.has(tag as SpanIndexedField);
  91. };
  92. const AGGREGATE_OPTIONS: {label: string; value: AggregateGroup}[] = [
  93. {
  94. label: t('count'),
  95. value: 'count',
  96. },
  97. {
  98. label: t('count_unique'),
  99. value: 'count_unique',
  100. },
  101. {
  102. label: t('min, max, sum, avg'),
  103. value: 'min_max',
  104. },
  105. {
  106. label: t('percentiles'),
  107. value: 'percentiles',
  108. },
  109. ];
  110. export function explodeAggregateGroup(group: AggregateGroup): MetricAggregation[] {
  111. switch (group) {
  112. case 'count':
  113. return ['count'];
  114. case 'count_unique':
  115. return ['count_unique'];
  116. case 'min_max':
  117. return ['min', 'max', 'sum', 'avg'];
  118. case 'percentiles':
  119. return ['p50', 'p75', 'p90', 'p95', 'p99'];
  120. default:
  121. throw new Error(`Unknown aggregate group: ${group}`);
  122. }
  123. }
  124. export function aggregatesToGroups(aggregates: MetricAggregation[]): AggregateGroup[] {
  125. const groups: AggregateGroup[] = [];
  126. if (aggregates.includes('count')) {
  127. groups.push('count');
  128. }
  129. if (aggregates.includes('count_unique')) {
  130. groups.push('count_unique');
  131. }
  132. const minMaxAggregates = new Set<MetricAggregation>(['min', 'max', 'sum', 'avg']);
  133. if (aggregates.find(aggregate => minMaxAggregates.has(aggregate))) {
  134. groups.push('min_max');
  135. }
  136. const percentileAggregates = new Set<MetricAggregation>([
  137. 'p50',
  138. 'p75',
  139. 'p90',
  140. 'p95',
  141. 'p99',
  142. ]);
  143. if (aggregates.find(aggregate => percentileAggregates.has(aggregate))) {
  144. groups.push('percentiles');
  145. }
  146. return groups;
  147. }
  148. let currentTempId = 0;
  149. function createTempId(): number {
  150. currentTempId -= 1;
  151. return currentTempId;
  152. }
  153. export function createCondition(): MetricsExtractionCondition {
  154. return {
  155. value: '',
  156. // id and mris will be set by the backend after creation
  157. id: createTempId(),
  158. mris: [],
  159. };
  160. }
  161. const SUPPORTED_UNITS = [
  162. 'none',
  163. 'nanosecond',
  164. 'microsecond',
  165. 'millisecond',
  166. 'second',
  167. 'minute',
  168. 'hour',
  169. 'day',
  170. 'week',
  171. 'ratio',
  172. 'percent',
  173. 'bit',
  174. 'byte',
  175. 'kibibyte',
  176. 'kilobyte',
  177. 'mebibyte',
  178. 'megabyte',
  179. 'gibibyte',
  180. 'gigabyte',
  181. 'tebibyte',
  182. 'terabyte',
  183. 'pebibyte',
  184. 'petabyte',
  185. 'exbibyte',
  186. 'exabyte',
  187. ] as const;
  188. const isSupportedUnit = (unit: string): unit is (typeof SUPPORTED_UNITS)[number] => {
  189. return SUPPORTED_UNITS.includes(unit as (typeof SUPPORTED_UNITS)[number]);
  190. };
  191. const EMPTY_SET = new Set<never>();
  192. const SPAN_SEARCH_CONFIG = {
  193. booleanKeys: EMPTY_SET,
  194. dateKeys: EMPTY_SET,
  195. durationKeys: EMPTY_SET,
  196. numericKeys: EMPTY_SET,
  197. percentageKeys: EMPTY_SET,
  198. sizeKeys: EMPTY_SET,
  199. textOperatorKeys: EMPTY_SET,
  200. disallowFreeText: true,
  201. disallowWildcard: true,
  202. disallowNegation: true,
  203. };
  204. const FIXED_UNITS_BY_ATTRIBUTE: Record<string, (typeof SUPPORTED_UNITS)[number]> = {
  205. [SpanIndexedField.SPAN_DURATION]: 'millisecond',
  206. };
  207. export function MetricsExtractionRuleForm({
  208. isEdit,
  209. projectId,
  210. onSubmit,
  211. cardinality,
  212. ...props
  213. }: Props) {
  214. const organization = useOrganization();
  215. const [customAttributes, setCustomAttributes] = useState<string[]>(() => {
  216. const {spanAttribute, tags} = props.initialData;
  217. return [...new Set(spanAttribute ? [...tags, spanAttribute] : tags)];
  218. });
  219. const [customUnit, setCustomUnit] = useState<string | null>(() => {
  220. const {unit} = props.initialData;
  221. return unit && !isSupportedUnit(unit) ? unit : null;
  222. });
  223. const [isUnitDisabled, setIsUnitDisabled] = useState(() => {
  224. const {spanAttribute} = props.initialData;
  225. return !!(spanAttribute && spanAttribute in FIXED_UNITS_BY_ATTRIBUTE);
  226. });
  227. const {data: extractionRules} = useMetricsExtractionRules({
  228. orgId: organization.slug,
  229. projectId: projectId,
  230. });
  231. const tags = useSpanFieldSupportedTags({projects: [Number(projectId)]});
  232. // TODO(aknaus): Make this nicer
  233. const supportedTags = useMemo(() => {
  234. const copy = {...tags};
  235. delete copy.has;
  236. return copy;
  237. }, [tags]);
  238. const allAttributeOptions = useMemo(() => {
  239. let keys = Object.keys(supportedTags);
  240. if (customAttributes.length) {
  241. keys = [...new Set(keys.concat(customAttributes))];
  242. }
  243. return keys.sort((a, b) => a.localeCompare(b));
  244. }, [customAttributes, supportedTags]);
  245. const attributeOptions = useMemo(() => {
  246. const disabledKeys = new Set(extractionRules?.map(rule => rule.spanAttribute) || []);
  247. return (
  248. allAttributeOptions
  249. .map<SelectValue<string>>(key => {
  250. const disabledRule = disabledKeys.has(key)
  251. ? extractionRules?.find(rule => rule.spanAttribute === key)
  252. : undefined;
  253. return {
  254. label: key,
  255. value: key,
  256. disabled: disabledKeys.has(key),
  257. tooltip: disabledKeys.has(key)
  258. ? tct(
  259. 'This attribute is already in use. Please select another one or [link:edit the existing metric].',
  260. {
  261. link: disabledRule ? (
  262. <Button
  263. priority="link"
  264. aria-label={t('Edit %s metric', disabledRule.spanAttribute)}
  265. onClick={() => {
  266. openExtractionRuleEditModal({
  267. metricExtractionRule: disabledRule,
  268. });
  269. }}
  270. />
  271. ) : null,
  272. }
  273. )
  274. : undefined,
  275. tooltipOptions: {position: 'left', isHoverable: true},
  276. };
  277. })
  278. // Sort disabled attributes to bottom
  279. .sort((a, b) => Number(a.disabled) - Number(b.disabled))
  280. );
  281. }, [allAttributeOptions, extractionRules]);
  282. const tagOptions = useMemo(() => {
  283. return allAttributeOptions.map<SelectValue<string>>(option => ({
  284. label: option,
  285. value: option,
  286. disabled: isHighCardinalityTag(option),
  287. tooltip: isHighCardinalityTag(option)
  288. ? t('This tag has high cardinality.')
  289. : undefined,
  290. tooltipOptions: {position: 'left'},
  291. }));
  292. }, [allAttributeOptions]);
  293. const unitOptions = useMemo(() => {
  294. const options: SelectValue<string>[] = SUPPORTED_UNITS.map(unit => ({
  295. label: unit + (unit === 'none' ? '' : 's'),
  296. value: unit,
  297. }));
  298. if (customUnit) {
  299. options.push({
  300. label: customUnit,
  301. value: customUnit,
  302. });
  303. }
  304. return options;
  305. }, [customUnit]);
  306. const handleSubmit = useCallback(
  307. (
  308. data: Record<string, any>,
  309. onSubmitSuccess: (data: Record<string, any>) => void,
  310. onSubmitError: (error: any) => void,
  311. event: React.FormEvent,
  312. model: FormModel
  313. ) => {
  314. const errors: Record<string, [string]> = {};
  315. if (!data.spanAttribute) {
  316. errors.spanAttribute = [t('Span attribute is required.')];
  317. }
  318. if (!data.aggregates.length) {
  319. errors.aggregates = [t('At least one aggregate is required.')];
  320. }
  321. if (Object.keys(errors).length) {
  322. onSubmitError({responseJSON: errors});
  323. return;
  324. }
  325. onSubmit?.(data as FormData, onSubmitSuccess, onSubmitError, event, model);
  326. },
  327. [onSubmit]
  328. );
  329. const isNewCustomSpanAttribute = useCallback((value: string) => {
  330. return !attributeOptions.some(option => option.value === value);
  331. // attributeOptions is being mutated when a new custom attribute is created
  332. }, []); // eslint-disable-line react-hooks/exhaustive-deps
  333. const isCardinalityLimited = useMemo(() => {
  334. if (!cardinality) {
  335. return false;
  336. }
  337. const {conditions} = props.initialData;
  338. return conditions.some(condition =>
  339. condition.mris.some(conditionMri => cardinality[conditionMri] > 0)
  340. );
  341. }, [cardinality, props.initialData]);
  342. return (
  343. <Form onSubmit={onSubmit && handleSubmit} {...props}>
  344. {({model}) => (
  345. <Fragment>
  346. <SpanAttributeUnitWrapper>
  347. <SelectField
  348. inline={false}
  349. stacked
  350. name="spanAttribute"
  351. options={attributeOptions}
  352. disabled={isEdit}
  353. label={
  354. <TooltipIconLabel
  355. isHoverable
  356. label={t('Measure')}
  357. help={tct(
  358. 'Define the span attribute you want to track. Learn how to instrument custom attributes in [link:our docs].',
  359. {
  360. // TODO(telemetry-experience): add the correct link here once we have it!!!
  361. link: (
  362. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  363. ),
  364. }
  365. )}
  366. />
  367. }
  368. onCreateOption={value => {
  369. setCustomAttributes(curr => [...curr, value]);
  370. model.setValue('spanAttribute', value);
  371. }}
  372. components={{
  373. MenuList: (
  374. menuListProps: React.ComponentProps<typeof components.MenuList>
  375. ) => {
  376. return (
  377. <MenuList
  378. {...menuListProps}
  379. info={tct(
  380. 'Select an attribute or create one. [link:See how to instrument a custom attribute.]',
  381. {
  382. link: (
  383. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/metrics-set-up/" />
  384. ),
  385. }
  386. )}
  387. />
  388. );
  389. },
  390. }}
  391. placeholder={t('Select span attribute')}
  392. creatable
  393. onChange={value => {
  394. model.setValue('spanAttribute', value);
  395. if (value in FIXED_UNITS_BY_ATTRIBUTE) {
  396. model.setValue('unit', FIXED_UNITS_BY_ATTRIBUTE[value]);
  397. setIsUnitDisabled(true);
  398. } else {
  399. setIsUnitDisabled(false);
  400. }
  401. }}
  402. required
  403. />
  404. <StyledFieldConnector>in</StyledFieldConnector>
  405. <SelectField
  406. inline={false}
  407. stacked
  408. allowEmpty
  409. name="unit"
  410. options={unitOptions}
  411. disabled={isUnitDisabled}
  412. placeholder={t('Select unit')}
  413. creatable
  414. onCreateOption={value => {
  415. setCustomUnit(value);
  416. model.setValue('unit', value);
  417. }}
  418. css={css`
  419. min-width: 150px;
  420. `}
  421. />
  422. </SpanAttributeUnitWrapper>
  423. <SelectField
  424. inline={false}
  425. stacked
  426. name="aggregates"
  427. required
  428. options={AGGREGATE_OPTIONS}
  429. placeholder={t('Select aggregations')}
  430. label={
  431. <TooltipIconLabel
  432. isHoverable
  433. label={t('Aggregate')}
  434. help={tct(
  435. 'Select the aggregations you’d like to view. For more information, read [link:our docs]',
  436. {
  437. // TODO(telemetry-experience): add the correct link here once we have it!!!
  438. link: (
  439. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/" />
  440. ),
  441. }
  442. )}
  443. />
  444. }
  445. multiple
  446. />
  447. <SelectField
  448. inline={false}
  449. stacked
  450. name="tags"
  451. aria-label={t('Select tags')}
  452. options={tagOptions}
  453. multiple
  454. placeholder={t('Select tags')}
  455. label={
  456. <Fragment>
  457. {isCardinalityLimited && (
  458. <Tooltip
  459. title={t(
  460. 'This filter is exeeding the cardinality limit. Remove tags or add more conditions to receive accurate data.'
  461. )}
  462. >
  463. <StyledIconWarning size="xs" color="yellow300" />
  464. </Tooltip>
  465. )}
  466. <TooltipIconLabel
  467. label={t('Group by')}
  468. help={t(
  469. 'Select the tags that can be used to group and filter the metric. Tag values have to be non-numeric.'
  470. )}
  471. />
  472. </Fragment>
  473. }
  474. creatable
  475. onCreateOption={value => {
  476. setCustomAttributes(curr => [...curr, value]);
  477. const currentTags = model.getValue('tags') as string[];
  478. model.setValue('tags', [...currentTags, value]);
  479. }}
  480. components={{
  481. MenuList: (
  482. menuListProps: React.ComponentProps<typeof components.MenuList>
  483. ) => {
  484. return (
  485. <MenuList
  486. {...menuListProps}
  487. info={tct(
  488. 'Select a tag or create one. [link:See how to instrument a custom tag.]',
  489. {
  490. link: (
  491. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/metrics-set-up/" />
  492. ),
  493. }
  494. )}
  495. />
  496. );
  497. },
  498. }}
  499. />
  500. <FormField
  501. stacked
  502. label={
  503. <TooltipIconLabel
  504. label={t('Filters')}
  505. help={t(
  506. 'Define filters to narrow down the metric to a specific set of spans.'
  507. )}
  508. />
  509. }
  510. name="conditions"
  511. inline={false}
  512. hasControlState={false}
  513. flexibleControlStateSize
  514. >
  515. {({onChange, initialData, value}) => {
  516. const conditions = (value ||
  517. initialData ||
  518. []) as MetricsExtractionCondition[];
  519. const handleChange = (queryString: string, index: number) => {
  520. onChange(
  521. conditions.toSpliced(index, 1, {
  522. ...conditions[index],
  523. value: queryString,
  524. }),
  525. {}
  526. );
  527. };
  528. return (
  529. <Fragment>
  530. <ConditionsWrapper hasDelete={value.length > 1}>
  531. {conditions.map((condition, index) => {
  532. const hasSiblings = conditions.length > 1;
  533. return (
  534. <Fragment key={condition.id}>
  535. <SearchWrapper hasPrefix={hasSiblings}>
  536. {hasSiblings ? (
  537. <ConditionSymbol>{index + 1}</ConditionSymbol>
  538. ) : null}
  539. <SearchBarWithId
  540. {...SPAN_SEARCH_CONFIG}
  541. searchSource="metrics-extraction"
  542. query={condition.value}
  543. onSearch={(queryString: string) =>
  544. handleChange(queryString, index)
  545. }
  546. onClose={(queryString: string, {validSearch}) => {
  547. if (validSearch) {
  548. handleChange(queryString, index);
  549. }
  550. }}
  551. placeholder={t('Add span attributes')}
  552. organization={organization}
  553. supportedTags={supportedTags}
  554. excludedTags={[]}
  555. dataset={DiscoverDatasets.SPANS_INDEXED}
  556. projectIds={[Number(projectId)]}
  557. hasRecentSearches={false}
  558. savedSearchType={undefined}
  559. useFormWrapper={false}
  560. />
  561. </SearchWrapper>
  562. {value.length > 1 && (
  563. <Button
  564. aria-label={t('Remove Filter')}
  565. onClick={() => onChange(conditions.toSpliced(index, 1), {})}
  566. icon={<IconClose />}
  567. />
  568. )}
  569. </Fragment>
  570. );
  571. })}
  572. </ConditionsWrapper>
  573. <ConditionsButtonBar>
  574. <Button
  575. size="sm"
  576. onClick={() => onChange([...conditions, createCondition()], {})}
  577. icon={<IconAdd />}
  578. >
  579. {t('Add Filter')}
  580. </Button>
  581. </ConditionsButtonBar>
  582. </Fragment>
  583. );
  584. }}
  585. </FormField>
  586. <Observer>
  587. {() =>
  588. model.formChanged ? (
  589. <Alert
  590. type="info"
  591. showIcon
  592. expand={
  593. <Fragment>
  594. <b>{t('Why that?')}</b>
  595. <p>
  596. {isNewCustomSpanAttribute(model.getValue('spanAttribute'))
  597. ? tct(
  598. 'We’ll only collect data from spans sent after you created the metric and not before. If you haven’t already, please [link:instrument your custom attribute.]',
  599. {
  600. link: (
  601. <ExternalLink href="https://docs.sentry.io/product/explore/metrics/metrics-set-up/" />
  602. ),
  603. }
  604. )
  605. : t(
  606. 'Well, it’s because we’ll only collect data once you’ve created a metric and not before. Likewise, if you deleted an existing metric, then we’ll stop collecting data for that metric.'
  607. )}
  608. </p>
  609. </Fragment>
  610. }
  611. >
  612. {t('Hey, we’ll need a moment to collect data that matches the above.')}
  613. </Alert>
  614. ) : null
  615. }
  616. </Observer>
  617. </Fragment>
  618. )}
  619. </Form>
  620. );
  621. }
  622. function MenuList({
  623. children,
  624. info,
  625. ...props
  626. }: React.ComponentProps<typeof components.MenuList> & {info: React.ReactNode}) {
  627. const theme = useTheme();
  628. return (
  629. <components.MenuList {...props}>
  630. <div
  631. css={css`
  632. /* The padding must align with the values specified for the option in the forms/controls/selectOption component */
  633. padding: ${space(1)};
  634. padding-left: calc(${space(0.5)} + ${space(1.5)});
  635. color: ${theme.gray300};
  636. `}
  637. >
  638. {info}
  639. </div>
  640. {children}
  641. </components.MenuList>
  642. );
  643. }
  644. function TooltipIconLabel({
  645. label,
  646. help,
  647. isHoverable,
  648. }: {
  649. help: React.ReactNode;
  650. label: React.ReactNode;
  651. isHoverable?: boolean;
  652. }) {
  653. return (
  654. <TooltipIconLabelWrapper>
  655. {label}
  656. <Tooltip title={help} isHoverable={isHoverable}>
  657. <IconQuestion size="sm" color="gray200" />
  658. </Tooltip>
  659. </TooltipIconLabelWrapper>
  660. );
  661. }
  662. const TooltipIconLabelWrapper = styled('span')`
  663. display: inline-flex;
  664. font-weight: bold;
  665. color: ${p => p.theme.gray300};
  666. gap: ${space(0.5)};
  667. & > span {
  668. margin-top: 1px;
  669. }
  670. & > span:hover {
  671. cursor: pointer;
  672. }
  673. `;
  674. const StyledFieldConnector = styled('div')`
  675. color: ${p => p.theme.gray300};
  676. padding-bottom: ${space(1)};
  677. `;
  678. const SpanAttributeUnitWrapper = styled('div')`
  679. display: flex;
  680. align-items: flex-end;
  681. gap: ${space(1)};
  682. padding-bottom: ${space(2)};
  683. & > div:first-child {
  684. flex: 1;
  685. padding-bottom: 0;
  686. }
  687. `;
  688. function SearchBarWithId(props: React.ComponentProps<typeof SearchBar>) {
  689. const id = useId();
  690. return <SearchBar id={id} {...props} />;
  691. }
  692. const ConditionsWrapper = styled('div')<{hasDelete: boolean}>`
  693. display: grid;
  694. align-items: center;
  695. gap: ${space(1)};
  696. ${p =>
  697. p.hasDelete
  698. ? `
  699. grid-template-columns: 1fr min-content;
  700. `
  701. : `
  702. grid-template-columns: 1fr;
  703. `}
  704. `;
  705. const SearchWrapper = styled('div')<{hasPrefix?: boolean}>`
  706. display: grid;
  707. gap: ${space(1)};
  708. align-items: center;
  709. grid-template-columns: ${p => (p.hasPrefix ? 'max-content' : '')} 1fr;
  710. `;
  711. const ConditionSymbol = styled('div')`
  712. background-color: ${p => p.theme.purple100};
  713. color: ${p => p.theme.purple400};
  714. text-align: center;
  715. align-content: center;
  716. height: ${space(3)};
  717. width: ${space(3)};
  718. border-radius: 50%;
  719. `;
  720. const StyledIconWarning = styled(IconWarning)`
  721. margin: 0 ${space(0.5)};
  722. `;
  723. const ConditionsButtonBar = styled('div')`
  724. margin-top: ${space(1)};
  725. `;