diff --git a/.storybook/preview.js b/.storybook/preview.js index ac09daa729b..0f16a8f4bc0 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -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 }, - withApi, ] diff --git a/frontend/src/lib/api/person-properties.tsx b/frontend/src/lib/api/person-properties.tsx new file mode 100644 index 00000000000..9ac135c8f6a --- /dev/null +++ b/frontend/src/lib/api/person-properties.tsx @@ -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({ + 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 +} diff --git a/frontend/src/lib/components/PropertyNamesSelect/PropertyNamesSelect.test.tsx b/frontend/src/lib/components/PropertyNamesSelect/PropertyNamesSelect.test.tsx new file mode 100644 index 00000000000..56194acc8c8 --- /dev/null +++ b/frontend/src/lib/components/PropertyNamesSelect/PropertyNamesSelect.test.tsx @@ -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() + + 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() + + 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() + + 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, RestContext, GetPersonPropertiesResponse> +) => rest.get('/api/person/properties', handler) diff --git a/frontend/src/lib/components/PropertyNamesSelect/PropertyNamesSelect.tsx b/frontend/src/lib/components/PropertyNamesSelect/PropertyNamesSelect.tsx new file mode 100644 index 00000000000..c049f473a51 --- /dev/null +++ b/frontend/src/lib/components/PropertyNamesSelect/PropertyNamesSelect.tsx @@ -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 ? ( +
+ Error loading properties! +
+ ) : properties ? ( + + + + ) : ( +
Loading properties...
+ ) +} + +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 ( +
+
+ {properties ? ( + <> + {selectState === 'all' ? ( + { + clearAll() + + if (onChange) { + onChange([]) + } + evt.stopPropagation() + }} + /> + ) : selectState === 'none' ? ( + { + selectAll() + + if (onChange) { + onChange(properties.map((property) => property.name)) + } + evt.stopPropagation() + }} + /> + ) : ( + { + selectAll() + + if (onChange) { + onChange(properties.map((property) => property.name)) + } + evt.stopPropagation() + }} + /> + )} + +
+ {selectedProperties.size} of {properties.length} selected +
+ + + + ) : ( + 'Loading properties' + )} +
+ {isSearchOpen ? ( +
+ +
+ ) : null} +
+ ) +} + +const PropertyNamesSearch = (): JSX.Element => { + const { properties, toggleProperty, isSelected } = useSelectedProperties() + const { filteredProperties, query, setQuery } = usePropertySearch(properties) + + return ( + <> + setQuery(value)} + allowClear + className="search-box" + placeholder="Search for properties" + prefix={} + /> +
+ {filteredProperties.length ? ( + filteredProperties.map((property) => ( + toggleProperty(property.name)} + > + {property.highlightedName} + + )) + ) : ( +

+ No properties match “{query}”. Refine your search to try again. +

+ )} +
+ + ) +} + +// 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(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(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('') + 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: ( + + {property.nameParts.map((part, index) => + part.toLowerCase() === query.toLowerCase() ? ( + {part} + ) : ( + {part} + ) + )} + + ), + })) + }, [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 + 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>( + new Set(properties.map((property) => property.name)) + ) + + const setAndNotify = (newSelectedProperties: Set): 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 ( + + {children} + + ) +} diff --git a/frontend/src/lib/components/PropertyNamesSelect/__stories__/PropertyNamesSelect.stories.tsx b/frontend/src/lib/components/PropertyNamesSelect/__stories__/PropertyNamesSelect.stories.tsx new file mode 100644 index 00000000000..1b3adef1af6 --- /dev/null +++ b/frontend/src/lib/components/PropertyNamesSelect/__stories__/PropertyNamesSelect.stories.tsx @@ -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 ( + console.log('Selected Properties', selectedProperties)} + /> + ) +} + +export const RequestFailure = (): JSX.Element => { + worker.use(mockGetPersonProperties((_, res, ctx) => res(ctx.delay(1500), ctx.status(500)))) + + return ( + console.log('Selected Properties', selectedProperties)} + /> + ) +} diff --git a/frontend/src/lib/components/PropertyNamesSelect/styles.scss b/frontend/src/lib/components/PropertyNamesSelect/styles.scss new file mode 100644 index 00000000000..5dee25de6be --- /dev/null +++ b/frontend/src/lib/components/PropertyNamesSelect/styles.scss @@ -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; + } + } + } +} diff --git a/frontend/src/lib/components/TaxonomicFilter/__stories__/TaxonomicFilter.stories.tsx b/frontend/src/lib/components/TaxonomicFilter/__stories__/TaxonomicFilter.stories.tsx index 1d93ec92d7a..fb30d5c4e69 100644 --- a/frontend/src/lib/components/TaxonomicFilter/__stories__/TaxonomicFilter.stories.tsx +++ b/frontend/src/lib/components/TaxonomicFilter/__stories__/TaxonomicFilter.stories.tsx @@ -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>('/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( - '/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('/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 => { ) } + +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type +export const mockGetPersonProperties = ( + handler: ResponseResolver, RestContext, GetPersonPropertiesResponse> +) => rest.get('/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, + RestContext, + GetPropertyDefinitionsResponse + > +) => + rest.get( + '/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, RestContext, GetCohortsResponse> +) => rest.get('/api/cohort/', handler) diff --git a/frontend/src/scenes/funnels/funnelLogic.ts b/frontend/src/scenes/funnels/funnelLogic.ts index ed096ad9b04..302413c54dd 100644 --- a/frontend/src/scenes/funnels/funnelLogic.ts +++ b/frontend/src/scenes/funnels/funnelLogic.ts @@ -131,15 +131,15 @@ export const funnelLogic = kea({ 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({ ) { actions.loadCorrelations() // Hardcoded for initial testing - actions.loadPropertyCorrelations('$browser, $os, $geoip_country_code') + actions.loadPropertyCorrelations(['$all']) } }, toggleVisibilityByBreakdown: ({ breakdownValue }) => { diff --git a/frontend/src/scenes/insights/InsightTabs/FunnelTab/FunnelPropertyCorrelationTable.tsx b/frontend/src/scenes/insights/InsightTabs/FunnelTab/FunnelPropertyCorrelationTable.tsx index a5fbd65ce3f..f1863204f28 100644 --- a/frontend/src/scenes/insights/InsightTabs/FunnelTab/FunnelPropertyCorrelationTable.tsx +++ b/frontend/src/scenes/insights/InsightTabs/FunnelTab/FunnelPropertyCorrelationTable.tsx @@ -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 { Correlation Analysis for: - loadPropertyCorrelations(value)} + loadPropertyCorrelations(selectedProperties)} /> { 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)) }), diff --git a/package.json b/package.json index ad785ec4bda..3a271eb0d4b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index cbcd1c36ef1..56ae989f3ad 100644 --- a/yarn.lock +++ b/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"