0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

feat(correlation): add multiselect for property names (#6407)

* refactor(taxonomic-filter): add api mock functions

* chore: remove makeApi from storybook preview.js

* feat(correlation): add multiselect for property names

Previously we had a simple text input box that was splitting the input
to get a list of property names. This doesn't give the greatest user
experience.

Now we have a simple select box that includes:

 1. search filtering for options
 2. multiselecting options
 3. a select $all, although this is pretty poorly implemented at the
    moment, for instance you can select $all, at the same time as
    selecting a subset of properties.

* add a little commentary around PropertyNamesSelect

* add deps to useEffect

* ignore return type eslint issues

* just use console.log instead of actions

* remove unused import

* chore: add error condition

* chore(correlation): update styling of property select

This puts it more in line with
https://www.figma.com/file/gQBj9YnNgD8YW4nBwCVLZf/PostHog-App?node-id=4200%3A30715

* Add no search results message

* input focus border

* fix type

* use onBlur

* make checkbox checked colour change

* make query bold in no results message

* add clear

* Implement search highlighting

* fix typing

* add regex split comment

* if no property names selected, default to $all

* typo

* Add test for selection component

* Add test highlighting onChange lag and clicking outside

* click out side should depend on hide

* make sure onChange triggered on clear or select all
This commit is contained in:
Harry Waye 2021-10-20 15:12:32 +01:00 committed by GitHub
parent f06e0b4c51
commit 01873b1780
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 793 additions and 56 deletions

View File

@ -3,7 +3,6 @@ import { getContext } from 'kea'
import '~/styles'
import { worker } from '../frontend/src/mocks/browser'
import { loadPostHogJS } from '~/loadPostHogJS'
import { withApi } from './ApiSelector/withApi'
const setupMsw = () => {
// Make sure the msw worker is started, if we're running in browser
@ -54,5 +53,4 @@ export const decorators = [
worker.resetHandlers()
return <Story />
},
withApi,
]

View File

@ -0,0 +1,27 @@
import React from 'react'
import { PersonProperty } from '~/types'
export type GetPersonPropertiesResponse = PersonProperty[]
export type GetPersonPropertiesRequest = undefined
type usePersonProperiesReturnType = { properties: GetPersonPropertiesResponse | undefined; error: boolean }
export const usePersonProperies = (): usePersonProperiesReturnType => {
const [response, setResponse] = React.useState<usePersonProperiesReturnType>({
properties: undefined,
error: false,
})
React.useEffect(() => {
const ac = new AbortController()
setResponse({ properties: undefined, error: false })
fetch('/api/person/properties', { signal: ac.signal })
.then((httpResponse) => httpResponse.json())
.then((jsonResponse) => setResponse({ properties: jsonResponse, error: false }))
.catch(() => setResponse({ properties: undefined, error: true }))
return () => ac.abort()
}, [])
return response
}

View File

@ -0,0 +1,102 @@
import React from 'react'
import { PropertyNamesSelect } from './PropertyNamesSelect'
import { render, within } from '@testing-library/react'
import { setupServer } from 'msw/node'
import userEvent from '@testing-library/user-event'
import { GetPersonPropertiesRequest, GetPersonPropertiesResponse } from 'lib/api/person-properties'
import { ResponseResolver, RestRequest, RestContext, rest } from 'msw'
test('Can load, deselect property, hide popup and receive selection via onChange', async () => {
const server = setupServer(
mockGetPersonProperties((_, res, ctx) =>
res(
ctx.json([
{ id: 1, name: 'Property A', count: 10 },
{ id: 2, name: 'Property B', count: 20 },
{ id: 3, name: 'Property C', count: 30 },
])
)
)
)
server.listen()
const onChange = jest.fn()
const { findByRole } = render(<PropertyNamesSelect onChange={onChange} />)
const combo = await findByRole('combobox')
const summaryText = await within(combo).findByText(/3 of 3 selected/)
userEvent.click(summaryText)
const propertyACheckbox = await findByRole('checkbox', { name: 'Property A' })
userEvent.click(propertyACheckbox)
userEvent.click(summaryText)
expect(onChange).toHaveBeenLastCalledWith(['Property B', 'Property C'])
})
test('Can load, deselect property, click away and receive selection via onChange', async () => {
const server = setupServer(
mockGetPersonProperties((_, res, ctx) =>
res(
ctx.json([
{ id: 1, name: 'Property A', count: 10 },
{ id: 2, name: 'Property B', count: 20 },
{ id: 3, name: 'Property C', count: 30 },
])
)
)
)
server.listen()
const onChange = jest.fn()
const { findByRole } = render(<PropertyNamesSelect onChange={onChange} />)
const combo = await findByRole('combobox')
const summaryText = await within(combo).findByText(/3 of 3 selected/)
userEvent.click(summaryText)
const propertyACheckbox = await findByRole('checkbox', { name: 'Property A' })
userEvent.click(propertyACheckbox)
// Click outside the component
userEvent.click(document.body)
expect(onChange).toHaveBeenLastCalledWith(['Property B', 'Property C'])
})
test('Can load, deselect and select all, and receive selection via onChange', async () => {
const server = setupServer(
mockGetPersonProperties((_, res, ctx) =>
res(
ctx.json([
{ id: 1, name: 'Property A', count: 10 },
{ id: 2, name: 'Property B', count: 20 },
{ id: 3, name: 'Property C', count: 30 },
])
)
)
)
server.listen()
const onChange = jest.fn()
const { findByRole } = render(<PropertyNamesSelect onChange={onChange} />)
const combo = await findByRole('combobox')
await within(combo).findByText(/3 of 3 selected/)
const selectAllCheckbox = await findByRole('checkbox', { name: 'Select all' })
userEvent.click(selectAllCheckbox)
await within(combo).findByText(/0 of 3 selected/)
expect(onChange).toHaveBeenLastCalledWith([])
userEvent.click(selectAllCheckbox)
await within(combo).findByText(/3 of 3 selected/)
expect(onChange).toHaveBeenLastCalledWith(['Property A', 'Property B', 'Property C'])
})
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
const mockGetPersonProperties = (
handler: ResponseResolver<RestRequest<GetPersonPropertiesRequest, any>, RestContext, GetPersonPropertiesResponse>
) => rest.get<GetPersonPropertiesRequest, GetPersonPropertiesResponse>('/api/person/properties', handler)

View File

@ -0,0 +1,333 @@
import CaretDownFilled from '@ant-design/icons/lib/icons/CaretDownFilled'
import SearchOutlined from '@ant-design/icons/lib/icons/SearchOutlined'
import WarningFilled from '@ant-design/icons/lib/icons/WarningFilled'
import { Checkbox, Input } from 'antd'
import { usePersonProperies } from 'lib/api/person-properties'
import React from 'react'
import { PersonProperty } from '~/types'
import './styles.scss'
export const PropertyNamesSelect = ({
onChange,
}: {
onChange?: (selectedProperties: string[]) => void
}): JSX.Element => {
/*
Provides a super simple multiselect box for selecting property names.
*/
const { properties, error } = usePersonProperies()
return error ? (
<div className="property-names-select">
<WarningFilled style={{ color: 'var(--warning)' }} /> Error loading properties!
</div>
) : properties ? (
<SelectPropertiesProvider properties={properties}>
<PropertyNamesSelectBox onChange={onChange} />
</SelectPropertiesProvider>
) : (
<div className="property-names-select">Loading properties...</div>
)
}
const PropertyNamesSelectBox = ({ onChange }: { onChange?: (selectedProperties: string[]) => void }): JSX.Element => {
const { properties, selectedProperties, selectAll, clearAll, selectState } = useSelectedProperties()
const {
isOpen: isSearchOpen,
popoverProps,
triggerProps,
} = usePopover({
onHide: () => {
if (onChange) {
onChange(Array.from(selectedProperties))
}
},
})
return (
<div className="property-names-select-container" {...triggerProps}>
<div className="property-names-select" role="combobox">
{properties ? (
<>
{selectState === 'all' ? (
<Checkbox
checked={true}
aria-label="Select all"
onClick={(evt) => {
clearAll()
if (onChange) {
onChange([])
}
evt.stopPropagation()
}}
/>
) : selectState === 'none' ? (
<Checkbox
checked={false}
aria-label="Select all"
onClick={(evt) => {
selectAll()
if (onChange) {
onChange(properties.map((property) => property.name))
}
evt.stopPropagation()
}}
/>
) : (
<Checkbox
aria-label="Select all"
indeterminate={true}
onClick={(evt) => {
selectAll()
if (onChange) {
onChange(properties.map((property) => property.name))
}
evt.stopPropagation()
}}
/>
)}
<div className="selection-status-text">
{selectedProperties.size} of {properties.length} selected
</div>
<CaretDownFilled />
</>
) : (
'Loading properties'
)}
</div>
{isSearchOpen ? (
<div className="popover" {...popoverProps}>
<PropertyNamesSearch />
</div>
) : null}
</div>
)
}
const PropertyNamesSearch = (): JSX.Element => {
const { properties, toggleProperty, isSelected } = useSelectedProperties()
const { filteredProperties, query, setQuery } = usePropertySearch(properties)
return (
<>
<Input
onChange={({ target: { value } }) => setQuery(value)}
allowClear
className="search-box"
placeholder="Search for properties"
prefix={<SearchOutlined />}
/>
<div className="search-results">
{filteredProperties.length ? (
filteredProperties.map((property) => (
<Checkbox
key={property.name}
className={'checkbox' + (isSelected(property.name) ? ' checked' : '')}
checked={isSelected(property.name)}
onChange={() => toggleProperty(property.name)}
>
{property.highlightedName}
</Checkbox>
))
) : (
<p className="no-results-message">
No properties match <b>{query}</b>. Refine your search to try again.
</p>
)}
</div>
</>
)
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const usePopover = ({ onHide }: { onHide: () => void }) => {
/* Logic for handling arbitrary popover state */
const [isOpen, setIsOpen] = React.useState<boolean>(false)
const hide = (): void => {
setIsOpen(false)
onHide()
}
const open = (): void => setIsOpen(true)
const toggle = (): void => {
if (isOpen) {
hide()
} else {
open()
}
}
// I use a ref to ensure we are able to close the popover when the user clicks outside of it.
const triggerRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const checkIfClickedOutside = (event: MouseEvent): void => {
if (
isOpen &&
triggerRef.current &&
event.target instanceof Node &&
!triggerRef.current.contains(event.target)
) {
hide()
}
}
document.addEventListener('mousedown', checkIfClickedOutside)
return () => {
// Cleanup the event listener
document.removeEventListener('mousedown', checkIfClickedOutside)
}
}, [isOpen, hide])
return {
isOpen,
open,
hide,
toggle,
// Return props that should be on the actual popover. This is so we can
// position things correctly
popoverProps: {
onClick(event: React.MouseEvent): void {
// Avoid the click propogating to the trigger element. We need
// to do this in order to prevent popover clicks also triggering
// anything on containing elements
event.stopPropagation()
},
},
// Return propse that should be on the trigger. This is so we can attach
// any show, hide handlers etc.
triggerProps: { ref: triggerRef, onClick: toggle },
}
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const usePropertySearch = (properties: PersonProperty[]) => {
/*
Basic case insensitive substring search functionality for person property
selection. It's pretty much this stackoverflow answer:
https://stackoverflow.com/a/43235785
*/
const [query, setQuery] = React.useState<string>('')
const filteredProperties = React.useMemo(() => {
return query === ''
? properties.map((property) => ({ ...property, highlightedName: property.name }))
: properties
// First we split on query term, case insensitive, and globally,
// not just the first
// NOTE: it's important to use a capture group here, otherwise
// the query string match will not be included as a part. See
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/split#splitting_with_a_regexp_to_include_parts_of_the_separator_in_the_result
// for details
.map((property) => ({
...property,
nameParts: property.name.split(new RegExp(`(${query})`, 'gi')),
}))
// Then filter where we have a match
.filter((property) => property.nameParts.length > 1)
// Then create a JSX.Element that can be rendered
.map((property) => ({
...property,
highlightedName: (
<span>
{property.nameParts.map((part, index) =>
part.toLowerCase() === query.toLowerCase() ? (
<b key={index}>{part}</b>
) : (
<React.Fragment key={index}>{part}</React.Fragment>
)
)}
</span>
),
}))
}, [query, properties])
return { filteredProperties, setQuery, query }
}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const useSelectedProperties = () => {
/* Provides functions for handling selected properties state */
const context = React.useContext(propertiesSelectionContext)
// make typing happy, i.e. rule out the undefined case so we don't have to
// check this everywhere
if (context === undefined) {
throw Error('No select React.Context found')
}
return context
}
/*
A propertiesSelectionContext provides:
- selectedProperties: a set of selected property names
- state manipulation functions for modifying the set of selected properties
*/
const propertiesSelectionContext = React.createContext<
| {
properties: PersonProperty[]
selectState: 'all' | 'none' | 'some'
selectedProperties: Set<string>
toggleProperty: (propertyName: string) => void
clearAll: () => void
selectAll: () => void
isSelected: (propertyName: string) => boolean
}
| undefined
>(undefined)
const SelectPropertiesProvider = ({
properties,
children,
}: {
properties: PersonProperty[]
children: React.ReactNode
}): JSX.Element => {
const [selectedProperties, setSelectedProperties] = React.useState<Set<string>>(
new Set(properties.map((property) => property.name))
)
const setAndNotify = (newSelectedProperties: Set<string>): void => {
setSelectedProperties(newSelectedProperties)
}
const toggleProperty = (property: string): void => {
setAndNotify(
selectedProperties.has(property)
? new Set(Array.from(selectedProperties).filter((p) => p !== property))
: new Set([...Array.from(selectedProperties), property])
)
}
const clearAll = (): void => {
setAndNotify(new Set())
}
const selectAll = (): void => {
setAndNotify(new Set(properties.map((property) => property.name)))
}
const isSelected = (property: string): boolean => selectedProperties.has(property)
const selectState: 'all' | 'none' | 'some' =
selectedProperties.size === properties.length ? 'all' : selectedProperties.size === 0 ? 'none' : 'some'
return (
<propertiesSelectionContext.Provider
value={{ properties, selectedProperties, toggleProperty, clearAll, selectAll, selectState, isSelected }}
>
{children}
</propertiesSelectionContext.Provider>
)
}

View File

@ -0,0 +1,47 @@
import { mockGetPersonProperties } from 'lib/components/TaxonomicFilter/__stories__/TaxonomicFilter.stories'
import React from 'react'
import { worker } from '~/mocks/browser'
import { PropertyNamesSelect } from '../PropertyNamesSelect'
export default {
title: 'PostHog/Components/PropertyNamesSelect',
}
export const EmptyWithOptions = (): JSX.Element => {
worker.use(
mockGetPersonProperties((_, res, ctx) =>
res(
ctx.delay(1500),
ctx.json([
{ id: 1, name: 'Property A', count: 10 },
{ id: 2, name: 'Property B', count: 20 },
{ id: 3, name: 'Property C', count: 30 },
{ id: 4, name: 'Property D', count: 40 },
{ id: 5, name: 'Property E', count: 50 },
{ id: 6, name: 'Property F', count: 60 },
{ id: 7, name: 'Property G', count: 70 },
{ id: 8, name: 'Property H', count: 80 },
{ id: 9, name: 'Property I', count: 90 },
])
)
)
)
return (
<PropertyNamesSelect
onChange={(selectedProperties) => console.log('Selected Properties', selectedProperties)}
/>
)
}
export const RequestFailure = (): JSX.Element => {
worker.use(mockGetPersonProperties((_, res, ctx) => res(ctx.delay(1500), ctx.status(500))))
return (
<PropertyNamesSelect
onChange={(selectedProperties) => console.log('Selected Properties', selectedProperties)}
/>
)
}

View File

@ -0,0 +1,107 @@
.property-names-select-container {
// Make sure we can absolutely position the popover
position: relative;
// Make sure the popover isn't cropped
overflow: visible;
min-width: 300px;
.property-names-select {
display: flex;
align-items: stretch;
align-items: center;
padding: 8px;
/* background/clear */
background: #ffffff;
/* main/primary */
border: 1px solid var(--primary);
border-radius: 4px;
.checkbox-icon {
font-size: 18px;
color: var(--primary);
}
.selection-status-text {
margin-left: 8px;
flex-grow: 1;
}
&:hover {
cursor: pointer;
}
}
.popover {
// Position the popover below the select
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
// Make sure we don't get covered up
z-index: 100; // number is abritrary
padding: 8px;
/* background/clear */
background: #ffffff;
/* main/primary */
border: 1px solid var(--primary);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 4px;
.search-box {
padding: 8px;
/* background/clear */
background: #ffffff;
/* border/default */
border: 1px solid #d9d9d9;
box-sizing: border-box;
border-radius: 4px;
&:focus-within {
border-color: var(--primary);
}
}
.search-results {
width: 100%;
max-height: 310px;
overflow-y: auto;
.checkbox {
display: block;
padding: 8px;
height: 40px;
margin: 4px 0px;
width: 100%;
background: #ffffff;
border-radius: 4px;
&.checked {
background: #eef2ff;
}
}
.no-results-message {
padding: 8px;
text-align: center;
font-size: 14px;
line-height: 24px;
color: #2d2d2d;
margin: 1em 4px;
}
}
}
}

View File

@ -7,8 +7,9 @@ import { initKea } from '~/initKea'
import { personPropertiesModel } from '~/models/personPropertiesModel'
import { cohortsModel } from '~/models/cohortsModel'
import { worker } from '~/mocks/browser'
import { DefaultRequestBody, rest } from 'msw'
import { CohortType, PersonProperty, PropertyDefinition } from '~/types'
import { ResponseResolver, rest, RestContext, RestRequest } from 'msw'
import { CohortType, PropertyDefinition } from '~/types'
import { GetPersonPropertiesRequest, GetPersonPropertiesResponse } from 'lib/api/person-properties'
export default {
title: 'PostHog/Components/TaxonomicFilter',
@ -31,55 +32,48 @@ export const AllGroups = (): JSX.Element => {
// informed that we need to update this data as well. We should be
// maintaining some level of backwards compatability so hopefully this isn't
// too unnecessarily laborious
// TODO: abstract away the api details behind, e.g.
// `setupPersonPropertiesEndpoint(rest...)`. This was we can keep the urls and
// typings in one place, but still give the freedom to do whatever we want
// in the rest handlers
worker.use(
rest.get<DefaultRequestBody, Array<PersonProperty>>('/api/person/properties', (_, res, ctx) => {
return res(
mockGetPersonProperties((_, res, ctx) =>
res(
ctx.json([
{ id: 1, name: 'location', count: 1 },
{ id: 2, name: 'role', count: 2 },
{ id: 3, name: 'height', count: 3 },
])
)
}),
rest.get<DefaultRequestBody, PropertyDefinition[]>(
'/api/projects/@current/property_definitions',
(_, res, ctx) => {
return res(
ctx.json([
{
id: 'a',
name: 'signed up',
description: 'signed up',
volume_30_day: 10,
query_usage_30_day: 5,
count: 101,
},
{
id: 'b',
name: 'viewed insights',
description: 'signed up',
volume_30_day: 10,
query_usage_30_day: 5,
count: 1,
},
{
id: 'c',
name: 'logged out',
description: 'signed up',
volume_30_day: 10,
query_usage_30_day: 5,
count: 103,
},
])
)
}
),
rest.get<DefaultRequestBody, { results: CohortType[] }>('/api/cohort/', (_, res, ctx) => {
return res(
mockGetPropertyDefinitions((_, res, ctx) =>
res(
ctx.json([
{
id: 'a',
name: 'signed up',
description: 'signed up',
volume_30_day: 10,
query_usage_30_day: 5,
count: 101,
},
{
id: 'b',
name: 'viewed insights',
description: 'signed up',
volume_30_day: 10,
query_usage_30_day: 5,
count: 1,
},
{
id: 'c',
name: 'logged out',
description: 'signed up',
volume_30_day: 10,
query_usage_30_day: 5,
count: 103,
},
])
)
),
mockGetCohorts((_, res, ctx) =>
res(
ctx.json({
results: [
{
@ -97,7 +91,7 @@ export const AllGroups = (): JSX.Element => {
],
})
)
})
)
)
return (
@ -106,3 +100,32 @@ export const AllGroups = (): JSX.Element => {
</Provider>
)
}
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export const mockGetPersonProperties = (
handler: ResponseResolver<RestRequest<GetPersonPropertiesRequest, any>, RestContext, GetPersonPropertiesResponse>
) => rest.get<GetPersonPropertiesRequest, GetPersonPropertiesResponse>('/api/person/properties', handler)
type GetPropertyDefinitionsResponse = PropertyDefinition[]
type GetPropertyDefinitionsRequest = undefined
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export const mockGetPropertyDefinitions = (
handler: ResponseResolver<
RestRequest<GetPropertyDefinitionsRequest, any>,
RestContext,
GetPropertyDefinitionsResponse
>
) =>
rest.get<GetPropertyDefinitionsRequest, GetPropertyDefinitionsResponse>(
'/api/projects/@current/property_definitions',
handler
)
type GetCohortsResponse = { results: CohortType[] }
type GetCohortsRequest = undefined
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type
export const mockGetCohorts = (
handler: ResponseResolver<RestRequest<GetCohortsRequest, any>, RestContext, GetCohortsResponse>
) => rest.get<GetCohortsRequest, GetCohortsResponse>('/api/cohort/', handler)

View File

@ -131,15 +131,15 @@ export const funnelLogic = kea<funnelLogicType>({
events: [],
} as Record<'events', FunnelCorrelation[]>,
{
loadPropertyCorrelations: async (propertyCorrelationName) => {
loadPropertyCorrelations: async (propertyNames: string[]) => {
return (
await api.create('api/insight/funnel/correlation', {
...values.apiParams,
funnel_correlation_type: 'properties',
// Name is comma separated list of property names
funnel_correlation_names: propertyCorrelationName
.split(',')
.map((name: string) => name.trim()),
funnel_correlation_names: propertyNames.length
? propertyNames.map((name: string) => name.trim())
: ['$all'],
})
).result
},
@ -781,7 +781,7 @@ export const funnelLogic = kea<funnelLogicType>({
) {
actions.loadCorrelations()
// Hardcoded for initial testing
actions.loadPropertyCorrelations('$browser, $os, $geoip_country_code')
actions.loadPropertyCorrelations(['$all'])
}
},
toggleVisibilityByBreakdown: ({ breakdownValue }) => {

View File

@ -1,5 +1,5 @@
import React from 'react'
import { Col, Input, Row, Table } from 'antd'
import { Col, Row, Table } from 'antd'
import Column from 'antd/lib/table/Column'
import { useActions, useValues } from 'kea'
import { RiseOutlined, FallOutlined } from '@ant-design/icons'
@ -7,6 +7,7 @@ import { funnelLogic } from 'scenes/funnels/funnelLogic'
import { FunnelCorrelation, FunnelCorrelationType } from '~/types'
import Checkbox from 'antd/lib/checkbox/Checkbox'
import { insightLogic } from 'scenes/insights/insightLogic'
import { PropertyNamesSelect } from 'lib/components/PropertyNamesSelect/PropertyNamesSelect'
export function FunnelPropertyCorrelationTable(): JSX.Element | null {
const { insightProps } = useValues(insightLogic)
@ -38,10 +39,8 @@ export function FunnelPropertyCorrelationTable(): JSX.Element | null {
<b>Correlation Analysis for:</b>
</Col>
<Col>
<Input
// Hardcoded for initial testing
defaultValue="$browser, $os, $geoip_country_code"
onBlur={({ target: { value } }) => loadPropertyCorrelations(value)}
<PropertyNamesSelect
onChange={(selectedProperties) => loadPropertyCorrelations(selectedProperties)}
/>
</Col>
<Col

View File

@ -10,6 +10,7 @@ import { rest } from 'msw'
import { worker } from '../../../mocks/browser'
import { FunnelResult, FunnelStep } from '~/types'
import posthog from 'posthog-js'
import { mockGetPersonProperties } from 'lib/components/TaxonomicFilter/__stories__/TaxonomicFilter.stories'
export default {
title: 'PostHog/Scenes/Insights/Funnel',
@ -37,6 +38,15 @@ export const WithCorrelation = (): JSX.Element => {
setFeatureFlags({ 'correlation-analysis': true })
worker.use(
mockGetPersonProperties((_, res, ctx) =>
res(
ctx.json([
{ id: 1, name: 'location', count: 1 },
{ id: 2, name: 'role', count: 2 },
{ id: 3, name: 'height', count: 3 },
])
)
),
rest.post('/api/insight/funnel/', (_, res, ctx) => {
return res(ctx.json(sampleFunnelResponse))
}),
@ -54,6 +64,15 @@ export const WithCorrelationAndSkew = (): JSX.Element => {
setFeatureFlags({ 'correlation-analysis': true })
worker.use(
mockGetPersonProperties((_, res, ctx) =>
res(
ctx.json([
{ id: 1, name: 'location', count: 1 },
{ id: 2, name: 'role', count: 2 },
{ id: 3, name: 'height', count: 3 },
])
)
),
rest.post('/api/insight/funnel/', (_, res, ctx) => {
return res(ctx.json(sampleSkewedFunnelResponse))
}),

View File

@ -125,6 +125,8 @@
"@storybook/addon-essentials": "^6.3.11",
"@storybook/addon-links": "^6.3.11",
"@storybook/react": "^6.3.11",
"@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^13.5.0",
"@types/chart.js": "^2.9.32",
"@types/d3": "^7.0.0",
"@types/jest": "^26.0.15",

View File

@ -2272,6 +2272,17 @@
"@types/yargs" "^16.0.0"
chalk "^4.0.0"
"@jest/types@^27.2.5":
version "27.2.5"
resolved "https://registry.yarnpkg.com/@jest/types/-/types-27.2.5.tgz#420765c052605e75686982d24b061b4cbba22132"
integrity sha512-nmuM4VuDtCZcY+eTpw+0nvstwReMsjPoj7ZR80/BbixulhLaiX+fbv8oeLW8WZlJMcsGQsTmMKT/iTZu1Uy/lQ==
dependencies:
"@types/istanbul-lib-coverage" "^2.0.0"
"@types/istanbul-reports" "^3.0.0"
"@types/node" "*"
"@types/yargs" "^16.0.0"
chalk "^4.0.0"
"@juggle/resize-observer@^3.3.1":
version "3.3.1"
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
@ -3544,6 +3555,35 @@
"@styled-system/core" "^5.1.2"
"@styled-system/css" "^5.1.5"
"@testing-library/dom@^8.0.0":
version "8.10.1"
resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.10.1.tgz#e24fed92ad51c619cf304c6f1410b4c76b1000c0"
integrity sha512-rab7vpf1uGig5efWwsCOn9j4/doy+W3VBoUyzX7C4y77u0wAckwc7R8nyH6e2rw0rRzKJR+gWPiAg8zhiFbxWQ==
dependencies:
"@babel/code-frame" "^7.10.4"
"@babel/runtime" "^7.12.5"
"@types/aria-query" "^4.2.0"
aria-query "^5.0.0"
chalk "^4.1.0"
dom-accessibility-api "^0.5.9"
lz-string "^1.4.4"
pretty-format "^27.0.2"
"@testing-library/react@^12.1.2":
version "12.1.2"
resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.2.tgz#f1bc9a45943461fa2a598bb4597df1ae044cfc76"
integrity sha512-ihQiEOklNyHIpo2Y8FREkyD1QAea054U0MVbwH1m8N9TxeFz+KoJ9LkqoKqJlzx2JDm56DVwaJ1r36JYxZM05g==
dependencies:
"@babel/runtime" "^7.12.5"
"@testing-library/dom" "^8.0.0"
"@testing-library/user-event@^13.5.0":
version "13.5.0"
resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295"
integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==
dependencies:
"@babel/runtime" "^7.12.5"
"@theme-ui/color-modes@^0.3.4":
version "0.3.4"
resolved "https://registry.yarnpkg.com/@theme-ui/color-modes/-/color-modes-0.3.4.tgz#df5cfa8714bed0d4a5d33860c3c94b2a100ba55a"
@ -3604,6 +3644,11 @@
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a"
integrity sha512-/+CRPXpBDpo2RK9C68N3b2cOvO0Cf5B9aPijHsoDQTHivnGSObdOF2BRQOYjojWTDy6nQvMjmqRXIxH55VjxxA==
"@types/aria-query@^4.2.0":
version "4.2.2"
resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc"
integrity sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==
"@types/babel__core@^7.0.0", "@types/babel__core@^7.1.7":
version "7.1.12"
resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.12.tgz#4d8e9e51eb265552a7e4f1ff2219ab6133bdfb2d"
@ -4717,6 +4762,11 @@ ansi-regex@^5.0.0:
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75"
integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==
ansi-regex@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
ansi-styles@^3.2.0, ansi-styles@^3.2.1:
version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@ -4731,6 +4781,11 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
dependencies:
color-convert "^2.0.1"
ansi-styles@^5.0.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b"
integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==
ansi-to-html@^0.6.11:
version "0.6.15"
resolved "https://registry.yarnpkg.com/ansi-to-html/-/ansi-to-html-0.6.15.tgz#ac6ad4798a00f6aa045535d7f6a9cb9294eebea7"
@ -4845,6 +4900,11 @@ argparse@^1.0.7:
dependencies:
sprintf-js "~1.0.2"
aria-query@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c"
integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==
arr-diff@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520"
@ -7385,6 +7445,11 @@ doctrine@^3.0.0:
dependencies:
esutils "^2.0.2"
dom-accessibility-api@^0.5.9:
version "0.5.9"
resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.9.tgz#915f8531ba29a50e5c29389dbfb87a9642fef0d6"
integrity sha512-+KPF4o71fl6NrdnqIrJc6m44NA+Rhf1h7In2MRznejSQasWkjqmHOBUlk+pXJ77cVOSYyZeNHFwn/sjotB6+Sw==
dom-align@^1.7.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/dom-align/-/dom-align-1.12.0.tgz#56fb7156df0b91099830364d2d48f88963f5a29c"
@ -11224,6 +11289,11 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
lz-string@^1.4.4:
version "1.4.4"
resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=
make-dir@^2.0.0, make-dir@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
@ -13016,6 +13086,16 @@ pretty-format@^26.0.0, pretty-format@^26.6.2:
ansi-styles "^4.0.0"
react-is "^17.0.1"
pretty-format@^27.0.2:
version "27.3.1"
resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.3.1.tgz#7e9486365ccdd4a502061fa761d3ab9ca1b78df5"
integrity sha512-DR/c+pvFc52nLimLROYjnXPtolawm+uWDxr4FjuLDLUn+ktWnSN851KoHwHzzqq6rfCOjkzN8FLgDrSub6UDuA==
dependencies:
"@jest/types" "^27.2.5"
ansi-regex "^5.0.1"
ansi-styles "^5.0.0"
react-is "^17.0.1"
pretty-hrtime@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"