groupBySelector.tsx 7.8 KB

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