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:
parent
e4a1037e3d
commit
44574016bc
8 changed files with 323 additions and 88 deletions
|
|
@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue