groupBySelector.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  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 {Button} from 'sentry/components/button';
  6. import FieldGroup from 'sentry/components/forms/fieldGroup';
  7. import {IconAdd} from 'sentry/icons';
  8. import {t} from 'sentry/locale';
  9. import {space} from 'sentry/styles/space';
  10. import {defined} from 'sentry/utils';
  11. import type {QueryFieldValue} from 'sentry/utils/discover/fields';
  12. import {generateFieldAsString} from 'sentry/utils/discover/fields';
  13. import {FieldValueKind} from 'sentry/views/discover/table/types';
  14. import type {generateFieldOptions} from 'sentry/views/discover/utils';
  15. import {QueryField} from './queryField';
  16. import {SortableQueryField} from './sortableQueryField';
  17. const GROUP_BY_LIMIT = 20;
  18. const EMPTY_FIELD: QueryFieldValue = {kind: FieldValueKind.FIELD, field: ''};
  19. type FieldOptions = ReturnType<typeof generateFieldOptions>;
  20. interface Props {
  21. fieldOptions: FieldOptions;
  22. onChange: (fields: QueryFieldValue[]) => void;
  23. columns?: QueryFieldValue[];
  24. }
  25. export function GroupBySelector({fieldOptions, columns = [], onChange}: Props) {
  26. const [activeId, setActiveId] = useState<string | null>(null);
  27. function handleAdd() {
  28. const newColumns =
  29. columns.length === 0
  30. ? [{...EMPTY_FIELD}, {...EMPTY_FIELD}]
  31. : [...columns, {...EMPTY_FIELD}];
  32. onChange(newColumns);
  33. }
  34. function handleSelect(value: QueryFieldValue, index?: number) {
  35. const newColumns = [...columns];
  36. if (columns.length === 0) {
  37. newColumns.push(value);
  38. } else if (defined(index)) {
  39. newColumns[index] = value;
  40. }
  41. onChange(newColumns);
  42. }
  43. function handleRemove(index: number) {
  44. const newColumns = [...columns];
  45. newColumns.splice(index, 1);
  46. onChange(newColumns);
  47. }
  48. const hasOnlySingleColumnWithValue =
  49. columns.length === 1 &&
  50. columns[0].kind === FieldValueKind.FIELD &&
  51. columns[0]?.field !== '';
  52. const canDrag = columns.length > 1;
  53. const canDelete = canDrag || hasOnlySingleColumnWithValue;
  54. const columnFieldsAsString = columns.map(generateFieldAsString);
  55. const {filteredFieldOptions, columnsAsFieldOptions} = useMemo(() => {
  56. return Object.keys(fieldOptions).reduce<{
  57. columnsAsFieldOptions: FieldOptions[];
  58. filteredFieldOptions: FieldOptions;
  59. }>(
  60. (acc, key) => {
  61. const value = fieldOptions[key];
  62. const optionInColumnsIndex = columnFieldsAsString.findIndex(
  63. column => column === value.value.meta.name
  64. );
  65. if (optionInColumnsIndex === -1) {
  66. acc.filteredFieldOptions[key] = value;
  67. return acc;
  68. }
  69. acc.columnsAsFieldOptions[optionInColumnsIndex] = {[key]: value};
  70. return acc;
  71. },
  72. {
  73. filteredFieldOptions: {},
  74. columnsAsFieldOptions: [],
  75. }
  76. );
  77. }, [fieldOptions, columnFieldsAsString]);
  78. const items = useMemo(() => {
  79. return columns.reduce<string[]>((acc, _column, index) => {
  80. acc.push(String(index));
  81. return acc;
  82. }, []);
  83. }, [columns]);
  84. return (
  85. <Fragment>
  86. <StyledField inline={false} flexibleControlStateSize stacked>
  87. {columns.length === 0 ? (
  88. <QueryField
  89. value={EMPTY_FIELD}
  90. fieldOptions={filteredFieldOptions}
  91. onChange={value => handleSelect(value, 0)}
  92. canDelete={canDelete}
  93. />
  94. ) : (
  95. <DndContext
  96. collisionDetection={closestCenter}
  97. onDragStart={({active}) => {
  98. setActiveId(active.id);
  99. }}
  100. onDragEnd={({over, active}) => {
  101. setActiveId(null);
  102. if (over) {
  103. const getIndex = items.indexOf.bind(items);
  104. const activeIndex = getIndex(active.id);
  105. const overIndex = getIndex(over.id);
  106. if (activeIndex !== overIndex) {
  107. onChange(arrayMove(columns, activeIndex, overIndex));
  108. }
  109. }
  110. }}
  111. onDragCancel={() => {
  112. setActiveId(null);
  113. }}
  114. >
  115. <SortableContext items={items} strategy={verticalListSortingStrategy}>
  116. <SortableQueryFields>
  117. {columns.map((column, index) => (
  118. <SortableQueryField
  119. key={items[index]}
  120. dragId={items[index]}
  121. value={column}
  122. fieldOptions={{
  123. ...filteredFieldOptions,
  124. ...columnsAsFieldOptions[index],
  125. }}
  126. onChange={value => handleSelect(value, index)}
  127. onDelete={() => handleRemove(index)}
  128. canDrag={canDrag}
  129. canDelete={canDelete}
  130. />
  131. ))}
  132. </SortableQueryFields>
  133. </SortableContext>
  134. <DragOverlay dropAnimation={null}>
  135. {activeId ? (
  136. <Ghost>
  137. <QueryField
  138. value={columns[Number(activeId)]}
  139. fieldOptions={{
  140. ...filteredFieldOptions,
  141. ...columnsAsFieldOptions[Number(activeId)],
  142. }}
  143. onChange={value => handleSelect(value, Number(activeId))}
  144. canDrag={canDrag}
  145. canDelete={canDelete}
  146. />
  147. </Ghost>
  148. ) : null}
  149. </DragOverlay>
  150. </DndContext>
  151. )}
  152. </StyledField>
  153. {columns.length < GROUP_BY_LIMIT && (
  154. <AddGroupButton size="sm" icon={<IconAdd isCircled />} onClick={handleAdd}>
  155. {t('Add Group')}
  156. </AddGroupButton>
  157. )}
  158. </Fragment>
  159. );
  160. }
  161. const StyledField = styled(FieldGroup)`
  162. padding-bottom: ${space(1)};
  163. `;
  164. const AddGroupButton = styled(Button)`
  165. width: min-content;
  166. `;
  167. const SortableQueryFields = styled('div')`
  168. display: grid;
  169. grid-auto-flow: row;
  170. gap: ${space(1)};
  171. `;
  172. const Ghost = styled('div')`
  173. position: absolute;
  174. background: ${p => p.theme.background};
  175. padding: ${space(0.5)};
  176. border-radius: ${p => p.theme.borderRadius};
  177. box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
  178. opacity: 0.8;
  179. cursor: grabbing;
  180. padding-right: ${space(2)};
  181. width: 100%;
  182. button {
  183. cursor: grabbing;
  184. }
  185. @media (min-width: ${p => p.theme.breakpoints.small}) {
  186. width: 710px;
  187. }
  188. `;