groupBySelector.tsx 9.4 KB

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