feat ui add dnd to event filters in search and dashboards (#2024)

* feat ui add dnd to event filters in search and dashboards

* rm console
This commit is contained in:
Delirium 2024-04-02 16:53:27 +02:00 committed by GitHub
parent e4a1037e3d
commit 44574016bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 323 additions and 88 deletions

View file

@ -43,6 +43,11 @@ function FilterSeries(props: Props) {
observeChanges();
};
const onFilterMove = (newFilters: any) => {
series.filter.replaceFilters(newFilters.toArray())
observeChanges();
}
const onChangeEventsOrder = (_: any, { name, value }: any) => {
series.filter.updateKey(name, value);
observeChanges();
@ -85,6 +90,7 @@ function FilterSeries(props: Props) {
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
supportsEmpty={supportsEmpty}
onFilterMove={onFilterMove}
excludeFilterKeys={excludeFilterKeys}
/>
) : (

View file

@ -1,62 +1,136 @@
import { useStore } from 'App/mstore';
import { Table } from 'antd';
import type { TableProps } from 'antd';
import { useObserver } from 'mobx-react-lite';
import React, { useEffect } from 'react';
import FunnelIssuesListItem from '../FunnelIssuesListItem';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { NoContent } from 'UI';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { useModal } from 'App/components/Modal';
import { useStore } from 'App/mstore';
import { NoContent } from 'UI';
import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG';
import FunnelIssueModal from '../FunnelIssueModal';
import FunnelIssuesListItem from '../FunnelIssuesListItem';
interface Issue {
issueId: string;
icon: {
icon: string;
color: string;
};
title: string;
contextString: string;
affectedUsers: number;
conversionImpact: string;
lostConversions: string;
unaffectedSessionsPer: string;
unaffectedSessions: string;
affectedSessionsPer: string;
affectedSessions: string;
lostConversionsPer: string;
}
// Issue | #Users Affected | Conversion Impact | Lost Conversions
const columns: TableProps<Issue>['columns'] = [
{
title: 'Issue',
dataIndex: 'title',
key: 'title',
},
{
title: '# Users Affected',
dataIndex: 'affectedUsers',
key: 'affectedUsers',
},
{
title: 'Conversion Impact',
dataIndex: 'conversionImpact',
key: 'conversionImpact',
render: (text: string) => <span>{text}%</span>,
},
{
title: 'Lost Conversions',
dataIndex: 'lostConversions',
key: 'lostConversions',
render: (text: string) => <span>{text}</span>,
},
];
interface Props {
loading?: boolean;
issues: any;
history: any;
location: any;
loading?: boolean;
issues: Issue[];
history: any;
location: any;
}
function FunnelIssuesList(props: RouteComponentProps<Props>) {
const { issues, loading } = props;
const { funnelStore } = useStore();
const issuesSort = useObserver(() => funnelStore.issuesSort);
const issuesFilter = useObserver(() => funnelStore.issuesFilter.map((issue: any) => issue.value));
const { showModal } = useModal();
const issueId = new URLSearchParams(props.location.search).get("issueId");
const { issues, loading } = props;
const { funnelStore } = useStore();
const issuesSort = useObserver(() => funnelStore.issuesSort);
const issuesFilter = useObserver(() =>
funnelStore.issuesFilter.map((issue: any) => issue.value)
);
const { showModal } = useModal();
const issueId = new URLSearchParams(props.location.search).get('issueId');
const onIssueClick = (issue: any) => {
props.history.replace({search: (new URLSearchParams({issueId : issue.issueId})).toString()});
}
const onIssueClick = (issue: any) => {
props.history.replace({
search: new URLSearchParams({ issueId: issue.issueId }).toString(),
});
};
useEffect(() => {
if (!issueId) return;
useEffect(() => {
if (!issueId) return;
showModal(<FunnelIssueModal issueId={issueId} />, { right: true, width: 1000, onClose: () => {
if (props.history.location.pathname.includes("/metric")) {
props.history.replace({search: ""});
}
}});
}, [issueId]);
let filteredIssues = useObserver(() => issuesFilter.length > 0 ? issues.filter((issue: any) => issuesFilter.includes(issue.type)) : issues);
filteredIssues = useObserver(() => issuesSort.sort ? filteredIssues.slice().sort((a: { [x: string]: number; }, b: { [x: string]: number; }) => a[issuesSort.sort] - b[issuesSort.sort]): filteredIssues);
filteredIssues = useObserver(() => issuesSort.order === 'desc' ? filteredIssues.reverse() : filteredIssues);
showModal(<FunnelIssueModal issueId={issueId} />, {
right: true,
width: 1000,
onClose: () => {
if (props.history.location.pathname.includes('/metric')) {
props.history.replace({ search: '' });
}
},
});
}, [issueId]);
return useObserver(() => (
<NoContent
show={!loading && filteredIssues.length === 0}
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_ISSUES} size="170" />
<div className="mt-4">No issues found</div>
</div>
}
>
{filteredIssues.map((issue: any, index: React.Key) => (
<div key={index} className="mb-4">
<FunnelIssuesListItem issue={issue} onClick={() => onIssueClick(issue)} />
</div>
))}
</NoContent>
))
let filteredIssues = useObserver(() =>
issuesFilter.length > 0
? issues.filter((issue: any) => issuesFilter.includes(issue.type))
: issues
);
filteredIssues = useObserver(() =>
issuesSort.sort
? filteredIssues
.slice()
.sort(
(a: { [x: string]: number }, b: { [x: string]: number }) =>
a[issuesSort.sort] - b[issuesSort.sort]
)
: filteredIssues
);
filteredIssues = useObserver(() =>
issuesSort.order === 'desc' ? filteredIssues.reverse() : filteredIssues
);
return useObserver(() => (
<NoContent
show={!loading && filteredIssues.length === 0}
title={
<div className="flex flex-col items-center justify-center">
<AnimatedSVG name={ICONS.NO_ISSUES} size="170" />
<div className="mt-4">No issues found</div>
</div>
}
>
<Table
columns={columns}
dataSource={filteredIssues}
onRow={(rec, ind) => ({
onClick: () => onIssueClick(rec),
})}
rowClassName={'cursor-pointer'}
/>
</NoContent>
));
}
export default withRouter(FunnelIssuesList) as React.FunctionComponent<RouteComponentProps<Props>>;
export default withRouter(FunnelIssuesList);

View file

@ -239,7 +239,7 @@ function WidgetForm(props: Props) {
)}
{!isPredefined && (
<div className='form-group'>
<div>
<div className='flex items-center font-medium py-2'>
{`${isTable || isFunnel || isClickmap || isInsights || isPathAnalysis || isRetention ? 'Filter by' : 'Chart Series'}`}
{!isTable && !isFunnel && !isClickmap && !isInsights && !isPathAnalysis && !isRetention && (

View file

@ -68,7 +68,7 @@ function FilterItem(props: Props) {
};
return (
<div className="flex items-center hover:bg-active-blue -mx-5 px-5 py-2">
<div className="flex items-center w-full">
<div className="flex items-start w-full">
{!isFilter && !hideIndex && filterIndex >= 0 && (
<div

View file

@ -1,20 +1,25 @@
import React, { useEffect } from 'react';
import FilterItem from '../FilterItem';
import { SegmentSelection, Tooltip } from 'UI';
import { Segmented } from 'antd';
import { List } from 'immutable';
import { useObserver } from 'mobx-react-lite';
import { GripHorizontal } from 'lucide-react';
import { observer } from 'mobx-react-lite';
import React, { useEffect } from 'react';
import { Tooltip } from 'UI';
import FilterItem from '../FilterItem';
interface Props {
filter?: any; // event/filter
onUpdateFilter: (filterIndex: any, filter: any) => void;
onFilterMove?: (filters: any) => void;
onRemoveFilter: (filterIndex: any) => void;
onChangeEventsOrder: (e: any, { name, value }: any) => void;
hideEventsOrder?: boolean;
observeChanges?: () => void;
saveRequestPayloads?: boolean;
supportsEmpty?: boolean
supportsEmpty?: boolean;
readonly?: boolean;
excludeFilterKeys?: Array<string>
excludeFilterKeys?: Array<string>;
isConditional?: boolean;
}
function FilterList(props: Props) {
@ -42,16 +47,97 @@ function FilterList(props: Props) {
props.onRemoveFilter(filterIndex);
};
return useObserver(() => (
const [hoveredItem, setHoveredItem] = React.useState<Record<string, any>>({
i: null,
position: null,
});
const [draggedInd, setDraggedItem] = React.useState<number | null>(null);
const handleDragOverEv = (event: Record<string, any>, i: number) => {
event.preventDefault();
const target = event.currentTarget.getBoundingClientRect();
const hoverMiddleY = (target.bottom - target.top) / 2;
const hoverClientY = event.clientY - target.top;
const position = hoverClientY < hoverMiddleY ? 'top' : 'bottom';
setHoveredItem({ position, i });
};
const calculateNewPosition = React.useCallback(
(draggedInd: number, hoveredIndex: number, hoveredPosition: string) => {
if (hoveredPosition === 'bottom') {
hoveredIndex++;
}
return draggedInd < hoveredIndex ? hoveredIndex - 1 : hoveredIndex;
},
[]
);
const handleDragStart = React.useCallback((
ev: Record<string, any>,
index: number,
elId: string
) => {
ev.dataTransfer.setData("text/plain", index.toString());
setDraggedItem(index);
const el = document.getElementById(elId);
console.log(el, ev);
if (el) {
ev.dataTransfer.setDragImage(el, 0, 0);
}
}, [])
const handleDrop = React.useCallback(
(event: Record<string, any>) => {
event.preventDefault();
console.log(draggedInd)
if (draggedInd === null) return;
const newItems = filters.toArray();
const newPosition = calculateNewPosition(
draggedInd,
hoveredItem.i,
hoveredItem.position
);
const reorderedItem = newItems.splice(draggedInd, 1)[0];
newItems.splice(newPosition, 0, reorderedItem);
props.onFilterMove?.(List(newItems));
setHoveredItem({ i: null, position: null });
setDraggedItem(null);
},
[draggedInd, hoveredItem, filters, props.onFilterMove]
);
const eventOrderItems = [
{
label: 'THEN',
value: 'then',
disabled: eventsOrderSupport && !eventsOrderSupport.includes('then'),
},
{
label: 'AND',
value: 'and',
disabled: eventsOrderSupport && !eventsOrderSupport.includes('and'),
},
{
label: 'OR',
value: 'or',
disabled: eventsOrderSupport && !eventsOrderSupport.includes('or'),
},
];
return (
<div className="flex flex-col">
{hasEvents && (
<>
<div className="flex items-center mb-2">
<div className="text-sm color-gray-medium mr-auto">{filter.eventsHeader}</div>
<div className="text-sm color-gray-medium mr-auto">
{filter.eventsHeader}
</div>
{!hideEventsOrder && (
<div className="flex items-center">
<div className="flex items-center gap-2">
<div
className="mr-2 color-gray-medium text-sm"
className="color-gray-medium text-sm"
style={{ textDecoration: 'underline dotted' }}
>
<Tooltip
@ -61,37 +147,76 @@ function FilterList(props: Props) {
</Tooltip>
</div>
<SegmentSelection
primary
name="eventsOrder"
size="small"
onSelect={props.onChangeEventsOrder}
value={{ value: filter.eventsOrder }}
list={[
{ name: 'THEN', value: 'then', disabled: eventsOrderSupport && !eventsOrderSupport.includes('then') },
{ name: 'AND', value: 'and', disabled: eventsOrderSupport && !eventsOrderSupport.includes('and')},
{ name: 'OR', value: 'or', disabled: eventsOrderSupport && !eventsOrderSupport.includes('or')},
]}
<Segmented
onChange={(v) =>
props.onChangeEventsOrder(
null,
eventOrderItems.find((i) => i.value === v)
)
}
value={filter.eventsOrder}
options={eventOrderItems}
/>
</div>
)}
</div>
{filters.map((filter: any, filterIndex: any) =>
filter.isEvent ? (
<FilterItem
key={`${filter.key}-${filterIndex}`}
filterIndex={rowIndex++}
filter={filter}
onUpdate={(filter) => props.onUpdateFilter(filterIndex, filter)}
onRemoveFilter={() => onRemoveFilter(filterIndex)}
saveRequestPayloads={saveRequestPayloads}
disableDelete={cannotDeleteFilter}
excludeFilterKeys={excludeFilterKeys}
readonly={props.readonly}
isConditional={isConditional}
/>
) : null
)}
<div className={'flex flex-col'}>
{filters.map((filter: any, filterIndex: number) =>
filter.isEvent ? (
<div
style={{
pointerEvents: 'unset',
paddingTop:
hoveredItem.i === filterIndex &&
hoveredItem.position === 'top'
? '1.5rem'
: '0.5rem',
paddingBottom:
hoveredItem.i === filterIndex &&
hoveredItem.position === 'bottom'
? '1.5rem'
: '0.5rem',
}}
className={
'hover:bg-active-blue -mx-5 px-5 gap-2 items-center flex w-full'
}
id={`${filter.key}-${filterIndex}`}
onDragOver={(e) => handleDragOverEv(e, filterIndex)}
onDrop={(e) => handleDrop(e)}
key={`${filter.key}-${filterIndex}`}
>
{!!props.onFilterMove ? (
<div
className={'p-2 cursor-grab'}
draggable={!!props.onFilterMove}
onDragStart={(e) =>
handleDragStart(
e,
filterIndex,
`${filter.key}-${filterIndex}`
)
}
>
<GripHorizontal size={16} />
</div>
) : null}
<FilterItem
filterIndex={rowIndex++}
filter={filter}
onUpdate={(filter) =>
props.onUpdateFilter(filterIndex, filter)
}
onRemoveFilter={() => onRemoveFilter(filterIndex)}
saveRequestPayloads={saveRequestPayloads}
disableDelete={cannotDeleteFilter}
excludeFilterKeys={excludeFilterKeys}
readonly={props.readonly}
isConditional={isConditional}
/>
</div>
) : null
)}
</div>
<div className="mb-2" />
</>
)}
@ -118,7 +243,7 @@ function FilterList(props: Props) {
</>
)}
</div>
));
);
}
export default FilterList;
export default observer(FilterList);

View file

@ -77,6 +77,15 @@ function SessionSearch(props: Props) {
debounceFetch();
};
const onFilterMove = (newFilters: any) => {
props.updateFilter({
...appliedFilter,
filters: newFilters,
});
debounceFetch();
}
const onRemoveFilter = (filterIndex: any) => {
const newFilters = appliedFilter.filters.filter((_filter: any, i: any) => {
return i !== filterIndex;
@ -115,6 +124,7 @@ function SessionSearch(props: Props) {
onUpdateFilter={onUpdateFilter}
onRemoveFilter={onRemoveFilter}
onChangeEventsOrder={onChangeEventsOrder}
onFilterMove={onFilterMove}
saveRequestPayloads={saveRequestPayloads}
/>
) : null}

View file

@ -28,6 +28,8 @@ export default class Filter {
updateKey: action,
merge: action,
addExcludeFilter: action,
updateFilter: action,
replaceFilters: action,
})
}
@ -48,6 +50,11 @@ export default class Filter {
this.filters.push(new FilterItem(filter))
}
replaceFilters(filters: any) {
console.log(filters, this.filters)
this.filters = filters;
}
updateFilter(index: number, filter: any) {
this.filters[index] = new FilterItem(filter)
}

View file

@ -386,6 +386,19 @@ export function millisToMinutesAndSeconds(millis: any) {
return minutes + 'm' + (seconds < 10 ? '0' : '') + seconds + 's';
}
export function simpleThrottle(func: (...args: any[]) => void, limit: number): (...args: any[]) => void {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
export function throttle(func, wait, options) {
var context, args, result;
var timeout = null;