groupBySelector.tsx 6.4 KB

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