groupBySelector.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import {Fragment, useMemo, useState} from 'react';
  2. import {closestCenter, DndContext, DragOverlay} from '@dnd-kit/core';
  3. import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable';
  4. import styled from '@emotion/styled';
  5. import {OnDemandWarningIcon} from 'sentry/components/alerts/onDemandMetricAlert';
  6. import {Button} from 'sentry/components/button';
  7. import FieldGroup from 'sentry/components/forms/fieldGroup';
  8. import {IconAdd} from 'sentry/icons';
  9. import {t} from 'sentry/locale';
  10. import {space} from 'sentry/styles/space';
  11. import {defined} from 'sentry/utils';
  12. import type {QueryFieldValue} from 'sentry/utils/discover/fields';
  13. import {generateFieldAsString} from 'sentry/utils/discover/fields';
  14. import {hasOnDemandMetricWidgetFeature} from 'sentry/utils/onDemandMetrics/features';
  15. import type {UseApiQueryResult} from 'sentry/utils/queryClient';
  16. import type RequestError from 'sentry/utils/requestError/requestError';
  17. import useOrganization from 'sentry/utils/useOrganization';
  18. import {
  19. OnDemandExtractionState,
  20. type ValidateWidgetResponse,
  21. } from 'sentry/views/dashboards/types';
  22. import {FieldValueKind} from 'sentry/views/discover/table/types';
  23. import type {generateFieldOptions} from 'sentry/views/discover/utils';
  24. import {QueryField} from './queryField';
  25. import {SortableQueryField} from './sortableQueryField';
  26. const GROUP_BY_LIMIT = 20;
  27. const EMPTY_FIELD: QueryFieldValue = {kind: FieldValueKind.FIELD, field: ''};
  28. type FieldOptions = ReturnType<typeof generateFieldOptions>;
  29. interface Props {
  30. fieldOptions: FieldOptions;
  31. onChange: (fields: QueryFieldValue[]) => void;
  32. validatedWidgetResponse: UseApiQueryResult<ValidateWidgetResponse, RequestError>;
  33. columns?: QueryFieldValue[];
  34. style?: React.CSSProperties;
  35. }
  36. export function GroupBySelector({
  37. fieldOptions,
  38. columns = [],
  39. onChange,
  40. validatedWidgetResponse,
  41. style,
  42. }: Props) {
  43. const [activeId, setActiveId] = useState<string | null>(null);
  44. function handleAdd() {
  45. const newColumns =
  46. columns.length === 0
  47. ? [{...EMPTY_FIELD}, {...EMPTY_FIELD}]
  48. : [...columns, {...EMPTY_FIELD}];
  49. onChange(newColumns);
  50. }
  51. function handleSelect(value: QueryFieldValue, index?: number) {
  52. const newColumns = [...columns];
  53. if (columns.length === 0) {
  54. newColumns.push(value);
  55. } else if (defined(index)) {
  56. newColumns[index] = value;
  57. }
  58. onChange(newColumns);
  59. }
  60. function handleRemove(index: number) {
  61. const newColumns = [...columns];
  62. newColumns.splice(index, 1);
  63. onChange(newColumns);
  64. }
  65. const hasOnlySingleColumnWithValue =
  66. columns.length === 1 &&
  67. columns[0]!.kind === FieldValueKind.FIELD &&
  68. columns[0]?.field !== '';
  69. const canDrag = columns.length > 1;
  70. const canDelete = canDrag || hasOnlySingleColumnWithValue;
  71. const columnFieldsAsString = columns.map(generateFieldAsString);
  72. const {filteredFieldOptions, columnsAsFieldOptions} = useMemo(() => {
  73. return Object.keys(fieldOptions).reduce<{
  74. columnsAsFieldOptions: FieldOptions[];
  75. filteredFieldOptions: FieldOptions;
  76. }>(
  77. (acc, key) => {
  78. const value = fieldOptions[key]!;
  79. const optionInColumnsIndex = columnFieldsAsString.findIndex(
  80. column => column === value!.value.meta.name
  81. );
  82. if (optionInColumnsIndex === -1) {
  83. acc.filteredFieldOptions[key] = value;
  84. return acc;
  85. }
  86. acc.columnsAsFieldOptions[optionInColumnsIndex] = {[key]: value};
  87. return acc;
  88. },
  89. {
  90. filteredFieldOptions: {},
  91. columnsAsFieldOptions: [],
  92. }
  93. );
  94. }, [fieldOptions, columnFieldsAsString]);
  95. const items = useMemo(() => {
  96. return columns.reduce<string[]>((acc, _column, index) => {
  97. acc.push(String(index));
  98. return acc;
  99. }, []);
  100. }, [columns]);
  101. return (
  102. <Fragment>
  103. <StyledField inline={false} style={style} flexibleControlStateSize stacked>
  104. {columns.length === 0 ? (
  105. <QueryField
  106. value={EMPTY_FIELD}
  107. fieldOptions={filteredFieldOptions}
  108. onChange={value => handleSelect(value, 0)}
  109. canDelete={canDelete}
  110. />
  111. ) : (
  112. <DndContext
  113. collisionDetection={closestCenter}
  114. onDragStart={({active}) => {
  115. setActiveId(active.id.toString());
  116. }}
  117. onDragEnd={({over, active}) => {
  118. setActiveId(null);
  119. if (over) {
  120. const getIndex = items.indexOf.bind(items);
  121. const activeIndex = getIndex(active.id);
  122. const overIndex = getIndex(over.id);
  123. if (activeIndex !== overIndex) {
  124. onChange(arrayMove(columns, activeIndex, overIndex));
  125. }
  126. }
  127. }}
  128. onDragCancel={() => {
  129. setActiveId(null);
  130. }}
  131. >
  132. <SortableContext items={items} strategy={verticalListSortingStrategy}>
  133. <SortableQueryFields>
  134. {columns.map((column, index) => (
  135. <SortableQueryField
  136. key={items[index]}
  137. dragId={items[index]!}
  138. value={column}
  139. fieldOptions={{
  140. ...filteredFieldOptions,
  141. ...columnsAsFieldOptions[index],
  142. }}
  143. fieldValidationError={
  144. <FieldValidationErrors
  145. column={column}
  146. validatedWidgetResponse={validatedWidgetResponse}
  147. />
  148. }
  149. onChange={value => handleSelect(value, index)}
  150. onDelete={() => handleRemove(index)}
  151. canDrag={canDrag}
  152. canDelete={canDelete}
  153. />
  154. ))}
  155. </SortableQueryFields>
  156. </SortableContext>
  157. <DragOverlay dropAnimation={null}>
  158. {activeId ? (
  159. <Ghost>
  160. <QueryField
  161. value={columns[Number(activeId)]!}
  162. fieldOptions={{
  163. ...filteredFieldOptions,
  164. ...columnsAsFieldOptions[Number(activeId)],
  165. }}
  166. onChange={value => handleSelect(value, Number(activeId))}
  167. canDrag={canDrag}
  168. canDelete={canDelete}
  169. />
  170. </Ghost>
  171. ) : null}
  172. </DragOverlay>
  173. </DndContext>
  174. )}
  175. </StyledField>
  176. {columns.length < GROUP_BY_LIMIT && (
  177. <AddGroupButton size="sm" icon={<IconAdd isCircled />} onClick={handleAdd}>
  178. {t('Add Group')}
  179. </AddGroupButton>
  180. )}
  181. </Fragment>
  182. );
  183. }
  184. function FieldValidationErrors(props: {
  185. column: QueryFieldValue;
  186. validatedWidgetResponse: Props['validatedWidgetResponse'];
  187. }) {
  188. const organization = useOrganization();
  189. if (!hasOnDemandMetricWidgetFeature(organization)) {
  190. return null;
  191. }
  192. return props.column.kind === 'field' &&
  193. props.validatedWidgetResponse.data?.warnings?.columns[props.column.field ?? ''] ===
  194. OnDemandExtractionState.DISABLED_HIGH_CARDINALITY ? (
  195. <OnDemandWarningIcon
  196. color="yellow300"
  197. msg={t('This group has too many unique values to collect metrics for it.')}
  198. />
  199. ) : null;
  200. }
  201. const StyledField = styled(FieldGroup)`
  202. padding-bottom: ${space(1)};
  203. `;
  204. const AddGroupButton = styled(Button)`
  205. width: min-content;
  206. `;
  207. const SortableQueryFields = styled('div')`
  208. display: grid;
  209. grid-auto-flow: row;
  210. gap: ${space(1)};
  211. `;
  212. const Ghost = styled('div')`
  213. position: absolute;
  214. background: ${p => p.theme.background};
  215. padding: ${space(0.5)};
  216. border-radius: ${p => p.theme.borderRadius};
  217. box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
  218. opacity: 0.8;
  219. cursor: grabbing;
  220. padding-right: ${space(2)};
  221. width: 100%;
  222. button {
  223. cursor: grabbing;
  224. }
  225. @media (min-width: ${p => p.theme.breakpoints.small}) {
  226. width: 710px;
  227. }
  228. `;