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:
parent
f06e0b4c51
commit
01873b1780
@ -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,
|
||||
]
|
||||
|
27
frontend/src/lib/api/person-properties.tsx
Normal file
27
frontend/src/lib/api/person-properties.tsx
Normal 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
|
||||
}
|
@ -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)
|
@ -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>
|
||||
)
|
||||
}
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
107
frontend/src/lib/components/PropertyNamesSelect/styles.scss
Normal file
107
frontend/src/lib/components/PropertyNamesSelect/styles.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
@ -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 }) => {
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
}),
|
||||
|
@ -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",
|
||||
|
80
yarn.lock
80
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user