From 44574016bc0e83c3cadecc736d2aa7e9c4168c49 Mon Sep 17 00:00:00 2001 From: Delirium Date: Tue, 2 Apr 2024 16:53:27 +0200 Subject: [PATCH] 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 --- .../components/FilterSeries/FilterSeries.tsx | 6 + .../FunnelIssuesList/FunnelIssuesList.tsx | 168 +++++++++++---- .../components/WidgetForm/WidgetForm.tsx | 2 +- .../shared/Filters/FilterItem/FilterItem.tsx | 2 +- .../shared/Filters/FilterList/FilterList.tsx | 203 ++++++++++++++---- .../shared/SessionSearch/SessionSearch.tsx | 10 + frontend/app/mstore/types/filter.ts | 7 + frontend/app/utils/index.ts | 13 ++ 8 files changed, 323 insertions(+), 88 deletions(-) diff --git a/frontend/app/components/Dashboard/components/FilterSeries/FilterSeries.tsx b/frontend/app/components/Dashboard/components/FilterSeries/FilterSeries.tsx index b2f1d08d7..14af62f56 100644 --- a/frontend/app/components/Dashboard/components/FilterSeries/FilterSeries.tsx +++ b/frontend/app/components/Dashboard/components/FilterSeries/FilterSeries.tsx @@ -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} /> ) : ( diff --git a/frontend/app/components/Dashboard/components/Funnels/FunnelIssuesList/FunnelIssuesList.tsx b/frontend/app/components/Dashboard/components/Funnels/FunnelIssuesList/FunnelIssuesList.tsx index 0f7dafdf5..6221b2c03 100644 --- a/frontend/app/components/Dashboard/components/Funnels/FunnelIssuesList/FunnelIssuesList.tsx +++ b/frontend/app/components/Dashboard/components/Funnels/FunnelIssuesList/FunnelIssuesList.tsx @@ -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['columns'] = [ + { + title: 'Issue', + dataIndex: 'title', + key: 'title', + }, + { + title: '# Users Affected', + dataIndex: 'affectedUsers', + key: 'affectedUsers', + }, + { + title: 'Conversion Impact', + dataIndex: 'conversionImpact', + key: 'conversionImpact', + render: (text: string) => {text}%, + }, + { + title: 'Lost Conversions', + dataIndex: 'lostConversions', + key: 'lostConversions', + render: (text: string) => {text}, + }, +]; interface Props { - loading?: boolean; - issues: any; - history: any; - location: any; + loading?: boolean; + issues: Issue[]; + history: any; + location: any; } function FunnelIssuesList(props: RouteComponentProps) { - 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(, { 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(, { + right: true, + width: 1000, + onClose: () => { + if (props.history.location.pathname.includes('/metric')) { + props.history.replace({ search: '' }); + } + }, + }); + }, [issueId]); - return useObserver(() => ( - - -
No issues found
- - } - > - {filteredIssues.map((issue: any, index: React.Key) => ( -
- onIssueClick(issue)} /> -
- ))} -
- )) + 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(() => ( + + +
No issues found
+ + } + > + ({ + onClick: () => onIssueClick(rec), + })} + rowClassName={'cursor-pointer'} + /> + + )); } -export default withRouter(FunnelIssuesList) as React.FunctionComponent>; +export default withRouter(FunnelIssuesList); diff --git a/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx b/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx index adc08635e..3e046e52f 100644 --- a/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx +++ b/frontend/app/components/Dashboard/components/WidgetForm/WidgetForm.tsx @@ -239,7 +239,7 @@ function WidgetForm(props: Props) { )} {!isPredefined && ( -
+
{`${isTable || isFunnel || isClickmap || isInsights || isPathAnalysis || isRetention ? 'Filter by' : 'Chart Series'}`} {!isTable && !isFunnel && !isClickmap && !isInsights && !isPathAnalysis && !isRetention && ( diff --git a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx index e8a42fc18..95cac0b9a 100644 --- a/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx +++ b/frontend/app/components/shared/Filters/FilterItem/FilterItem.tsx @@ -68,7 +68,7 @@ function FilterItem(props: Props) { }; return ( -
+
{!isFilter && !hideIndex && filterIndex >= 0 && (
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 + excludeFilterKeys?: Array; isConditional?: boolean; } function FilterList(props: Props) { @@ -42,16 +47,97 @@ function FilterList(props: Props) { props.onRemoveFilter(filterIndex); }; - return useObserver(() => ( + const [hoveredItem, setHoveredItem] = React.useState>({ + i: null, + position: null, + }); + const [draggedInd, setDraggedItem] = React.useState(null); + + const handleDragOverEv = (event: Record, 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, + 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) => { + 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 (
{hasEvents && ( <>
-
{filter.eventsHeader}
+
+ {filter.eventsHeader} +
{!hideEventsOrder && ( -
+
- + props.onChangeEventsOrder( + null, + eventOrderItems.find((i) => i.value === v) + ) + } + value={filter.eventsOrder} + options={eventOrderItems} />
)}
- {filters.map((filter: any, filterIndex: any) => - filter.isEvent ? ( - props.onUpdateFilter(filterIndex, filter)} - onRemoveFilter={() => onRemoveFilter(filterIndex)} - saveRequestPayloads={saveRequestPayloads} - disableDelete={cannotDeleteFilter} - excludeFilterKeys={excludeFilterKeys} - readonly={props.readonly} - isConditional={isConditional} - /> - ) : null - )} +
+ {filters.map((filter: any, filterIndex: number) => + filter.isEvent ? ( +
handleDragOverEv(e, filterIndex)} + onDrop={(e) => handleDrop(e)} + key={`${filter.key}-${filterIndex}`} + > + {!!props.onFilterMove ? ( +
+ handleDragStart( + e, + filterIndex, + `${filter.key}-${filterIndex}` + ) + } + > + +
+ ) : null} + + props.onUpdateFilter(filterIndex, filter) + } + onRemoveFilter={() => onRemoveFilter(filterIndex)} + saveRequestPayloads={saveRequestPayloads} + disableDelete={cannotDeleteFilter} + excludeFilterKeys={excludeFilterKeys} + readonly={props.readonly} + isConditional={isConditional} + /> +
+ ) : null + )} +
)} @@ -118,7 +243,7 @@ function FilterList(props: Props) { )}
- )); + ); } -export default FilterList; +export default observer(FilterList); diff --git a/frontend/app/components/shared/SessionSearch/SessionSearch.tsx b/frontend/app/components/shared/SessionSearch/SessionSearch.tsx index d73c041b6..24c2e25e9 100644 --- a/frontend/app/components/shared/SessionSearch/SessionSearch.tsx +++ b/frontend/app/components/shared/SessionSearch/SessionSearch.tsx @@ -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} diff --git a/frontend/app/mstore/types/filter.ts b/frontend/app/mstore/types/filter.ts index 81dc1a2ea..862e412d1 100644 --- a/frontend/app/mstore/types/filter.ts +++ b/frontend/app/mstore/types/filter.ts @@ -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) } diff --git a/frontend/app/utils/index.ts b/frontend/app/utils/index.ts index bb7d32e3a..89e4a49f6 100644 --- a/frontend/app/utils/index.ts +++ b/frontend/app/utils/index.ts @@ -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;