|
@@ -1,30 +1,33 @@
|
|
|
-import {Fragment} from 'react';
|
|
|
+import React, {Fragment, useMemo, useState} from 'react';
|
|
|
+import {closestCenter, DndContext, DragOverlay} from '@dnd-kit/core';
|
|
|
+import {arrayMove, SortableContext, verticalListSortingStrategy} from '@dnd-kit/sortable';
|
|
|
import styled from '@emotion/styled';
|
|
|
|
|
|
import Button from 'sentry/components/button';
|
|
|
import Field from 'sentry/components/forms/field';
|
|
|
-import {IconAdd, IconDelete} from 'sentry/icons';
|
|
|
+import {IconAdd} from 'sentry/icons';
|
|
|
import {t} from 'sentry/locale';
|
|
|
import space from 'sentry/styles/space';
|
|
|
import {defined} from 'sentry/utils';
|
|
|
-import {QueryFieldValue} from 'sentry/utils/discover/fields';
|
|
|
-import {FieldValueOption, QueryField} from 'sentry/views/eventsV2/table/queryField';
|
|
|
+import {generateFieldAsString, QueryFieldValue} from 'sentry/utils/discover/fields';
|
|
|
import {FieldValueKind} from 'sentry/views/eventsV2/table/types';
|
|
|
import {generateFieldOptions} from 'sentry/views/eventsV2/utils';
|
|
|
|
|
|
+import {QueryField} from './queryField';
|
|
|
+import {SortableQueryField} from './sortableQueryField';
|
|
|
+
|
|
|
const GROUP_BY_LIMIT = 20;
|
|
|
const EMPTY_FIELD: QueryFieldValue = {kind: FieldValueKind.FIELD, field: ''};
|
|
|
|
|
|
+type FieldOptions = ReturnType<typeof generateFieldOptions>;
|
|
|
interface Props {
|
|
|
- fieldOptions: ReturnType<typeof generateFieldOptions>;
|
|
|
+ fieldOptions: FieldOptions;
|
|
|
onChange: (fields: QueryFieldValue[]) => void;
|
|
|
columns?: QueryFieldValue[];
|
|
|
}
|
|
|
|
|
|
export function GroupBySelector({fieldOptions, columns = [], onChange}: Props) {
|
|
|
- function filterPrimaryOptions(option: FieldValueOption) {
|
|
|
- return option.value.kind !== FieldValueKind.FUNCTION;
|
|
|
- }
|
|
|
+ const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
|
|
|
function handleAdd() {
|
|
|
const newColumns =
|
|
@@ -50,61 +53,117 @@ export function GroupBySelector({fieldOptions, columns = [], onChange}: Props) {
|
|
|
onChange(newColumns);
|
|
|
}
|
|
|
|
|
|
- if (columns.length === 0) {
|
|
|
- return (
|
|
|
- <Fragment>
|
|
|
- <StyledField inline={false} flexibleControlStateSize stacked>
|
|
|
- <QueryFieldWrapper>
|
|
|
- <QueryField
|
|
|
- placeholder={t('Select group')}
|
|
|
- fieldValue={EMPTY_FIELD}
|
|
|
- fieldOptions={fieldOptions}
|
|
|
- onChange={value => handleSelect(value, 0)}
|
|
|
- filterPrimaryOptions={filterPrimaryOptions}
|
|
|
- />
|
|
|
- </QueryFieldWrapper>
|
|
|
- </StyledField>
|
|
|
-
|
|
|
- <AddGroupButton size="small" icon={<IconAdd isCircled />} onClick={handleAdd}>
|
|
|
- {t('Add Group')}
|
|
|
- </AddGroupButton>
|
|
|
- </Fragment>
|
|
|
- );
|
|
|
- }
|
|
|
-
|
|
|
const hasOnlySingleColumnWithValue =
|
|
|
columns.length === 1 &&
|
|
|
columns[0].kind === FieldValueKind.FIELD &&
|
|
|
columns[0]?.field !== '';
|
|
|
|
|
|
- const canDelete = columns.length > 1 || hasOnlySingleColumnWithValue;
|
|
|
+ const canDrag = columns.length > 1;
|
|
|
+ const canDelete = canDrag || hasOnlySingleColumnWithValue;
|
|
|
+ const columnFieldsAsString = columns.map(generateFieldAsString);
|
|
|
+
|
|
|
+ const {filteredFieldOptions, columnsAsFieldOptions} = useMemo(() => {
|
|
|
+ return Object.keys(fieldOptions).reduce(
|
|
|
+ (acc, key) => {
|
|
|
+ const value = fieldOptions[key];
|
|
|
+ const optionInColumnsIndex = columnFieldsAsString.findIndex(
|
|
|
+ column => column === value.value.meta.name
|
|
|
+ );
|
|
|
+ if (optionInColumnsIndex === -1) {
|
|
|
+ acc.filteredFieldOptions[key] = value;
|
|
|
+ return acc;
|
|
|
+ }
|
|
|
+ acc.columnsAsFieldOptions[optionInColumnsIndex] = {[key]: value};
|
|
|
+ return acc;
|
|
|
+ },
|
|
|
+ {
|
|
|
+ filteredFieldOptions: {},
|
|
|
+ columnsAsFieldOptions: [],
|
|
|
+ } as {
|
|
|
+ columnsAsFieldOptions: FieldOptions[];
|
|
|
+ filteredFieldOptions: FieldOptions;
|
|
|
+ }
|
|
|
+ );
|
|
|
+ }, [fieldOptions, columns]);
|
|
|
+
|
|
|
+ const items = useMemo(() => {
|
|
|
+ return columns.reduce((acc, _column, index) => {
|
|
|
+ acc.push(String(index));
|
|
|
+ return acc;
|
|
|
+ }, [] as string[]);
|
|
|
+ }, [columns]);
|
|
|
|
|
|
return (
|
|
|
<Fragment>
|
|
|
<StyledField inline={false} flexibleControlStateSize stacked>
|
|
|
- {columns.map((column, index) => (
|
|
|
- <QueryFieldWrapper key={`groupby-${index}`}>
|
|
|
- <QueryField
|
|
|
- placeholder={t('Select group')}
|
|
|
- fieldValue={column}
|
|
|
- fieldOptions={fieldOptions}
|
|
|
- onChange={value => handleSelect(value, index)}
|
|
|
- filterPrimaryOptions={filterPrimaryOptions}
|
|
|
- />
|
|
|
- {canDelete && (
|
|
|
- <Button
|
|
|
- size="zero"
|
|
|
- borderless
|
|
|
- onClick={() => handleRemove(index)}
|
|
|
- icon={<IconDelete />}
|
|
|
- title={t('Remove group')}
|
|
|
- aria-label={t('Remove group')}
|
|
|
- />
|
|
|
- )}
|
|
|
- </QueryFieldWrapper>
|
|
|
- ))}
|
|
|
+ {columns.length === 0 ? (
|
|
|
+ <QueryField
|
|
|
+ value={EMPTY_FIELD}
|
|
|
+ fieldOptions={filteredFieldOptions}
|
|
|
+ onChange={value => handleSelect(value, 0)}
|
|
|
+ canDelete={canDelete}
|
|
|
+ />
|
|
|
+ ) : (
|
|
|
+ <DndContext
|
|
|
+ collisionDetection={closestCenter}
|
|
|
+ onDragStart={({active}) => {
|
|
|
+ setActiveId(active.id);
|
|
|
+ }}
|
|
|
+ onDragEnd={({over, active}) => {
|
|
|
+ setActiveId(null);
|
|
|
+
|
|
|
+ if (over) {
|
|
|
+ const getIndex = items.indexOf.bind(items);
|
|
|
+ const activeIndex = getIndex(active.id);
|
|
|
+ const overIndex = getIndex(over.id);
|
|
|
+
|
|
|
+ if (activeIndex !== overIndex) {
|
|
|
+ onChange(arrayMove(columns, activeIndex, overIndex));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ onDragCancel={() => {
|
|
|
+ setActiveId(null);
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <SortableContext items={items} strategy={verticalListSortingStrategy}>
|
|
|
+ <SortableQueryFields>
|
|
|
+ {columns.map((column, index) => (
|
|
|
+ <SortableQueryField
|
|
|
+ key={items[index]}
|
|
|
+ dragId={items[index]}
|
|
|
+ value={column}
|
|
|
+ fieldOptions={{
|
|
|
+ ...filteredFieldOptions,
|
|
|
+ ...columnsAsFieldOptions[index],
|
|
|
+ }}
|
|
|
+ onChange={value => handleSelect(value, index)}
|
|
|
+ onDelete={() => handleRemove(index)}
|
|
|
+ canDrag={canDrag}
|
|
|
+ canDelete={canDelete}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </SortableQueryFields>
|
|
|
+ </SortableContext>
|
|
|
+ <DragOverlay dropAnimation={null}>
|
|
|
+ {activeId ? (
|
|
|
+ <Ghost>
|
|
|
+ <QueryField
|
|
|
+ value={columns[Number(activeId)]}
|
|
|
+ fieldOptions={{
|
|
|
+ ...filteredFieldOptions,
|
|
|
+ ...columnsAsFieldOptions[Number(activeId)],
|
|
|
+ }}
|
|
|
+ onChange={value => handleSelect(value, Number(activeId))}
|
|
|
+ canDrag={canDrag}
|
|
|
+ canDelete={canDelete}
|
|
|
+ />
|
|
|
+ </Ghost>
|
|
|
+ ) : null}
|
|
|
+ </DragOverlay>
|
|
|
+ </DndContext>
|
|
|
+ )}
|
|
|
</StyledField>
|
|
|
-
|
|
|
{columns.length < GROUP_BY_LIMIT && (
|
|
|
<AddGroupButton size="small" icon={<IconAdd isCircled />} onClick={handleAdd}>
|
|
|
{t('Add Group')}
|
|
@@ -118,20 +177,32 @@ const StyledField = styled(Field)`
|
|
|
padding-bottom: ${space(1)};
|
|
|
`;
|
|
|
|
|
|
-const QueryFieldWrapper = styled('div')`
|
|
|
- display: flex;
|
|
|
- align-items: center;
|
|
|
- justify-content: space-between;
|
|
|
+const AddGroupButton = styled(Button)`
|
|
|
+ width: min-content;
|
|
|
+`;
|
|
|
|
|
|
- :not(:last-child) {
|
|
|
- margin-bottom: ${space(1)};
|
|
|
- }
|
|
|
+const SortableQueryFields = styled('div')`
|
|
|
+ display: grid;
|
|
|
+ grid-auto-flow: row;
|
|
|
+ grid-gap: ${space(1)};
|
|
|
+`;
|
|
|
|
|
|
- > * + * {
|
|
|
- margin-left: ${space(1)};
|
|
|
+const Ghost = styled('div')`
|
|
|
+ position: absolute;
|
|
|
+ background: ${p => p.theme.background};
|
|
|
+ padding: ${space(0.5)};
|
|
|
+ border-radius: ${p => p.theme.borderRadius};
|
|
|
+ box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
|
|
|
+ opacity: 0.8;
|
|
|
+ cursor: grabbing;
|
|
|
+ padding-right: ${space(2)};
|
|
|
+ width: 100%;
|
|
|
+
|
|
|
+ button {
|
|
|
+ cursor: grabbing;
|
|
|
}
|
|
|
-`;
|
|
|
|
|
|
-const AddGroupButton = styled(Button)`
|
|
|
- width: min-content;
|
|
|
+ @media (min-width: ${p => p.theme.breakpoints[0]}) {
|
|
|
+ width: 710px;
|
|
|
+ }
|
|
|
`;
|