From 67b54ccf6b51915eabcfa6fe2c1cb8510d5ac5ca Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Mon, 19 Jul 2021 21:04:26 +0200 Subject: [PATCH 1/2] the new filtering experience --- frontend/src/lib/components/Popup/Popup.tsx | 53 +++- .../components/PropertyFilter.tsx | 2 +- .../components/TaxonomicPropertyFilter.scss | 114 ++++++++ .../components/TaxonomicPropertyFilter.tsx | 132 +++++++++ .../TaxonomicPropertyFilter/InfiniteList.tsx | 142 ---------- .../InfiniteSelectResults.tsx | 58 ---- .../TaxonomicPropertyFilter.scss | 185 ------------- .../TaxonomicPropertyFilter.tsx | 177 ------------ .../TaxonomicPropertyFilter/groups.ts | 42 --- .../taxonomicPropertyFilterLogic.ts | 140 ---------- .../taxonomicPropertyFilterLogic.ts | 72 +++++ .../PropertyFilters/propertyFilterLogic.ts | 20 +- .../lib/components/PropertyFilters/types.ts | 4 - .../lib/components/PropertyFilters/utils.ts | 18 ++ .../src/lib/components/PropertyKeyInfo.tsx | 89 +++--- .../TaxonomicFilter/InfiniteList.scss | 38 +++ .../TaxonomicFilter/InfiniteList.tsx | 254 ++++++++++++++++++ .../TaxonomicFilter/InfiniteSelectResults.tsx | 75 ++++++ .../TaxonomicFilter/TaxonomicFilter.scss | 10 + .../TaxonomicFilter/TaxonomicFilter.tsx | 90 +++++++ .../lib/components/TaxonomicFilter/groups.ts | 66 +++++ .../infiniteListLogic.ts | 93 ++++--- .../TaxonomicFilter/taxonomicFilterLogic.ts | 123 +++++++++ .../lib/components/TaxonomicFilter/types.ts | 40 +++ .../src/lib/hooks/useOutsideClickHandler.ts | 8 +- .../ActionFilter/ActionFilterRow/index.tsx | 121 +++++++-- .../BreakdownFilter/BreakdownFilter.js | 14 +- .../TaxonomicBreakdownFilter.tsx | 77 ++++++ frontend/src/scenes/paths/pathsLogic.ts | 6 +- .../scenes/retention/retentionTableLogic.ts | 18 +- .../src/scenes/trends/personsModalLogic.ts | 2 - frontend/src/scenes/trends/trendsLogic.ts | 12 +- package.json | 4 +- yarn.lock | 16 +- 34 files changed, 1413 insertions(+), 902 deletions(-) create mode 100644 frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.scss create mode 100644 frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.tsx delete mode 100644 frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/InfiniteList.tsx delete mode 100644 frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/InfiniteSelectResults.tsx delete mode 100644 frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/TaxonomicPropertyFilter.scss delete mode 100644 frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/TaxonomicPropertyFilter.tsx delete mode 100644 frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/groups.ts delete mode 100644 frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/taxonomicPropertyFilterLogic.ts create mode 100644 frontend/src/lib/components/PropertyFilters/components/taxonomicPropertyFilterLogic.ts create mode 100644 frontend/src/lib/components/TaxonomicFilter/InfiniteList.scss create mode 100644 frontend/src/lib/components/TaxonomicFilter/InfiniteList.tsx create mode 100644 frontend/src/lib/components/TaxonomicFilter/InfiniteSelectResults.tsx create mode 100644 frontend/src/lib/components/TaxonomicFilter/TaxonomicFilter.scss create mode 100644 frontend/src/lib/components/TaxonomicFilter/TaxonomicFilter.tsx create mode 100644 frontend/src/lib/components/TaxonomicFilter/groups.ts rename frontend/src/lib/components/{PropertyFilters/components/TaxonomicPropertyFilter => TaxonomicFilter}/infiniteListLogic.ts (71%) create mode 100644 frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.ts create mode 100644 frontend/src/lib/components/TaxonomicFilter/types.ts create mode 100644 frontend/src/scenes/insights/BreakdownFilter/TaxonomicBreakdownFilter.tsx diff --git a/frontend/src/lib/components/Popup/Popup.tsx b/frontend/src/lib/components/Popup/Popup.tsx index df37957474677..58eca6f044e0a 100644 --- a/frontend/src/lib/components/Popup/Popup.tsx +++ b/frontend/src/lib/components/Popup/Popup.tsx @@ -1,18 +1,25 @@ import './Popup.scss' -import React, { ReactElement, useState } from 'react' +import React, { ReactElement, useContext, useEffect, useMemo, useState } from 'react' +import ReactDOM from 'react-dom' import { usePopper } from 'react-popper' import { useOutsideClickHandler } from 'lib/hooks/useOutsideClickHandler' import { Placement } from '@popperjs/core' interface PopupProps { visible?: boolean - onClickOutside?: () => void + onClickOutside?: (event: Event) => void children: React.ReactChild | ((props: { setRef?: (ref: HTMLElement) => void }) => JSX.Element) overlay: React.ReactNode placement?: Placement fallbackPlacements?: Placement[] } +// if we're inside a popup inside a popup, prevent the parent's onClickOutside from working +const PopupContext = React.createContext(0) +const disabledPopups = new Map() +let uniqueMemoizedIndex = 0 + +/** This is a custom popup control that uses `react-popper` to position DOM nodes */ export function Popup({ children, overlay, @@ -21,15 +28,32 @@ export function Popup({ placement = 'bottom-start', fallbackPlacements = ['bottom-end', 'top-start', 'top-end'], }: PopupProps): JSX.Element { + const popupId = useMemo(() => ++uniqueMemoizedIndex, []) + const [referenceElement, setReferenceElement] = useState(null) const [popperElement, setPopperElement] = useState(null) - const [arrowElement, setArrowElement] = useState(null) - useOutsideClickHandler([popperElement, referenceElement, arrowElement] as HTMLElement[], onClickOutside) + + const parentPopupId = useContext(PopupContext) + const localRefs = [popperElement, referenceElement] as (HTMLElement | null)[] + + useEffect(() => { + if (visible) { + disabledPopups.set(parentPopupId, (disabledPopups.get(parentPopupId) || 0) + 1) + return () => { + disabledPopups.set(parentPopupId, (disabledPopups.get(parentPopupId) || 0) - 1) + } + } + }, [visible, parentPopupId]) + + useOutsideClickHandler(localRefs, (event) => { + if (visible && !disabledPopups.get(popupId)) { + onClickOutside?.(event) + } + }) const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: placement, modifiers: [ - { name: 'arrow', options: { element: arrowElement } }, fallbackPlacements ? { name: 'flip', @@ -55,12 +79,19 @@ export function Popup({ return ( <> {clonedChildren} - {visible && ( -
- {overlay} -
-
- )} + {visible + ? ReactDOM.createPortal( +
+ {overlay} +
, + document.querySelector('body') as HTMLElement + ) + : null} ) } diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyFilter.tsx b/frontend/src/lib/components/PropertyFilters/components/PropertyFilter.tsx index d18a58d5c5e04..0845c573fb308 100644 --- a/frontend/src/lib/components/PropertyFilters/components/PropertyFilter.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/PropertyFilter.tsx @@ -1,7 +1,7 @@ import React from 'react' import { SelectGradientOverflowProps } from 'lib/components/SelectGradientOverflow' import { TabbedPropertyFilter } from './TabbedPropertyFilter' -import { TaxonomicPropertyFilter } from './TaxonomicPropertyFilter/TaxonomicPropertyFilter' +import { TaxonomicPropertyFilter } from './TaxonomicPropertyFilter' import { UnifiedPropertyFilter } from './UnifiedPropertyFilter' export interface PropertyFilterInternalProps { diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.scss b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.scss new file mode 100644 index 0000000000000..093b41465a027 --- /dev/null +++ b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.scss @@ -0,0 +1,114 @@ +.taxonomic-property-filter { + width: 100%; + + &.in-dropdown { + min-width: 300px; + width: 550px; + max-width: calc(100vw - 25px); + background: white; + } + + .taxonomic-filter-row { + display: grid; + grid-column-gap: 0.5rem; + grid-row-gap: 0.25rem; + grid-template-columns: 70px minmax(180px, 1fr) minmax(100px, auto); + + // only setting grid properties here, the rest are below + .taxonomic-where { + grid-column: 1; + grid-row: 1; + } + + .taxonomic-button { + grid-column: 2; + grid-row: 1; + } + + .taxonomic-operator { + grid-column: 3; + grid-row: 1; + } + + .taxonomic-value-select { + grid-column: 2 / 4; + grid-row: 2; + } + + // small screens + @media (max-width: 512px) { + grid-template-columns: 35px auto 70px; + .taxonomic-where { + .arrow { + display: none; + } + } + } + + // bigger screens + @media (min-width: 1080px) { + grid-template-columns: 70px minmax(140px, 160px) minmax(100px, 120px) minmax(100px, 400px); + .taxonomic-where { + grid-column: 1; + grid-row: 1; + } + .taxonomic-button { + grid-column: 2; + grid-row: 1; + } + .taxonomic-operator { + grid-column: 3; + grid-row: 1; + } + .taxonomic-value-select { + grid-column: 4; + grid-row: 1; + } + } + // even more space for huge screens + @media (min-width: 1280px) { + grid-template-columns: 70px minmax(140px, 220px) minmax(80px, 160px) minmax(100px, 500px); + } + } + + .taxonomic-where { + height: 32px; // matches antd Select height + display: flex; + align-items: center; + justify-content: flex-end; + + .arrow { + color: #c4c4c4; + font-size: 18px; + font-weight: bold; + padding-left: 6px; + padding-right: 8px; + position: relative; + top: -4px; + user-select: none; + } + } + + .taxonomic-button { + display: flex; + justify-content: space-between; + overflow: hidden; + .property-key-info { + width: auto; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + &.add-filter { + width: max-content; + } + } + + .taxonomic-operator { + overflow: hidden; + } + + .taxonomic-value-select { + overflow: hidden; + } +} diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.tsx b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.tsx new file mode 100644 index 0000000000000..0394a5683d115 --- /dev/null +++ b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter.tsx @@ -0,0 +1,132 @@ +import './TaxonomicPropertyFilter.scss' +import React, { useMemo } from 'react' +import { Button, Col } from 'antd' +import { useActions, useValues } from 'kea' +import { propertyFilterLogic } from 'lib/components/PropertyFilters/propertyFilterLogic' +import { taxonomicPropertyFilterLogic } from './taxonomicPropertyFilterLogic' +import { SelectDownIcon } from 'lib/components/SelectDownIcon' +import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' +import { OperatorValueSelect } from 'lib/components/PropertyFilters/components/OperatorValueSelect' +import { isOperatorMulti, isOperatorRegex } from 'lib/utils' +import { Popup } from 'lib/components/Popup/Popup' +import { PropertyFilterInternalProps } from 'lib/components/PropertyFilters' +import { TaxonomicFilter } from 'lib/components/TaxonomicFilter/TaxonomicFilter' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { + propertyFilterTypeToTaxonomicFilterType, + taxonomicFilterTypeToPropertyFilterType, +} from 'lib/components/PropertyFilters/utils' + +let uniqueMemoizedIndex = 0 + +export function TaxonomicPropertyFilter({ + pageKey: pageKeyInput, + index, + onComplete, + disablePopover, // inside a dropdown if this is false +}: PropertyFilterInternalProps): JSX.Element { + const pageKey = useMemo(() => pageKeyInput || `filter-${uniqueMemoizedIndex++}`, [pageKeyInput]) + const { setFilter } = useActions(propertyFilterLogic) + + const logic = taxonomicPropertyFilterLogic({ pageKey, filterIndex: index }) + const { filter, dropdownOpen, selectedCohortName } = useValues(logic) + const { openDropdown, closeDropdown, selectItem } = useActions(logic) + + const showInitialSearchInline = !disablePopover && ((!filter?.type && !filter?.key) || filter?.type === 'cohort') + const showOperatorValueSelect = filter?.type && filter?.key && filter?.type !== 'cohort' + + const taxonomicFilter = ( + { + selectItem(taxonomicFilterTypeToPropertyFilterType(groupType), value) + if (groupType === TaxonomicFilterGroupType.Cohorts) { + onComplete?.() + } + }} + groupTypes={[ + TaxonomicFilterGroupType.EventProperties, + TaxonomicFilterGroupType.PersonProperties, + TaxonomicFilterGroupType.Cohorts, + TaxonomicFilterGroupType.Elements, + ]} + /> + ) + + return ( +
+ {showInitialSearchInline ? ( + taxonomicFilter + ) : ( +
+ + {index === 0 ? ( + <> + + where + + ) : ( + + AND + + )} + + + + + + + {showOperatorValueSelect && ( + { + if (filter?.key && filter?.type) { + setFilter(index, filter?.key, newValue || null, newOperator, filter?.type) + } + if ( + newOperator && + newValue && + !isOperatorMulti(newOperator) && + !isOperatorRegex(newOperator) + ) { + onComplete() + } + }} + columnOptions={[ + { + className: 'taxonomic-operator', + }, + { + className: 'taxonomic-value-select', + }, + ]} + /> + )} +
+ )} +
+ ) +} diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/InfiniteList.tsx b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/InfiniteList.tsx deleted file mode 100644 index 2dfdba3976a13..0000000000000 --- a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/InfiniteList.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import React, { useEffect, useRef, useState } from 'react' -import { Empty, Skeleton } from 'antd' -import { AutoSizer, List, ListRowProps, ListRowRenderer } from 'react-virtualized' -import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' -import { useActions, useValues } from 'kea' -import { infiniteListLogic } from './infiniteListLogic' -import { taxonomicPropertyFilterLogic } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter/taxonomicPropertyFilterLogic' -import { filterMatchesItem } from 'lib/components/PropertyFilters/utils' - -enum ListTooltip { - None = 0, - Left = 1, - Right = 2, -} - -interface InfiniteListProps { - pageKey: string - filterIndex: number - type: string - onComplete: () => void -} - -export function tooltipDesiredState(element?: Element | null): ListTooltip { - let desiredState: ListTooltip = ListTooltip.None - const rect = element?.getBoundingClientRect() - if (rect) { - if (window.innerWidth - rect.right > 300) { - desiredState = ListTooltip.Right - } else if (rect.left > 300) { - desiredState = ListTooltip.Left - } - } - return desiredState -} - -export function InfiniteList({ pageKey, filterIndex, type, onComplete }: InfiniteListProps): JSX.Element { - const filterLogic = taxonomicPropertyFilterLogic({ pageKey, filterIndex }) - const { filter, mouseInteractionsEnabled, activeTab, searchQuery } = useValues(filterLogic) - const { selectItem } = useActions(filterLogic) - - const listLogic = infiniteListLogic({ pageKey, filterIndex, type }) - const { isLoading, results, totalCount, index } = useValues(listLogic) - const { onRowsRendered, setIndex } = useActions(listLogic) - - // after rendering measure if there's space for a tooltip - const [listTooltip, setListTooltip] = useState(ListTooltip.None) - const listRef = useRef(null) - useEffect(() => { - function adjustListTooltip(): void { - const desiredState = tooltipDesiredState(listRef?.current) - if (listTooltip !== desiredState) { - setListTooltip(desiredState) - } - } - if (listRef?.current) { - const resizeObserver = new ResizeObserver(adjustListTooltip) - resizeObserver.observe(listRef.current) - window.addEventListener('resize', adjustListTooltip) - window.addEventListener('scroll', adjustListTooltip) - return () => { - resizeObserver.disconnect() - window.removeEventListener('scroll', adjustListTooltip) - window.removeEventListener('resize', adjustListTooltip) - } - } - }, [listRef.current, index]) - - const renderItem: ListRowRenderer = ({ index: rowIndex, style }: ListRowProps): JSX.Element | null => { - const item = results[rowIndex] - - const isSelected = filterMatchesItem(filter, item, type) - - return item ? ( -
{ - selectItem(type, item.id, item.name) - if (type === 'cohort') { - onComplete?.() - } - }} - onMouseOver={() => { - if (mouseInteractionsEnabled) { - setIndex(rowIndex) - } - }} - style={style} - data-attr={`prop-filter-${type}-${rowIndex}`} - > - -
- ) : ( -
mouseInteractionsEnabled && setIndex(rowIndex)} - style={style} - data-attr={`prop-filter-${type}-${rowIndex}`} - > - -
- ) - } - - const showEmptyState = totalCount === 0 && !isLoading - - return ( -
- {showEmptyState ? ( -
- - No results for "{searchQuery}" - - } - /> -
- ) : ( - - {({ height, width }) => ( - - )} - - )} -
- ) -} diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/InfiniteSelectResults.tsx b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/InfiniteSelectResults.tsx deleted file mode 100644 index ea27a61de8260..0000000000000 --- a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/InfiniteSelectResults.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' -import { Tabs, Tag } from 'antd' -import { useActions, useValues } from 'kea' -import { taxonomicPropertyFilterLogic } from './taxonomicPropertyFilterLogic' -import { groups } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter/groups' -import { infiniteListLogic } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter/infiniteListLogic' -import { InfiniteList } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter/InfiniteList' - -export interface InfiniteSelectResultsProps { - pageKey: string - filterIndex: number - focusInput: () => void - onComplete: () => void -} - -export function InfiniteSelectResults({ - pageKey, - filterIndex, - focusInput, - onComplete, -}: InfiniteSelectResultsProps): JSX.Element { - const filterLogic = taxonomicPropertyFilterLogic({ pageKey, filterIndex }) - const { activeTab } = useValues(filterLogic) - const { setActiveTab } = useActions(filterLogic) - - const counts: Record = {} - for (const group of groups) { - // :TRICKY: `groups` never changes, so this `useValues` hook is ran deterministically, even if in a for loop - const logic = infiniteListLogic({ pageKey, filterIndex, type: group.type }) - counts[group.type] = useValues(logic).totalCount - } - - return ( - { - setActiveTab(value) - focusInput() - }} - tabPosition="top" - animated={false} - > - {groups.map(({ name, type }) => { - const count = counts[type] - const tabTitle = ( - <> - {name} {count != null && {count}} - - ) - return ( - - - - ) - })} - - ) -} diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/TaxonomicPropertyFilter.scss b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/TaxonomicPropertyFilter.scss deleted file mode 100644 index 9b1c71fa3a4d0..0000000000000 --- a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/TaxonomicPropertyFilter.scss +++ /dev/null @@ -1,185 +0,0 @@ -.taxonomic-filter-dropdown { - background: white; - min-width: 300px; - width: 100%; - max-width: calc(100vw - 20px); -} - -.taxonomic-infinite-list { - min-height: 200px; - - &.empty-infinite-list { - width: 100%; - display: flex; - align-items: center; - justify-content: center; - .no-infinite-results { - color: #666; - } - } - - .taxonomic-list-row { - display: flex; - align-items: center; - justify-content: space-between; - color: #2d2d2d; - padding: 4px 12px; - cursor: pointer; - border: none; - - &.hover { - background: rgba(0, 0, 0, 0.1); - } - - &.selected { - font-weight: bold; - } - - &.skeleton-row { - // center-align this antd skeleton - .ant-skeleton-paragraph { - margin-bottom: 0; - } - } - } -} - -.taxonomic-property-filter { - width: 100%; - - .taxonomic-filter-row { - display: grid; - grid-column-gap: 0.5rem; - grid-row-gap: 0.25rem; - } - - &.in-dropdown { - min-width: 300px; - width: 550px; - max-width: calc(100vw - 40px); - .taxonomic-filter-row { - grid-template-columns: auto 160px; - .taxonomic-where { - display: none; - } - .taxonomic-button { - grid-column: 1; - grid-row: 1; - } - .taxonomic-operator { - grid-column: 2; - grid-row: 1; - } - .taxonomic-value-select { - grid-column: 1 / 3; - grid-row: 2; - } - @media (max-width: 400px) { - grid-template-columns: auto 100px; - } - } - } - - &.row-on-page { - .taxonomic-filter-row { - grid-template-columns: 70px minmax(180px, 1fr) minmax(100px, auto); - - // only setting grid properties here, the rest are below - .taxonomic-where { - grid-column: 1; - grid-row: 1; - } - - .taxonomic-button { - grid-column: 2; - grid-row: 1; - } - - .taxonomic-operator { - grid-column: 3; - grid-row: 1; - } - - .taxonomic-value-select { - grid-column: 2 / 4; - grid-row: 2; - } - - // small screens - @media (max-width: 512px) { - grid-template-columns: 35px auto 70px; - .taxonomic-where { - .arrow { - display: none; - } - } - } - - // bigger screens - @media (min-width: 1080px) { - grid-template-columns: 70px minmax(140px, 160px) minmax(100px, 120px) minmax(100px, 400px); - .taxonomic-where { - grid-column: 1; - grid-row: 1; - } - .taxonomic-button { - grid-column: 2; - grid-row: 1; - } - .taxonomic-operator { - grid-column: 3; - grid-row: 1; - } - .taxonomic-value-select { - grid-column: 4; - grid-row: 1; - } - } - // even more space for huge screens - @media (min-width: 1280px) { - grid-template-columns: 70px minmax(140px, 220px) minmax(80px, 160px) minmax(100px, 500px); - } - } - } - - .taxonomic-where { - height: 32px; // matches antd Select height - display: flex; - align-items: center; - justify-content: flex-end; - - .arrow { - color: #c4c4c4; - font-size: 18px; - font-weight: bold; - padding-left: 6px; - padding-right: 8px; - position: relative; - top: -4px; - user-select: none; - } - } - - .taxonomic-button { - display: flex; - justify-content: space-between; - overflow: hidden; - .property-key-info { - width: auto; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - } - &.add-filter { - width: max-content; - } - } - - .taxonomic-operator { - overflow: hidden; - } - - .taxonomic-value-select { - overflow: hidden; - } -} diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/TaxonomicPropertyFilter.tsx b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/TaxonomicPropertyFilter.tsx deleted file mode 100644 index 7a56e0b3085ea..0000000000000 --- a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/TaxonomicPropertyFilter.tsx +++ /dev/null @@ -1,177 +0,0 @@ -/* -Contains the property filter component w/ properties and cohorts separated in tabs. Also includes infinite-scroll remote loading. -*/ -import './TaxonomicPropertyFilter.scss' -import React, { useEffect, useMemo, useRef } from 'react' -import { Button, Col, Input } from 'antd' -import { useValues, useActions, BindLogic } from 'kea' -import { PropertyFilterInternalProps } from '../PropertyFilter' -import { InfiniteSelectResults } from './InfiniteSelectResults' -import { propertyFilterLogic } from 'lib/components/PropertyFilters/propertyFilterLogic' -import { taxonomicPropertyFilterLogic } from './taxonomicPropertyFilterLogic' -import { SelectDownIcon } from 'lib/components/SelectDownIcon' -import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' -import { OperatorValueSelect } from 'lib/components/PropertyFilters/components/OperatorValueSelect' -import { isOperatorMulti, isOperatorRegex } from 'lib/utils' -import { Popup } from 'lib/components/Popup/Popup' - -let uniqueMemoizedIndex = 0 - -export function TaxonomicPropertyFilter({ - pageKey: pageKeyInput, - index, - onComplete, - disablePopover, -}: PropertyFilterInternalProps): JSX.Element { - const pageKey = useMemo(() => pageKeyInput || `filter-${uniqueMemoizedIndex++}`, [pageKeyInput]) - - const searchInputRef = useRef(null) - const focusInput = (): void => searchInputRef.current?.focus() - - const { setFilter } = useActions(propertyFilterLogic) - - const logic = taxonomicPropertyFilterLogic({ pageKey, filterIndex: index }) - const { searchQuery, filter, dropdownOpen, selectedCohortName } = useValues(logic) - const { - setSearchQuery, - openDropdown, - closeDropdown, - moveUp, - moveDown, - tabLeft, - tabRight, - selectSelected, - } = useActions(logic) - - const showInitialSearchInline = !disablePopover && ((!filter?.type && !filter?.key) || filter?.type === 'cohort') - const showOperatorValueSelect = filter?.type && filter?.key && filter?.type !== 'cohort' - - const searchInput = ( - (searchInputRef.current = ref)} - onChange={(e) => setSearchQuery(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'ArrowUp') { - e.preventDefault() - moveUp() - } - if (e.key === 'ArrowDown') { - e.preventDefault() - moveDown() - } - if (e.key === 'Tab') { - e.preventDefault() - if (e.shiftKey) { - tabLeft() - } else { - tabRight() - } - } - if (e.key === 'Enter') { - e.preventDefault() - selectSelected(onComplete) - } - }} - /> - ) - - const searchResults = ( - - ) - - useEffect(() => { - if (dropdownOpen || showInitialSearchInline) { - window.setTimeout(() => focusInput(), 1) - } - }, [dropdownOpen, showInitialSearchInline]) - - return ( -
- - {showInitialSearchInline ? ( -
- {searchInput} - {searchResults} -
- ) : ( -
- - {index === 0 ? ( - <> - - where - - ) : ( - - AND - - )} - - - - {searchInput} - {searchResults} -
- ) : null - } - placement={'bottom-start'} - fallbackPlacements={['bottom-end']} - visible={dropdownOpen} - onClickOutside={closeDropdown} - > - - - - {showOperatorValueSelect && ( - { - if (filter?.key && filter?.type) { - setFilter(index, filter?.key, newValue || null, newOperator, filter?.type) - } - if ( - newOperator && - newValue && - !isOperatorMulti(newOperator) && - !isOperatorRegex(newOperator) - ) { - onComplete() - } - }} - columnOptions={[ - { - className: 'taxonomic-operator', - }, - { - className: 'taxonomic-value-select', - }, - ]} - /> - )} -
- )} - -
- ) -} diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/groups.ts b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/groups.ts deleted file mode 100644 index d6999533ade9a..0000000000000 --- a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/groups.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { personPropertiesModel } from '~/models/personPropertiesModel' -import { CohortType, PersonProperty } from '~/types' -import { cohortsModel } from '~/models/cohortsModel' -import { LogicWrapper } from 'kea' - -export type PropertyFilterGroup = { - name: string - type: string - endpoint?: string - logic?: LogicWrapper - value?: string - map?: (prop: any) => any -} - -export const groups: PropertyFilterGroup[] = [ - { - name: 'Event properties', - type: 'event', - endpoint: 'api/projects/@current/property_definitions', - }, - { - name: 'Person properties', - type: 'person', - logic: personPropertiesModel, - value: 'personProperties', - map: (property: PersonProperty): any => ({ - ...property, - key: property.name, - }), - }, - { - name: 'Cohorts', - type: 'cohort', - logic: cohortsModel, - value: 'cohorts', - map: (cohort: CohortType): any => ({ - ...cohort, - key: cohort.id, - name: cohort.name || '', - }), - }, -] diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/taxonomicPropertyFilterLogic.ts b/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/taxonomicPropertyFilterLogic.ts deleted file mode 100644 index a4e632f9a73b5..0000000000000 --- a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/taxonomicPropertyFilterLogic.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { kea } from 'kea' -import { taxonomicPropertyFilterLogicType } from './taxonomicPropertyFilterLogicType' -import { propertyFilterLogic } from 'lib/components/PropertyFilters/propertyFilterLogic' -import { TaxonomicPropertyFilterLogicProps } from 'lib/components/PropertyFilters/types' -import { AnyPropertyFilter, PropertyOperator } from '~/types' -import { groups } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter/groups' -import { cohortsModel } from '~/models/cohortsModel' -import { infiniteListLogic } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter/infiniteListLogic' - -export const taxonomicPropertyFilterLogic = kea({ - props: {} as TaxonomicPropertyFilterLogicProps, - key: (props) => `${props.pageKey}-${props.filterIndex}`, - - connect: (props: TaxonomicPropertyFilterLogicProps) => ({ - values: [propertyFilterLogic(props), ['filters']], - }), - - actions: () => ({ - moveUp: true, - moveDown: true, - selectSelected: (onComplete?: () => void) => ({ onComplete }), - enableMouseInteractions: true, - tabLeft: true, - tabRight: true, - setSearchQuery: (searchQuery: string) => ({ searchQuery }), - setActiveTab: (activeTab: string) => ({ activeTab }), - selectItem: (type: string, id: string | number, name: string) => ({ type, id, name }), - openDropdown: true, - closeDropdown: true, - }), - - reducers: ({ selectors }) => ({ - searchQuery: [ - '', - { - setSearchQuery: (_, { searchQuery }) => searchQuery, - selectItem: () => '', - }, - ], - activeTab: [ - (state: any) => { - const type = selectors.filter(state)?.type - return groups.find((g) => g.type === type)?.type || groups[0]?.type - }, - { - setActiveTab: (_, { activeTab }) => activeTab, - }, - ], - dropdownOpen: [ - false, - { - openDropdown: () => true, - closeDropdown: () => false, - }, - ], - mouseInteractionsEnabled: [ - // This fixes a bug with keyboard up/down scrolling when the mouse is over the list. - // Otherwise shifting list elements cause the "hover" action to be triggered randomly. - true, - { - moveUp: () => false, - moveDown: () => false, - setActiveTab: () => true, // reset immediately if clicked on a tab - enableMouseInteractions: () => true, // called 100ms after up/down - }, - ], - }), - - selectors: { - tabs: [() => [], () => groups.map((g) => g.type)], - currentTabIndex: [ - (s) => [s.tabs, s.activeTab], - (tabs, activeTab) => Math.max(tabs.indexOf(activeTab || ''), 0), - ], - filter: [ - (s) => [s.filters, (_, props) => props.filterIndex], - (filters, filterIndex): AnyPropertyFilter | null => filters[filterIndex] || null, - ], - selectedCohortName: [ - (s) => [s.filter, cohortsModel.selectors.cohorts], - (filter, cohorts) => (filter?.type === 'cohort' ? cohorts.find((c) => c.id === filter?.value)?.name : null), - ], - }, - - listeners: ({ actions, values, props }) => ({ - selectItem: ({ type, id, name }) => { - if (type === 'cohort') { - propertyFilterLogic(props).actions.setFilter(props.filterIndex, 'id', id, null, type) - } else { - const operator = - name === '$active_feature_flags' - ? PropertyOperator.IContains - : values.filter?.operator || PropertyOperator.Exact - - propertyFilterLogic(props).actions.setFilter( - props.filterIndex, - name, - null, // Reset value field - operator, - type - ) - } - actions.closeDropdown() - }, - - moveUp: async (_, breakpoint) => { - if (values.activeTab) { - infiniteListLogic({ ...props, type: values.activeTab }).actions.moveUp() - } - await breakpoint(100) - actions.enableMouseInteractions() - }, - - moveDown: async (_, breakpoint) => { - if (values.activeTab) { - infiniteListLogic({ ...props, type: values.activeTab }).actions.moveDown() - } - await breakpoint(100) - actions.enableMouseInteractions() - }, - - selectSelected: async ({ onComplete }, breakpoint) => { - if (values.activeTab) { - infiniteListLogic({ ...props, type: values.activeTab }).actions.selectSelected(onComplete) - } - await breakpoint(100) - actions.enableMouseInteractions() - }, - - tabLeft: () => { - const newIndex = (values.currentTabIndex - 1 + groups.length) % groups.length - actions.setActiveTab(groups[newIndex].type) - }, - - tabRight: () => { - const newIndex = (values.currentTabIndex + 1) % groups.length - actions.setActiveTab(groups[newIndex].type) - }, - }), -}) diff --git a/frontend/src/lib/components/PropertyFilters/components/taxonomicPropertyFilterLogic.ts b/frontend/src/lib/components/PropertyFilters/components/taxonomicPropertyFilterLogic.ts new file mode 100644 index 0000000000000..578375ca27c2c --- /dev/null +++ b/frontend/src/lib/components/PropertyFilters/components/taxonomicPropertyFilterLogic.ts @@ -0,0 +1,72 @@ +import { kea } from 'kea' +import { propertyFilterLogic } from 'lib/components/PropertyFilters/propertyFilterLogic' +import { TaxonomicPropertyFilterLogicProps } from 'lib/components/PropertyFilters/types' +import { AnyPropertyFilter, PropertyFilterValue, PropertyOperator } from '~/types' +import { taxonomicPropertyFilterLogicType } from './taxonomicPropertyFilterLogicType' +import { cohortsModel } from '~/models/cohortsModel' + +export const taxonomicPropertyFilterLogic = kea({ + props: {} as TaxonomicPropertyFilterLogicProps, + key: (props) => `${props.pageKey}-${props.filterIndex}`, + + connect: (props: TaxonomicPropertyFilterLogicProps) => ({ + values: [propertyFilterLogic(props), ['filters']], + }), + + actions: { + selectItem: (propertyType?: string, propertyKey?: PropertyFilterValue) => ({ propertyType, propertyKey }), + openDropdown: true, + closeDropdown: true, + }, + + reducers: { + dropdownOpen: [ + false, + { + openDropdown: () => true, + closeDropdown: () => false, + }, + ], + }, + + selectors: { + filter: [ + (s) => [s.filters, (_, props) => props.filterIndex], + (filters, filterIndex): AnyPropertyFilter | null => filters[filterIndex] || null, + ], + selectedCohortName: [ + (s) => [s.filter, cohortsModel.selectors.cohorts], + (filter, cohorts) => (filter?.type === 'cohort' ? cohorts.find((c) => c.id === filter?.value)?.name : null), + ], + }, + + listeners: ({ actions, values, props }) => ({ + selectItem: ({ propertyType, propertyKey }) => { + if (propertyKey && propertyType) { + if (propertyType === 'cohort') { + propertyFilterLogic(props).actions.setFilter( + props.filterIndex, + 'id', + propertyKey, + null, + propertyType + ) + } else { + const operator = + propertyKey === '$active_feature_flags' + ? PropertyOperator.IContains + : values.filter?.operator || PropertyOperator.Exact + + propertyFilterLogic(props).actions.setFilter( + props.filterIndex, + propertyKey.toString(), + null, // Reset value field + operator, + propertyType + ) + } + actions.closeDropdown() + } + }, + }), +}) diff --git a/frontend/src/lib/components/PropertyFilters/propertyFilterLogic.ts b/frontend/src/lib/components/PropertyFilters/propertyFilterLogic.ts index bd58caf729cf2..c7da97f974995 100644 --- a/frontend/src/lib/components/PropertyFilters/propertyFilterLogic.ts +++ b/frontend/src/lib/components/PropertyFilters/propertyFilterLogic.ts @@ -7,6 +7,8 @@ import { propertyFilterLogicType } from './propertyFilterLogicType' import { AnyPropertyFilter, EmptyPropertyFilter, PropertyFilter } from '~/types' import { isValidPropertyFilter, parseProperties } from 'lib/components/PropertyFilters/utils' import { PropertyFilterLogicProps } from 'lib/components/PropertyFilters/types' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' export const propertyFilterLogic = kea({ props: {} as PropertyFilterLogicProps, @@ -28,10 +30,7 @@ export const propertyFilterLogic = kea({ reducers: ({ props }) => ({ filters: [ - (props.propertyFilters ? parseProperties(props.propertyFilters) : []) as ( - | PropertyFilter - | EmptyPropertyFilter - )[], + props.propertyFilters ? parseProperties(props.propertyFilters) : ([] as AnyPropertyFilter[]), { setFilter: (state, { index, key, value, operator, type }) => { const newFilters = [...state] @@ -65,14 +64,12 @@ export const propertyFilterLogic = kea({ update: () => { const cleanedFilters = [...values.filters].filter(isValidPropertyFilter) - // If the last item has a key, we need to add a new empty filter so the button appears - if ('key' in values.filters[values.filters.length - 1]) { + // if the last filter is used, add an empty filter to get the "new filter" button + if (isValidPropertyFilter(values.filters[values.filters.length - 1])) { actions.newFilter() } + if (props.onChange) { - if (cleanedFilters.length === 0) { - return props.onChange([]) - } props.onChange(cleanedFilters) } else { const { properties, ...searchParams } = router.values.searchParams // eslint-disable-line @@ -111,7 +108,10 @@ export const propertyFilterLogic = kea({ }), selectors: { - filtersLoading: [() => [propertyDefinitionsModel.selectors.loaded], (loaded) => !loaded], + filtersLoading: [ + () => [featureFlagLogic.selectors.featureFlags, propertyDefinitionsModel.selectors.loaded], + (featureFlags, loaded) => !featureFlags[FEATURE_FLAGS.TAXONOMIC_PROPERTY_FILTER] && !loaded, + ], filledFilters: [(s) => [s.filters], (filters) => filters.filter(isValidPropertyFilter)], }, diff --git a/frontend/src/lib/components/PropertyFilters/types.ts b/frontend/src/lib/components/PropertyFilters/types.ts index 45f4facd269b5..747a56d98a0f8 100644 --- a/frontend/src/lib/components/PropertyFilters/types.ts +++ b/frontend/src/lib/components/PropertyFilters/types.ts @@ -12,7 +12,3 @@ export interface PropertyFilterLogicProps extends PropertyFilterBaseProps { export interface TaxonomicPropertyFilterLogicProps extends PropertyFilterBaseProps { filterIndex: number } - -export interface TaxonomicPropertyFilterListLogicProps extends TaxonomicPropertyFilterLogicProps { - type: string -} diff --git a/frontend/src/lib/components/PropertyFilters/utils.ts b/frontend/src/lib/components/PropertyFilters/utils.ts index 4ae093761e018..f839cac9220ed 100644 --- a/frontend/src/lib/components/PropertyFilters/utils.ts +++ b/frontend/src/lib/components/PropertyFilters/utils.ts @@ -1,4 +1,5 @@ import { AnyPropertyFilter, EventDefinition, PropertyFilter, PropertyOperator } from '~/types' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' export function parseProperties( input: AnyPropertyFilter[] | Record | null | undefined @@ -37,3 +38,20 @@ export function filterMatchesItem( } return filter.type === 'cohort' ? filter.value === item.id : filter.key === item.name } + +const propertyFilterMapping: Record = { + person: TaxonomicFilterGroupType.PersonProperties, + event: TaxonomicFilterGroupType.EventProperties, + cohort: TaxonomicFilterGroupType.Cohorts, + element: TaxonomicFilterGroupType.Elements, +} + +export function propertyFilterTypeToTaxonomicFilterType( + filterType?: string | null +): TaxonomicFilterGroupType | undefined { + return filterType && filterType in propertyFilterMapping ? propertyFilterMapping[filterType] : undefined +} + +export function taxonomicFilterTypeToPropertyFilterType(filterType?: TaxonomicFilterGroupType): string | undefined { + return Object.entries(propertyFilterMapping).find(([, v]) => v === filterType)?.[0] +} diff --git a/frontend/src/lib/components/PropertyKeyInfo.tsx b/frontend/src/lib/components/PropertyKeyInfo.tsx index 1ab49ce18f0e9..e749efb094a78 100644 --- a/frontend/src/lib/components/PropertyKeyInfo.tsx +++ b/frontend/src/lib/components/PropertyKeyInfo.tsx @@ -1,7 +1,7 @@ import './PropertyKeyInfo.scss' import React from 'react' import { Popover, Typography } from 'antd' -import { KeyMapping } from '~/types' +import { KeyMapping, PropertyFilterValue } from '~/types' import { ANTD_TOOLTIP_PLACEMENTS } from 'lib/utils' import { TooltipPlacement } from 'antd/es/tooltip' @@ -512,6 +512,53 @@ interface PropertyKeyInfoInterface { ellipsis?: boolean } +export function PropertyKeyTitle({ data }: { data: KeyMapping }): JSX.Element { + return ( + + + {data.label} + + ) +} + +export function PropertyKeyDescription({ data, value }: { data: KeyMapping; value: string }): JSX.Element { + return ( + + {data.examples ? ( + <> + {data.description} +
+
+ + Example: + {data.examples.join(', ')} + + + ) : ( + data.description + )} +
+ Sent as
{value}
+
+ ) +} + +export function getKeyMapping(value: string | PropertyFilterValue, type: 'event' | 'element'): KeyMapping | null { + value = `${value}` // convert to string + let data = null + if (value in keyMapping[type]) { + return { ...keyMapping[type][value] } + } else if (value.startsWith('$initial_') && value.replace(/^\$initial_/, '$') in keyMapping[type]) { + data = { ...keyMapping[type][value.replace(/^\$initial_/, '$')] } + if (data.description) { + data.label = `Initial ${data.label}` + data.description = `${data.description} Data from the first time this user was seen.` + } + return data + } + return null +} + export function PropertyKeyInfo({ value, type = 'event', @@ -522,16 +569,8 @@ export function PropertyKeyInfo({ ellipsis = true, }: PropertyKeyInfoInterface): JSX.Element { value = `${value}` // convert to string - let data = null - if (value in keyMapping[type]) { - data = { ...keyMapping[type][value] } - } else if (value.startsWith('$initial_') && value.replace(/^\$initial_/, '$') in keyMapping[type]) { - data = { ...keyMapping[type][value.replace(/^\$initial_/, '$')] } - if (data.description) { - data.label = `Initial ${data.label}` - data.description = `${data.description} Data from the first time this user was seen.` - } - } else { + const data = getKeyMapping(value, type) + if (!data) { return ( {value !== '' ? value : (empty string)} @@ -553,32 +592,8 @@ export function PropertyKeyInfo({
) - const popoverTitle = ( - - - {data.label} - - ) - - const popoverContent = ( - - {data.examples ? ( - <> - {data.description} -
-
- - Example: - {data.examples.join(', ')} - - - ) : ( - data.description - )} -
- Sent as
{value}
-
- ) + const popoverTitle = + const popoverContent = return disablePopover ? ( innerContent diff --git a/frontend/src/lib/components/TaxonomicFilter/InfiniteList.scss b/frontend/src/lib/components/TaxonomicFilter/InfiniteList.scss new file mode 100644 index 0000000000000..9b26bd9e4ef90 --- /dev/null +++ b/frontend/src/lib/components/TaxonomicFilter/InfiniteList.scss @@ -0,0 +1,38 @@ +.taxonomic-infinite-list { + min-height: 200px; + + &.empty-infinite-list { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + .no-infinite-results { + color: #666; + } + } + + .taxonomic-list-row { + display: flex; + align-items: center; + justify-content: space-between; + color: #2d2d2d; + padding: 4px 12px; + cursor: pointer; + border: none; + + &.hover { + background: rgba(0, 0, 0, 0.1); + } + + &.selected { + font-weight: bold; + } + + &.skeleton-row { + // center-align this antd skeleton + .ant-skeleton-paragraph { + margin-bottom: 0; + } + } + } +} diff --git a/frontend/src/lib/components/TaxonomicFilter/InfiniteList.tsx b/frontend/src/lib/components/TaxonomicFilter/InfiniteList.tsx new file mode 100644 index 0000000000000..2d629df9a0478 --- /dev/null +++ b/frontend/src/lib/components/TaxonomicFilter/InfiniteList.tsx @@ -0,0 +1,254 @@ +import './InfiniteList.scss' +import '../Popup/Popup.scss' +import React, { useState } from 'react' +import { Empty, Skeleton } from 'antd' +import { AutoSizer, List, ListRowProps, ListRowRenderer } from 'react-virtualized' +import { + getKeyMapping, + PropertyKeyDescription, + PropertyKeyInfo, + PropertyKeyTitle, +} from 'lib/components/PropertyKeyInfo' +import { useActions, useValues } from 'kea' +import { infiniteListLogic } from './infiniteListLogic' +import { taxonomicFilterLogic } from 'lib/components/TaxonomicFilter/taxonomicFilterLogic' +import { TaxonomicFilterGroup, TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import ReactDOM from 'react-dom' +import { usePopper } from 'react-popper' +import { ActionType, CohortType, KeyMapping, PropertyDefinition } from '~/types' +import { AimOutlined } from '@ant-design/icons' +import { Link } from 'lib/components/Link' +import { ActionSelectInfo } from 'scenes/insights/ActionSelectInfo' + +enum ListTooltip { + None = 0, + Left = 1, + Right = 2, +} + +export function tooltipDesiredState(element?: Element | null): ListTooltip { + let desiredState: ListTooltip = ListTooltip.None + const rect = element?.getBoundingClientRect() + if (rect) { + if (window.innerWidth - rect.right > 300) { + desiredState = ListTooltip.Right + } else if (rect.left > 300) { + desiredState = ListTooltip.Left + } + } + return desiredState +} + +const renderItemContents = ({ + item, + listGroupType, +}: { + item: PropertyDefinition | CohortType + listGroupType: TaxonomicFilterGroupType +}): JSX.Element | string => { + return listGroupType === TaxonomicFilterGroupType.EventProperties || + listGroupType === TaxonomicFilterGroupType.PersonProperties || + listGroupType === TaxonomicFilterGroupType.Events ? ( + + ) : listGroupType === TaxonomicFilterGroupType.Elements ? ( + + ) : ( + item.name ?? '' + ) +} + +const renderItemPopup = ( + item: PropertyDefinition | CohortType | ActionType, + listGroupType: TaxonomicFilterGroupType, + group: TaxonomicFilterGroup +): JSX.Element | string => { + const width = 265 + let data: KeyMapping | null = null + const value = group.getValue(item) + + if (value) { + if (listGroupType === TaxonomicFilterGroupType.Actions && 'id' in item) { + return ( +
+ Actions + + edit + +
+

+ +

+ {item && } +
+ ) + } + + if ( + // NB: also update "selectedItemHasPopup" below + listGroupType === TaxonomicFilterGroupType.Events || + listGroupType === TaxonomicFilterGroupType.EventProperties || + listGroupType === TaxonomicFilterGroupType.PersonProperties + ) { + data = getKeyMapping(value.toString(), 'event') + } else if (listGroupType === TaxonomicFilterGroupType.Elements) { + data = getKeyMapping(value.toString(), 'element') + } + + if (data) { + return ( +
+ + {data.description ?
: null} + + + {'volume_30_day' in item && (item.volume_30_day || 0) > 0 ? ( +

+ Seen {item.volume_30_day} times.{' '} +

+ ) : null} + {'query_usage_30_day' in item && (item.query_usage_30_day || 0) > 0 ? ( +

+ Used in {item.query_usage_30_day} queries. +

+ ) : null} +
+ ) + } + } + + return item.name ?? '' +} + +const selectedItemHasPopup = ( + item?: PropertyDefinition | CohortType, + listGroupType?: TaxonomicFilterGroupType, + group?: TaxonomicFilterGroup +): boolean => { + return ( + // NB: also update "renderItemPopup" above + !!item && + !!group?.getValue(item) && + (listGroupType === TaxonomicFilterGroupType.Actions || + ((listGroupType === TaxonomicFilterGroupType.Elements || + listGroupType === TaxonomicFilterGroupType.Events || + listGroupType === TaxonomicFilterGroupType.EventProperties || + listGroupType === TaxonomicFilterGroupType.PersonProperties) && + !!getKeyMapping( + group?.getValue(item), + listGroupType === TaxonomicFilterGroupType.Elements ? 'element' : 'event' + ))) + ) +} + +export function InfiniteList(): JSX.Element { + const { mouseInteractionsEnabled, activeTab, searchQuery, value, groupType } = useValues(taxonomicFilterLogic) + const { selectItem } = useActions(taxonomicFilterLogic) + + const { isLoading, results, totalCount, index, listGroupType, group, selectedItem, selectedItemInView } = useValues( + infiniteListLogic + ) + const { onRowsRendered, setIndex } = useActions(infiniteListLogic) + + const isActiveTab = listGroupType === activeTab + const showEmptyState = totalCount === 0 && !isLoading + + const [referenceElement, setReferenceElement] = useState(null) + const [popperElement, setPopperElement] = useState(null) + + const { styles, attributes } = usePopper(referenceElement, popperElement, { + placement: 'right', + + modifiers: [ + { + name: 'offset', + options: { + offset: [0, 10], + }, + }, + ], + }) + + const renderItem: ListRowRenderer = ({ index: rowIndex, style }: ListRowProps): JSX.Element | null => { + const item = results[rowIndex] + const itemValue = item ? group?.getValue?.(item) : null + const isSelected = listGroupType === groupType && itemValue === value + const isHighlighted = rowIndex === index && isActiveTab + + return item ? ( +
selectItem(listGroupType, itemValue ?? null, item)} + onMouseOver={() => (mouseInteractionsEnabled ? setIndex(rowIndex) : null)} + style={style} + data-attr={`prop-filter-${groupType}-${rowIndex}`} + ref={isHighlighted ? setReferenceElement : null} + > + {renderItemContents({ item, listGroupType })} +
+ ) : ( +
mouseInteractionsEnabled && setIndex(rowIndex)} + style={style} + data-attr={`prop-filter-${groupType}-${rowIndex}`} + > + +
+ ) + } + + return ( +
+ {showEmptyState ? ( +
+ + No results for "{searchQuery}" + + } + /> +
+ ) : ( + + {({ height, width }) => ( + + )} + + )} + {isActiveTab && + selectedItemInView && + selectedItemHasPopup(selectedItem, listGroupType, group) && + tooltipDesiredState(referenceElement) !== ListTooltip.None + ? ReactDOM.createPortal( +
+ {selectedItem && group ? renderItemPopup(selectedItem, listGroupType, group) : null} +
, + document.querySelector('body') as HTMLElement + ) + : null} +
+ ) +} diff --git a/frontend/src/lib/components/TaxonomicFilter/InfiniteSelectResults.tsx b/frontend/src/lib/components/TaxonomicFilter/InfiniteSelectResults.tsx new file mode 100644 index 0000000000000..4e2d724af074f --- /dev/null +++ b/frontend/src/lib/components/TaxonomicFilter/InfiniteSelectResults.tsx @@ -0,0 +1,75 @@ +import React from 'react' +import { Tabs, Tag } from 'antd' +import { BindLogic, useActions, useValues } from 'kea' +import { taxonomicFilterLogic } from './taxonomicFilterLogic' +import { infiniteListLogic } from 'lib/components/TaxonomicFilter/infiniteListLogic' +import { groups } from 'lib/components/TaxonomicFilter/groups' +import { InfiniteList } from 'lib/components/TaxonomicFilter/InfiniteList' +import { TaxonomicFilterGroupType, TaxonomicFilterLogicProps } from 'lib/components/TaxonomicFilter/types' + +export interface InfiniteSelectResultsProps { + focusInput: () => void + taxonomicFilterLogicProps: TaxonomicFilterLogicProps +} + +function TabTitle({ + groupType, + taxonomicFilterLogicProps, +}: { + groupType: TaxonomicFilterGroupType + taxonomicFilterLogicProps: TaxonomicFilterLogicProps +}): JSX.Element { + const logic = infiniteListLogic({ ...taxonomicFilterLogicProps, listGroupType: groupType }) + const { totalCount } = useValues(logic) + + const group = groups.find((g) => g.type === groupType) + return ( + <> + {group?.name} {totalCount != null && {totalCount}} + + ) +} + +export function InfiniteSelectResults({ + focusInput, + taxonomicFilterLogicProps, +}: InfiniteSelectResultsProps): JSX.Element { + const { activeTab, groupTypes } = useValues(taxonomicFilterLogic) + const { setActiveTab } = useActions(taxonomicFilterLogic) + + if (groupTypes.length === 1) { + return ( + + + + ) + } + + return ( + { + setActiveTab(value as TaxonomicFilterGroupType) + focusInput() + }} + tabPosition="top" + animated={false} + > + {groupTypes.map((groupType) => { + return ( + } + > + + + + + ) + })} + + ) +} diff --git a/frontend/src/lib/components/TaxonomicFilter/TaxonomicFilter.scss b/frontend/src/lib/components/TaxonomicFilter/TaxonomicFilter.scss new file mode 100644 index 0000000000000..0582f5f706931 --- /dev/null +++ b/frontend/src/lib/components/TaxonomicFilter/TaxonomicFilter.scss @@ -0,0 +1,10 @@ +.taxonomic-filter { + min-width: 300px; + width: 550px; + max-width: calc(100vw - 40px); + background: white; + + .ant-tabs-tab { + margin-right: 16px; + } +} diff --git a/frontend/src/lib/components/TaxonomicFilter/TaxonomicFilter.tsx b/frontend/src/lib/components/TaxonomicFilter/TaxonomicFilter.tsx new file mode 100644 index 0000000000000..f156eaea1a98d --- /dev/null +++ b/frontend/src/lib/components/TaxonomicFilter/TaxonomicFilter.tsx @@ -0,0 +1,90 @@ +import './TaxonomicFilter.scss' +import React, { useEffect, useMemo, useRef } from 'react' +import { Input } from 'antd' +import { useValues, useActions, BindLogic } from 'kea' +import { InfiniteSelectResults } from './InfiniteSelectResults' +import { taxonomicFilterLogic } from './taxonomicFilterLogic' +import { + TaxonomicFilterGroupType, + TaxonomicFilterLogicProps, + TaxonomicFilterProps, +} from 'lib/components/TaxonomicFilter/types' + +let uniqueMemoizedIndex = 0 + +export function TaxonomicFilter({ + taxonomicFilterLogicKey: taxonomicFilterLogicKeyInput, + groupType, + value, + onChange, + onClose, + groupTypes = [ + TaxonomicFilterGroupType.EventProperties, + TaxonomicFilterGroupType.PersonProperties, + TaxonomicFilterGroupType.Cohorts, + ], +}: TaxonomicFilterProps): JSX.Element { + // Generate a unique key for each unique TaxonomicFilter that's rendered + const taxonomicFilterLogicKey = useMemo( + () => taxonomicFilterLogicKeyInput || `taxonomic-filter-${uniqueMemoizedIndex++}`, + [taxonomicFilterLogicKeyInput] + ) + + const searchInputRef = useRef(null) + const focusInput = (): void => searchInputRef.current?.focus() + + const taxonomicFilterLogicProps: TaxonomicFilterLogicProps = { + taxonomicFilterLogicKey, + groupType, + value, + onChange, + groupTypes, + } + const logic = taxonomicFilterLogic(taxonomicFilterLogicProps) + const { searchQuery } = useValues(logic) + const { setSearchQuery, moveUp, moveDown, tabLeft, tabRight, selectSelected } = useActions(logic) + + useEffect(() => { + window.setTimeout(() => focusInput(), 1) + }, []) + + return ( + +
+ (searchInputRef.current = ref)} + onChange={(e) => setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'ArrowUp') { + e.preventDefault() + moveUp() + } + if (e.key === 'ArrowDown') { + e.preventDefault() + moveDown() + } + if (e.key === 'Tab') { + e.preventDefault() + if (e.shiftKey) { + tabLeft() + } else { + tabRight() + } + } + if (e.key === 'Enter') { + e.preventDefault() + selectSelected() + } + if (e.key === 'Escape') { + e.preventDefault() + onClose?.() + } + }} + /> + +
+
+ ) +} diff --git a/frontend/src/lib/components/TaxonomicFilter/groups.ts b/frontend/src/lib/components/TaxonomicFilter/groups.ts new file mode 100644 index 0000000000000..f49323bfd7253 --- /dev/null +++ b/frontend/src/lib/components/TaxonomicFilter/groups.ts @@ -0,0 +1,66 @@ +import { personPropertiesModel } from '~/models/personPropertiesModel' +import { + ActionType, + CohortType, + EventDefinition, + PersonProperty, + PropertyDefinition, + PropertyFilterValue, +} from '~/types' +import { cohortsModel } from '~/models/cohortsModel' +import { TaxonomicFilterGroup, TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import { actionsModel } from '~/models/actionsModel' + +type SimpleOption = { + name: string +} + +export const groups: TaxonomicFilterGroup[] = [ + { + name: 'Events', + type: TaxonomicFilterGroupType.Events, + endpoint: 'api/projects/@current/event_definitions', + getName: (eventDefinition: EventDefinition): string => eventDefinition.name, + getValue: (eventDefinition: EventDefinition): PropertyFilterValue => eventDefinition.name, + }, + { + name: 'Actions', + type: TaxonomicFilterGroupType.Actions, + logic: actionsModel, + value: 'actions', + getName: (action: ActionType): string => action.name, + getValue: (action: ActionType): PropertyFilterValue => action.id, + }, + { + name: 'Elements', + type: TaxonomicFilterGroupType.Elements, + options: ['tag_name', 'text', 'href', 'selector'].map((option) => ({ + name: option, + })) as SimpleOption[], + getName: (option: SimpleOption): string => option.name, + getValue: (option: SimpleOption): PropertyFilterValue => option.name, + }, + { + name: 'Event properties', + type: TaxonomicFilterGroupType.EventProperties, + endpoint: 'api/projects/@current/property_definitions', + getName: (propertyDefinition: PropertyDefinition): string => propertyDefinition.name, + getValue: (propertyDefinition: PropertyDefinition): PropertyFilterValue => propertyDefinition.name, + }, + { + name: 'Person properties', + type: TaxonomicFilterGroupType.PersonProperties, + logic: personPropertiesModel, + value: 'personProperties', + getName: (personProperty: PersonProperty): string => personProperty.name, + getValue: (personProperty: PersonProperty): PropertyFilterValue => personProperty.name, + }, + { + name: 'Cohorts', + type: TaxonomicFilterGroupType.Cohorts, + logic: cohortsModel, + value: 'cohorts', + getName: (cohort: CohortType): string => cohort.name || `Cohort #${cohort.id}`, + getValue: (cohort: CohortType): PropertyFilterValue => cohort.id, + }, +] diff --git a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/infiniteListLogic.ts b/frontend/src/lib/components/TaxonomicFilter/infiniteListLogic.ts similarity index 71% rename from frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/infiniteListLogic.ts rename to frontend/src/lib/components/TaxonomicFilter/infiniteListLogic.ts index 8a20b936c6c01..b62c4a61acd82 100644 --- a/frontend/src/lib/components/PropertyFilters/components/TaxonomicPropertyFilter/infiniteListLogic.ts +++ b/frontend/src/lib/components/TaxonomicFilter/infiniteListLogic.ts @@ -4,14 +4,14 @@ import api from 'lib/api' import { RenderedRows } from 'react-virtualized/dist/es/List' import { EventDefinitionStorage } from '~/models/eventDefinitionsModel' import { infiniteListLogicType } from './infiniteListLogicType' -import { TaxonomicPropertyFilterListLogicProps } from 'lib/components/PropertyFilters/types' -import { groups } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter/groups' -import { taxonomicPropertyFilterLogic } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter/taxonomicPropertyFilterLogic' -import { EventDefinition } from '~/types' +import { CohortType, EventDefinition } from '~/types' import Fuse from 'fuse.js' +import { InfiniteListLogicProps } from 'lib/components/TaxonomicFilter/types' +import { taxonomicFilterLogic } from 'lib/components/TaxonomicFilter/taxonomicFilterLogic' +import { groups } from 'lib/components/TaxonomicFilter/groups' interface ListStorage { - results: EventDefinition[] + results: (EventDefinition | CohortType)[] searchQuery?: string // Query used for the results currently in state count: number queryChanged?: boolean @@ -23,7 +23,10 @@ interface LoaderOptions { limit: number } -type ListFuse = Fuse // local alias for typegen +type ListFuse = Fuse<{ + name: string + item: EventDefinition | CohortType +}> // local alias for typegen function appendAtIndex(array: T[], items: any[], startIndex?: number): T[] { if (startIndex === undefined) { @@ -49,17 +52,17 @@ const apiCache: Record = {} const apiCacheTimers: Record = {} export const infiniteListLogic = kea>({ - props: {} as TaxonomicPropertyFilterListLogicProps, + props: {} as InfiniteListLogicProps, - key: (props) => `${props.pageKey}-${props.filterIndex}-${props.type}`, + key: (props) => `${props.taxonomicFilterLogicKey}-${props.listGroupType}`, - connect: (props: TaxonomicPropertyFilterListLogicProps) => ({ - values: [taxonomicPropertyFilterLogic(props), ['searchQuery', 'filter']], - actions: [taxonomicPropertyFilterLogic(props), ['setSearchQuery', 'selectItem as selectFilterItem']], + connect: (props: InfiniteListLogicProps) => ({ + values: [taxonomicFilterLogic(props), ['searchQuery', 'value', 'groupType']], + actions: [taxonomicFilterLogic(props), ['setSearchQuery', 'selectItem']], }), actions: { - selectSelected: (onComplete?: () => void) => ({ onComplete }), + selectSelected: true, moveUp: true, moveDown: true, setIndex: (index: number) => ({ index }), @@ -82,6 +85,8 @@ export const infiniteListLogic = kea limit, }, ], + startIndex: [0, { onRowsRendered: (_, { rowInfo: { startIndex } }) => startIndex }], + stopIndex: [0, { onRowsRendered: (_, { rowInfo: { stopIndex } }) => stopIndex }], }, loaders: ({ values }) => ({ @@ -164,46 +169,52 @@ export const infiniteListLogic = kea { - const item = values.selectedItem - if (item) { - actions.selectFilterItem(props.type, item.id, item.name) - onComplete?.() - } + selectSelected: () => { + actions.selectItem(props.listGroupType, values.selectedItemValue, values.selectedItem) }, }), selectors: { + listGroupType: [() => [(_, props) => props.listGroupType], (listGroupType) => listGroupType], isLoading: [(s) => [s.remoteItemsLoading], (remoteItemsLoading) => remoteItemsLoading], - group: [() => [(_, props) => props.type], (type) => groups.find((g) => g.type === type)], + group: [(s) => [s.listGroupType], (listGroupType) => groups.find((g) => g.type === listGroupType)], remoteEndpoint: [(s) => [s.group], (group) => group?.endpoint || null], isRemoteDataSource: [(s) => [s.remoteEndpoint], (remoteEndpoint) => !!remoteEndpoint], rawLocalItems: [ () => [ (state, props) => { - const group = groups.find((g) => g.type === props.type) + const group = groups.find((g) => g.type === props.listGroupType) if (group?.logic && group?.value) { return group.logic.selectors[group.value]?.(state) || null } + if (group?.options) { + return group.options + } return null }, ], - (rawLocalItems: EventDefinition[]) => rawLocalItems, + (rawLocalItems: (EventDefinition | CohortType)[]) => rawLocalItems, ], fuse: [ - (s) => [s.rawLocalItems], - (rawLocalItems): ListFuse => - new Fuse(rawLocalItems || [], { - keys: ['name'], - threshold: 0.3, - }), + (s) => [s.rawLocalItems, s.group], + (rawLocalItems, group): ListFuse => + new Fuse( + (rawLocalItems || []).map((item) => ({ + name: group?.getName?.(item) || '', + item: item, + })), + { + keys: ['name'], + threshold: 0.3, + } + ), ], localItems: [ - (s) => [s.rawLocalItems, s.group, s.searchQuery, s.fuse], - (rawLocalItems, group, searchQuery, fuse): ListStorage => { + (s) => [s.rawLocalItems, s.searchQuery, s.fuse], + (rawLocalItems, searchQuery, fuse): ListStorage => { if (rawLocalItems) { const filteredItems = searchQuery - ? fuse.search(searchQuery).map((result) => (group?.map ? group.map(result.item) : result.item)) + ? fuse.search(searchQuery).map((result) => result.item.item) : rawLocalItems return { @@ -222,23 +233,23 @@ export const infiniteListLogic = kea [s.items], (items) => items.count || 0], results: [(s) => [s.items], (items) => items.results], selectedItem: [(s) => [s.index, s.items], (index, items) => (index >= 0 ? items.results[index] : undefined)], + selectedItemValue: [ + (s) => [s.selectedItem, s.group], + (selectedItem, group) => group?.getValue?.(selectedItem) || null, + ], + selectedItemInView: [ + (s) => [s.index, s.startIndex, s.stopIndex], + (index, startIndex, stopIndex) => typeof index === 'number' && index >= startIndex && index <= stopIndex, + ], }, events: ({ actions, values, props }) => ({ afterMount: () => { if (values.isRemoteDataSource) { actions.loadRemoteItems({ offset: 0, limit: values.limit }) - } else if (values.filter?.type === props.type) { - const { - filter: { key, value }, - results, - } = values - - if (props.type === 'cohort') { - actions.setIndex(results.findIndex((r) => r.id === value)) - } else { - actions.setIndex(results.findIndex((r) => r.name === key)) - } + } else if (values.groupType === props.listGroupType) { + const { value, group, results } = values + actions.setIndex(results.findIndex((r) => group?.getValue?.(r) === value)) } }, }), diff --git a/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.ts b/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.ts new file mode 100644 index 0000000000000..6c7bc1decad6f --- /dev/null +++ b/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.ts @@ -0,0 +1,123 @@ +import { kea } from 'kea' +import { PropertyFilterValue } from '~/types' +import { taxonomicFilterLogicType } from './taxonomicFilterLogicType' +import { TaxonomicFilterGroupType, TaxonomicFilterLogicProps } from 'lib/components/TaxonomicFilter/types' +import { infiniteListLogic } from 'lib/components/TaxonomicFilter/infiniteListLogic' + +export const taxonomicFilterLogic = kea({ + props: {} as TaxonomicFilterLogicProps, + key: (props) => `${props.taxonomicFilterLogicKey}`, + + actions: () => ({ + moveUp: true, + moveDown: true, + selectSelected: (onComplete?: () => void) => ({ onComplete }), + enableMouseInteractions: true, + tabLeft: true, + tabRight: true, + setSearchQuery: (searchQuery: string) => ({ searchQuery }), + setActiveTab: (activeTab: TaxonomicFilterGroupType) => ({ activeTab }), + selectItem: (groupType: TaxonomicFilterGroupType, value: PropertyFilterValue, item: any) => ({ + groupType, + value, + item, + }), + }), + + reducers: ({ selectors }) => ({ + searchQuery: [ + '', + { + setSearchQuery: (_, { searchQuery }) => searchQuery, + selectItem: () => '', + }, + ], + activeTab: [ + (state: any): TaxonomicFilterGroupType => { + return selectors.groupType(state) || selectors.groupTypes(state)[0] + }, + { + setActiveTab: (_, { activeTab }) => activeTab, + }, + ], + mouseInteractionsEnabled: [ + // This fixes a bug with keyboard up/down scrolling when the mouse is over the list. + // Otherwise shifting list elements cause the "hover" action to be triggered randomly. + true, + { + moveUp: () => false, + moveDown: () => false, + setActiveTab: () => true, + enableMouseInteractions: () => true, + }, + ], + }), + + selectors: { + taxonomicFilterLogicKey: [ + () => [(_, props) => props.taxonomicFilterLogicKey], + (taxonomicFilterLogicKey) => taxonomicFilterLogicKey, + ], + groupTypes: [ + () => [(_, props) => props.groupTypes], + (groupTypes): TaxonomicFilterGroupType[] => groupTypes || [], + ], + value: [() => [(_, props) => props.value], (value) => value], + groupType: [() => [(_, props) => props.groupType], (groupType) => groupType], + currentTabIndex: [ + (s) => [s.groupTypes, s.activeTab], + (groupTypes, activeTab) => Math.max(groupTypes.indexOf(activeTab || ''), 0), + ], + }, + + listeners: ({ actions, values, props }) => ({ + selectItem: ({ groupType, value, item }) => { + props.onChange?.(groupType, value, item) + }, + + moveUp: async (_, breakpoint) => { + if (values.activeTab) { + infiniteListLogic({ + ...props, + listGroupType: values.activeTab, + }).actions.moveUp() + } + await breakpoint(100) + actions.enableMouseInteractions() + }, + + moveDown: async (_, breakpoint) => { + if (values.activeTab) { + infiniteListLogic({ + ...props, + listGroupType: values.activeTab, + }).actions.moveDown() + } + await breakpoint(100) + actions.enableMouseInteractions() + }, + + selectSelected: async (_, breakpoint) => { + if (values.activeTab) { + infiniteListLogic({ + ...props, + listGroupType: values.activeTab, + }).actions.selectSelected() + } + await breakpoint(100) + actions.enableMouseInteractions() + }, + + tabLeft: () => { + const { currentTabIndex, groupTypes } = values + const newIndex = (currentTabIndex - 1 + groupTypes.length) % groupTypes.length + actions.setActiveTab(groupTypes[newIndex]) + }, + + tabRight: () => { + const { currentTabIndex, groupTypes } = values + const newIndex = (currentTabIndex + 1) % groupTypes.length + actions.setActiveTab(groupTypes[newIndex]) + }, + }), +}) diff --git a/frontend/src/lib/components/TaxonomicFilter/types.ts b/frontend/src/lib/components/TaxonomicFilter/types.ts new file mode 100644 index 0000000000000..0cdc55f2d8ccb --- /dev/null +++ b/frontend/src/lib/components/TaxonomicFilter/types.ts @@ -0,0 +1,40 @@ +import { LogicWrapper } from 'kea' +import { PropertyFilterValue } from '~/types' + +export interface TaxonomicFilterProps { + groupType?: TaxonomicFilterGroupType + value?: PropertyFilterValue + onChange?: (groupType: TaxonomicFilterGroupType, value: PropertyFilterValue, item: any) => void + onClose?: () => void + groupTypes?: string[] + taxonomicFilterLogicKey?: string +} + +export interface TaxonomicFilterLogicProps extends TaxonomicFilterProps { + taxonomicFilterLogicKey: string +} + +export interface TaxonomicFilterGroup { + name: string + type: TaxonomicFilterGroupType + endpoint?: string + options?: Record[] + logic?: LogicWrapper + value?: string + getName: (object: any) => string + getValue: (object: any) => PropertyFilterValue +} + +export enum TaxonomicFilterGroupType { + Actions = 'actions', + Cohorts = 'cohorts', + Elements = 'elements', + Events = 'events', + EventProperties = 'event_properties', + PersonProperties = 'person_properties', +} + +export interface InfiniteListLogicProps { + taxonomicFilterLogicKey: string + listGroupType: TaxonomicFilterGroupType +} diff --git a/frontend/src/lib/hooks/useOutsideClickHandler.ts b/frontend/src/lib/hooks/useOutsideClickHandler.ts index 147db9d929909..2932203a709af 100644 --- a/frontend/src/lib/hooks/useOutsideClickHandler.ts +++ b/frontend/src/lib/hooks/useOutsideClickHandler.ts @@ -1,13 +1,13 @@ import { useEffect } from 'react' -const exceptions = ['.ant-select-dropdown *'] +const exceptions = ['.ant-select-dropdown *', '.click-outside-block', '.click-outside-block *'] export function useOutsideClickHandler( refOrRefs: Element | null | (Element | null)[], - handleClickOutside?: () => void, + handleClickOutside?: (event: Event) => void, extraDeps: any[] = [] ): void { - const allRefs = Array.isArray(refOrRefs) ? refOrRefs : [refOrRefs] + const allRefs = (Array.isArray(refOrRefs) ? refOrRefs : [refOrRefs]).map((f) => f) useEffect(() => { function handleClick(event: Event): void { @@ -17,7 +17,7 @@ export function useOutsideClickHandler( if (allRefs.some((ref) => ref?.contains(event.target as Element))) { return } - handleClickOutside?.() + handleClickOutside?.(event) } if (allRefs.length > 0) { diff --git a/frontend/src/scenes/insights/ActionFilter/ActionFilterRow/index.tsx b/frontend/src/scenes/insights/ActionFilter/ActionFilterRow/index.tsx index c518986c32faf..8248e0cf14c94 100644 --- a/frontend/src/scenes/insights/ActionFilter/ActionFilterRow/index.tsx +++ b/frontend/src/scenes/insights/ActionFilter/ActionFilterRow/index.tsx @@ -1,11 +1,11 @@ import React, { useRef } from 'react' import { useActions, useValues } from 'kea' -import { Button, Tooltip, Col, Row, Select } from 'antd' -import { ActionFilter, EntityTypes, PropertyFilter, SelectOption } from '~/types' +import { Button, Col, Row, Select, Tooltip } from 'antd' +import { ActionFilter, EntityType, EntityTypes, PropertyFilter, PropertyFilterValue, SelectOption } from '~/types' import { ActionFilterDropdown } from './ActionFilterDropdown' import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters' -import { PROPERTY_MATH_TYPE, EVENT_MATH_TYPE, MATHS } from 'lib/constants' -import { DownOutlined, DeleteOutlined, FilterOutlined, CloseSquareOutlined } from '@ant-design/icons' +import { EVENT_MATH_TYPE, FEATURE_FLAGS, MATHS, PROPERTY_MATH_TYPE } from 'lib/constants' +import { CloseSquareOutlined, DeleteOutlined, DownOutlined, FilterOutlined } from '@ant-design/icons' import { SelectGradientOverflow } from 'lib/components/SelectGradientOverflow' import { BareEntity, entityFilterLogic } from '../entityFilterLogic' import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' @@ -13,8 +13,11 @@ import { preflightLogic } from 'scenes/PreflightCheck/logic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel' import { pluralize } from 'lib/utils' -import { SeriesLetter, SeriesGlyph } from 'lib/components/SeriesGlyph' +import { SeriesGlyph, SeriesLetter } from 'lib/components/SeriesGlyph' import './index.scss' +import { Popup } from 'lib/components/Popup/Popup' +import { TaxonomicFilter } from 'lib/components/TaxonomicFilter/TaxonomicFilter' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' const EVENT_MATH_ENTRIES = Object.entries(MATHS).filter(([, item]) => item.type == EVENT_MATH_TYPE) const PROPERTY_MATH_ENTRIES = Object.entries(MATHS).filter(([, item]) => item.type == PROPERTY_MATH_TYPE) @@ -65,6 +68,7 @@ export function ActionFilterRow({ const node = useRef(null) const { selectedFilter, entities, entityFilterVisible } = useValues(logic) const { + updateFilter, selectFilter, updateFilterMath, removeLocalFilter, @@ -72,10 +76,11 @@ export function ActionFilterRow({ setEntityFilterVisibility, } = useActions(logic) const { numericalPropertyNames } = useValues(propertyDefinitionsModel) + const { featureFlags } = useValues(featureFlagLogic) const visible = typeof filter.order === 'number' ? entityFilterVisible[filter.order] : false - let entity, name, value + let entity: BareEntity, name: string | null | undefined, value: PropertyFilterValue const { math, math_property: mathProperty } = filter const onClose = (): void => { @@ -155,29 +160,76 @@ export function ActionFilterRow({ style={fullWidth ? {} : { maxWidth: `calc(${hideMathSelector ? '100' : '50'}% - 16px)` }} flex={fullWidth ? 'auto' : undefined} > - - selectFilter(null)} - /> + {featureFlags[FEATURE_FLAGS.TAXONOMIC_PROPERTY_FILTER] ? ( + { + updateFilter({ + type: taxonomicFilterGroupTypeToEntityType(groupType) || undefined, + id: `${changedValue}`, + name: item?.name, + index, + }) + }} + onClose={() => selectFilter(null)} + groupTypes={[TaxonomicFilterGroupType.Events, TaxonomicFilterGroupType.Actions]} + /> + } + visible={dropDownCondition} + onClickOutside={() => selectFilter(null)} + > + {({ setRef }) => ( + + )} + + ) : ( + <> + + selectFilter(null)} + /> + + )} {!hideMathSelector && ( <> @@ -434,3 +486,14 @@ function MathPropertySelector(props: MathPropertySelectorProps): JSX.Element { ) } + +const taxonomicFilterGroupTypeToEntityTypeMapping: Partial> = { + [TaxonomicFilterGroupType.Events]: EntityTypes.EVENTS, + [TaxonomicFilterGroupType.Actions]: EntityTypes.ACTIONS, +} + +export function taxonomicFilterGroupTypeToEntityType( + taxonomicFilterGroupType: TaxonomicFilterGroupType +): EntityType | null { + return taxonomicFilterGroupTypeToEntityTypeMapping[taxonomicFilterGroupType] || null +} diff --git a/frontend/src/scenes/insights/BreakdownFilter/BreakdownFilter.js b/frontend/src/scenes/insights/BreakdownFilter/BreakdownFilter.js index 31fe010f34bae..4e64598684d10 100644 --- a/frontend/src/scenes/insights/BreakdownFilter/BreakdownFilter.js +++ b/frontend/src/scenes/insights/BreakdownFilter/BreakdownFilter.js @@ -7,6 +7,9 @@ import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel' import { cohortsModel } from '~/models/cohortsModel' import { personPropertiesModel } from '~/models/personPropertiesModel' import { ViewType } from '~/types' +import { TaxonomicBreakdownFilter } from 'scenes/insights/BreakdownFilter/TaxonomicBreakdownFilter' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' const { TabPane } = Tabs @@ -125,7 +128,7 @@ function Content({ breakdown, breakdown_type, onChange }) { ) } -export function BreakdownFilter({ filters, onChange }) { +export function OriginalBreakdownFilter({ filters, onChange }) { const { cohorts } = useValues(cohortsModel) const { breakdown, breakdown_type, insight } = filters let [open, setOpen] = useState(false) @@ -177,3 +180,12 @@ export function BreakdownFilter({ filters, onChange }) { ) } + +export function BreakdownFilter(props) { + const { featureFlags } = useValues(featureFlagLogic) + if (featureFlags[FEATURE_FLAGS.TAXONOMIC_PROPERTY_FILTER]) { + return + } else { + return + } +} diff --git a/frontend/src/scenes/insights/BreakdownFilter/TaxonomicBreakdownFilter.tsx b/frontend/src/scenes/insights/BreakdownFilter/TaxonomicBreakdownFilter.tsx new file mode 100644 index 0000000000000..18f050eea66a2 --- /dev/null +++ b/frontend/src/scenes/insights/BreakdownFilter/TaxonomicBreakdownFilter.tsx @@ -0,0 +1,77 @@ +import { useValues } from 'kea' +import { cohortsModel } from '~/models/cohortsModel' +import React, { useState } from 'react' +import { Button, Tooltip } from 'antd' +import { BreakdownType, FilterType, ViewType } from '~/types' +import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' +import { Popup } from 'lib/components/Popup/Popup' +import { TaxonomicFilter } from 'lib/components/TaxonomicFilter/TaxonomicFilter' +import { + propertyFilterTypeToTaxonomicFilterType, + taxonomicFilterTypeToPropertyFilterType, +} from 'lib/components/PropertyFilters/utils' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' + +export interface TaxonomicBreakdownFilterProps { + filters: Partial + onChange: (breakdown: string, breakdownType: BreakdownType) => void +} + +export function TaxonomicBreakdownFilter({ filters, onChange }: TaxonomicBreakdownFilterProps): JSX.Element { + const { cohorts } = useValues(cohortsModel) + const { breakdown, breakdown_type, insight } = filters + const [open, setOpen] = useState(false) + let label = breakdown + + if (breakdown_type === 'cohort' && breakdown) { + label = cohorts.filter((c) => c.id == breakdown)[0]?.name || `Cohort #${breakdown}` + } + + return ( + { + const filterType = taxonomicFilterTypeToPropertyFilterType(groupType) + if (value && filterType) { + onChange(value.toString(), filterType as BreakdownType) + setOpen(false) + } + }} + groupTypes={[ + TaxonomicFilterGroupType.EventProperties, + TaxonomicFilterGroupType.PersonProperties, + TaxonomicFilterGroupType.Cohorts, + ]} + /> + } + placement={'bottom-start'} + fallbackPlacements={['bottom-end']} + visible={open} + onClickOutside={() => setOpen(false)} + > + {({ setRef }) => ( + + + + )} + + ) +} diff --git a/frontend/src/scenes/paths/pathsLogic.ts b/frontend/src/scenes/paths/pathsLogic.ts index 306f90d10f98d..74dffa8d5eda3 100644 --- a/frontend/src/scenes/paths/pathsLogic.ts +++ b/frontend/src/scenes/paths/pathsLogic.ts @@ -8,6 +8,8 @@ import { pathsLogicType } from './pathsLogicType' import { FilterType, PathType, PropertyFilter, ViewType } from '~/types' import { dashboardItemsModel } from '~/models/dashboardItemsModel' import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' export const pathOptionsToLabels = { [PathType.PageView]: 'Page views (Web)', @@ -176,8 +178,8 @@ export const pathsLogic = kea>({ }, ], filtersLoading: [ - () => [propertyDefinitionsModel.selectors.loaded], - (propertiesLoaded): boolean => !propertiesLoaded, + () => [featureFlagLogic.selectors.featureFlags, propertyDefinitionsModel.selectors.loaded], + (featureFlags, loaded) => !featureFlags[FEATURE_FLAGS.TAXONOMIC_PROPERTY_FILTER] && !loaded, ], }, actionToUrl: ({ values }) => ({ diff --git a/frontend/src/scenes/retention/retentionTableLogic.ts b/frontend/src/scenes/retention/retentionTableLogic.ts index e72e313c9d204..f158ee3bbe785 100644 --- a/frontend/src/scenes/retention/retentionTableLogic.ts +++ b/frontend/src/scenes/retention/retentionTableLogic.ts @@ -5,7 +5,13 @@ import { toParams, objectsEqual, uuid } from 'lib/utils' import { insightLogic } from 'scenes/insights/insightLogic' import { insightHistoryLogic } from 'scenes/insights/InsightHistoryPanel/insightHistoryLogic' import { retentionTableLogicType } from './retentionTableLogicType' -import { ACTIONS_LINE_GRAPH_LINEAR, ACTIONS_TABLE, RETENTION_FIRST_TIME, RETENTION_RECURRING } from 'lib/constants' +import { + ACTIONS_LINE_GRAPH_LINEAR, + ACTIONS_TABLE, + FEATURE_FLAGS, + RETENTION_FIRST_TIME, + RETENTION_RECURRING, +} from 'lib/constants' import { actionsModel } from '~/models/actionsModel' import { ActionType, FilterType, ViewType } from '~/types' import { @@ -17,6 +23,7 @@ import { import { dashboardItemsModel } from '~/models/dashboardItemsModel' import { eventDefinitionsModel } from '~/models/eventDefinitionsModel' import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' export const dateOptions = ['Hour', 'Day', 'Week', 'Month'] @@ -135,8 +142,13 @@ export const retentionTableLogic = kea({ (actions: ActionType[]) => Object.assign({}, ...actions.map((action) => ({ [action.id]: action.name }))), ], filtersLoading: [ - () => [eventDefinitionsModel.selectors.loaded, propertyDefinitionsModel.selectors.loaded], - (eventsLoaded, propertiesLoaded) => !eventsLoaded || !propertiesLoaded, + () => [ + featureFlagLogic.selectors.featureFlags, + eventDefinitionsModel.selectors.loaded, + propertyDefinitionsModel.selectors.loaded, + ], + (featureFlags, eventsLoaded, propertiesLoaded) => + !featureFlags[FEATURE_FLAGS.TAXONOMIC_PROPERTY_FILTER] && (!eventsLoaded || !propertiesLoaded), ], }, events: ({ actions, props }) => ({ diff --git a/frontend/src/scenes/trends/personsModalLogic.ts b/frontend/src/scenes/trends/personsModalLogic.ts index 5534c25a99538..116219d462f70 100644 --- a/frontend/src/scenes/trends/personsModalLogic.ts +++ b/frontend/src/scenes/trends/personsModalLogic.ts @@ -120,7 +120,6 @@ export const personsModalLogic = kea>({ } }, loadPeople: async ({ peopleParams }, breakpoint) => { - actions.setPeopleLoading(true) let people = [] const { label, @@ -157,7 +156,6 @@ export const personsModalLogic = kea>({ people = await api.get(`api/action/people/?${filterParams}${searchTermParam}`) } breakpoint() - actions.setPeopleLoading(false) const peopleResult = { people: people.results[0]?.people, count: people.results[0]?.count || 0, diff --git a/frontend/src/scenes/trends/trendsLogic.ts b/frontend/src/scenes/trends/trendsLogic.ts index e0e68a60432d7..511edbea71ce2 100644 --- a/frontend/src/scenes/trends/trendsLogic.ts +++ b/frontend/src/scenes/trends/trendsLogic.ts @@ -4,7 +4,7 @@ import api from 'lib/api' import { autocorrectInterval, objectsEqual, toParams as toAPIParams, uuid } from 'lib/utils' import { actionsModel } from '~/models/actionsModel' import { router } from 'kea-router' -import { ACTIONS_LINE_GRAPH_CUMULATIVE, ShownAsValue } from 'lib/constants' +import { ACTIONS_LINE_GRAPH_CUMULATIVE, FEATURE_FLAGS, ShownAsValue } from 'lib/constants' import { defaultFilterTestAccounts, insightLogic, TRENDS_BASED_INSIGHTS } from '../insights/insightLogic' import { insightHistoryLogic } from '../insights/InsightHistoryPanel/insightHistoryLogic' import { @@ -23,6 +23,7 @@ import { dashboardItemsModel } from '~/models/dashboardItemsModel' import { eventDefinitionsModel } from '~/models/eventDefinitionsModel' import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel' import { sceneLogic } from 'scenes/sceneLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' interface TrendResponse { result: TrendResult[] @@ -310,8 +311,13 @@ export const trendsLogic = kea ({ filtersLoading: [ - () => [eventDefinitionsModel.selectors.loaded, propertyDefinitionsModel.selectors.loaded], - (eventsLoaded, propertiesLoaded): boolean => !eventsLoaded || !propertiesLoaded, + () => [ + featureFlagLogic.selectors.featureFlags, + eventDefinitionsModel.selectors.loaded, + propertyDefinitionsModel.selectors.loaded, + ], + (featureFlags, eventsLoaded, propertiesLoaded) => + !featureFlags[FEATURE_FLAGS.TAXONOMIC_PROPERTY_FILTER] && (!eventsLoaded || !propertiesLoaded), ], results: [(selectors) => [selectors._results], (response) => response.result], resultsLoading: [(selectors) => [selectors._resultsLoading], (_resultsLoading) => _resultsLoading], diff --git a/package.json b/package.json index 9047732df23c4..aa1c366ce46f6 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "chartjs-adapter-dayjs": "^1.0.0", "chartjs-plugin-crosshair": "^1.1.6", "clsx": "^1.1.1", - "core-js": "3.6.5", + "core-js": "3.15.2", "d3": "^5.15.0", "d3-sankey": "^0.12.3", "dayjs": "^1.10.4", @@ -65,7 +65,7 @@ "fast-deep-equal": "^3.1.3", "funnel-graph-js": "^1.4.1", "fuse.js": "^6.4.1", - "kea": "^2.4.5", + "kea": "^2.4.6", "kea-loaders": "^0.4.0", "kea-localstorage": "^1.1.1", "kea-router": "^1.0.2", diff --git a/yarn.lock b/yarn.lock index 33ab3c38e8bcd..1f1c8d3e6669d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3718,10 +3718,10 @@ core-js-compat@^3.6.2: browserslist "^4.8.5" semver "7.0.0" -core-js@3.6.5: - version "3.6.5" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a" - integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA== +core-js@3.15.2: + version "3.15.2" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.15.2.tgz#740660d2ff55ef34ce664d7e2455119c5bdd3d61" + integrity sha512-tKs41J7NJVuaya8DxIOCnl8QuPHx5/ZVbFo1oKgVl1qHFBBrDctzQGtuLjPpRdNTWmKPH6oEvgN/MUID+l485Q== core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" @@ -7178,10 +7178,10 @@ kea-window-values@^0.0.1: resolved "https://registry.yarnpkg.com/kea-window-values/-/kea-window-values-0.0.1.tgz#918eee6647507e2d3d5d19466b9561261e7bc8d7" integrity sha512-60SfOqHrmnCC8hSD2LALMJemYcohQ8tGcTHlA5u4rQy0l0wFFE4gqH1WbKd+73j9m4f6zdoOk07rwJf+P8maiQ== -kea@^2.4.5: - version "2.4.5" - resolved "https://registry.yarnpkg.com/kea/-/kea-2.4.5.tgz#5a43a5aedf306bc7ae0a271ee5bc1ae5fedb4959" - integrity sha512-eo9o0AlnPVKyX2/qFAMxFksPjgHfc//NgzET7X3QbddKPDqCVgNfR8DGdrhnbrKlHF9lJtMQ4ERbVr9WaEEheg== +kea@^2.4.6: + version "2.4.6" + resolved "https://registry.yarnpkg.com/kea/-/kea-2.4.6.tgz#07782a79c5f036e6514d6300281416e736ed98e3" + integrity sha512-NuH1GOck4Dmr18Kjrto8ZsL6n7OgL2CagQeqPnFjdjyxBgNS7JDusex6G9sasW5Cp1D0Cb4izJvFVocj0fS8Jg== killable@^1.0.1: version "1.0.1" From 29191e90561572c74f1b13d5a5144ff24bcf8cda Mon Sep 17 00:00:00 2001 From: Marius Andra Date: Mon, 19 Jul 2021 23:53:04 +0200 Subject: [PATCH 2/2] revert bug --- frontend/src/scenes/trends/personsModalLogic.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/frontend/src/scenes/trends/personsModalLogic.ts b/frontend/src/scenes/trends/personsModalLogic.ts index 116219d462f70..5534c25a99538 100644 --- a/frontend/src/scenes/trends/personsModalLogic.ts +++ b/frontend/src/scenes/trends/personsModalLogic.ts @@ -120,6 +120,7 @@ export const personsModalLogic = kea>({ } }, loadPeople: async ({ peopleParams }, breakpoint) => { + actions.setPeopleLoading(true) let people = [] const { label, @@ -156,6 +157,7 @@ export const personsModalLogic = kea>({ people = await api.get(`api/action/people/?${filterParams}${searchTermParam}`) } breakpoint() + actions.setPeopleLoading(false) const peopleResult = { people: people.results[0]?.people, count: people.results[0]?.count || 0,