diff --git a/frontend/src/queries/Query/Query.stories.tsx b/frontend/src/queries/Query/Query.stories.tsx deleted file mode 100644 index e5bf828fb01b4..0000000000000 --- a/frontend/src/queries/Query/Query.stories.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react' -import { Query, QueryProps } from './Query' -import { useState } from 'react' -import { QueryEditor } from '~/queries/QueryEditor/QueryEditor' -import { examples } from '../examples' - -export default { - title: 'Queries/Query', - component: Query, - parameters: { - chromatic: { disableSnapshot: false }, - layout: 'fullscreen', - options: { showPanel: false }, - viewMode: 'story', - }, - argTypes: { - query: { defaultValue: {} }, - }, -} as ComponentMeta - -const BasicTemplate: ComponentStory = (props: QueryProps) => { - const [queryString, setQueryString] = useState(JSON.stringify(props.query)) - - return ( - <> - -
- -
- - ) -} - -export const Events = BasicTemplate.bind({}) -Events.args = { query: examples['Events'] } - -export const EventsTable = BasicTemplate.bind({}) -EventsTable.args = { query: examples['EventsTable'] } - -export const LegacyTrendsQuery = BasicTemplate.bind({}) -LegacyTrendsQuery.args = { query: examples['LegacyTrendsQuery'] } - -export const InsightTrendsQuery = BasicTemplate.bind({}) -InsightTrendsQuery.args = { query: examples['InsightTrendsQuery'] } diff --git a/frontend/src/queries/QueryRunner/QueryRunner.tsx b/frontend/src/queries/QueryRunner/QueryRunner.tsx new file mode 100644 index 0000000000000..3f601949f91be --- /dev/null +++ b/frontend/src/queries/QueryRunner/QueryRunner.tsx @@ -0,0 +1,30 @@ +import { Query } from '~/queries/Query/Query' +import { useEffect, useState } from 'react' +import { QueryEditor } from '~/queries/QueryEditor/QueryEditor' +import { Node } from '~/queries/schema' + +export interface QueryRunnerProps { + /** The query to render */ + query: Node | string +} + +export function QueryRunner(props: QueryRunnerProps): JSX.Element { + const [queryString, setQueryString] = useState( + typeof props.query === 'string' ? props.query : JSON.stringify(props.query) + ) + useEffect(() => { + const newQueryString = typeof props.query === 'string' ? props.query : JSON.stringify(props.query) + if (newQueryString !== queryString) { + setQueryString(newQueryString) + } + }, [queryString]) + + return ( + <> + +
+ +
+ + ) +} diff --git a/frontend/src/queries/examples.ts b/frontend/src/queries/examples.ts index 3f2356c5ffa23..f4237d0f220c6 100644 --- a/frontend/src/queries/examples.ts +++ b/frontend/src/queries/examples.ts @@ -1,29 +1,29 @@ // This file contains example queries, used in storybook and in the /query interface. import { - EventsNode, + ActionsNode, DataTableNode, + EventsNode, + FunnelsQuery, LegacyQuery, + LifecycleQuery, Node, NodeKind, - TrendsQuery, - FunnelsQuery, - RetentionQuery, - ActionsNode, PathsQuery, + PersonsNode, + RetentionQuery, StickinessQuery, - LifecycleQuery, + TrendsQuery, } from '~/queries/schema' import { ChartDisplayType, + FilterLogicalOperator, InsightType, PropertyFilterType, + PropertyGroupFilter, PropertyOperator, - // PropertyMathType, - FilterLogicalOperator, StepOrderValue, - PropertyGroupFilter, } from '~/types' -import { defaultDataTableStringColumns } from '~/queries/nodes/DataTable/defaults' +import { defaultDataTableColumns } from '~/queries/nodes/DataTable/defaults' import { ShownAsValue } from '~/lib/constants' const Events: EventsNode = { @@ -36,9 +36,39 @@ const Events: EventsNode = { const EventsTable: DataTableNode = { kind: NodeKind.DataTableNode, - columns: defaultDataTableStringColumns, + columns: defaultDataTableColumns({ kind: NodeKind.EventsNode }), source: Events, } +const EventsTableFull: DataTableNode = { + ...EventsTable, + showPropertyFilter: true, + showEventFilter: true, + showExport: true, + showReload: true, + showColumnConfigurator: true, + showEventsBufferWarning: true, +} + +const Persons: PersonsNode = { + kind: NodeKind.PersonsNode, + properties: [ + { type: PropertyFilterType.Person, key: '$browser', operator: PropertyOperator.Exact, value: 'Chrome' }, + ], +} + +const PersonsTable: DataTableNode = { + kind: NodeKind.DataTableNode, + columns: defaultDataTableColumns({ kind: NodeKind.PersonsNode }), + source: Persons, +} + +const PersonsTableFull: DataTableNode = { + ...PersonsTable, + showSearch: true, + showPropertyFilter: true, + showExport: true, + showReload: true, +} const LegacyTrendsQuery: LegacyQuery = { kind: NodeKind.LegacyQuery, @@ -196,6 +226,10 @@ const InsightLifecycleQuery: LifecycleQuery = { export const examples: Record = { Events, EventsTable, + EventsTableFull, + Persons, + PersonsTable, + PersonsTableFull, LegacyTrendsQuery, InsightTrendsQuery, InsightFunnelsQuery, diff --git a/frontend/src/queries/nodes/DataNode/DataNode.stories.tsx b/frontend/src/queries/nodes/DataNode/DataNode.stories.tsx new file mode 100644 index 0000000000000..8e0169a374aac --- /dev/null +++ b/frontend/src/queries/nodes/DataNode/DataNode.stories.tsx @@ -0,0 +1,36 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react' +import { QueryRunner } from '~/queries/QueryRunner/QueryRunner' +import { examples } from '~/queries/examples' +import { mswDecorator } from '~/mocks/browser' +import events from './__mocks__/EventsNode.json' +import persons from './__mocks__/PersonsNode.json' + +export default { + title: 'Queries/DataNode', + component: QueryRunner, + parameters: { + chromatic: { disableSnapshot: false }, + layout: 'fullscreen', + options: { showPanel: false }, + viewMode: 'story', + }, + argTypes: { + query: { defaultValue: {} }, + }, + decorators: [ + mswDecorator({ + get: { + '/api/projects/:projectId/events': events, + '/api/projects/:projectId/persons': persons, + }, + }), + ], +} as ComponentMeta + +const QueryTemplate: ComponentStory = QueryRunner + +export const Events = QueryTemplate.bind({}) +Events.args = { query: examples['Events'] } + +export const Persons = QueryTemplate.bind({}) +Persons.args = { query: examples['Persons'] } diff --git a/frontend/src/queries/nodes/DataNode/DataNode.tsx b/frontend/src/queries/nodes/DataNode/DataNode.tsx index ac5cb3cc1d8ea..c272efb00084c 100644 --- a/frontend/src/queries/nodes/DataNode/DataNode.tsx +++ b/frontend/src/queries/nodes/DataNode/DataNode.tsx @@ -1,13 +1,15 @@ import MonacoEditor from '@monaco-editor/react' import { useState } from 'react' import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer' -import { DataNode as DataNodeType } from '~/queries/schema' +import { DataNode as DataNodeType, DataTableNode, Node } from '~/queries/schema' import { useValues } from 'kea' import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic' import { Spinner } from 'lib/components/Spinner/Spinner' +import { InlineEditorButton } from '~/queries/nodes/Node/InlineEditorButton' interface DataNodeProps { query: DataNodeType + setQuery?: (node: DataTableNode) => void } let uniqueNode = 0 @@ -18,19 +20,28 @@ export function DataNode(props: DataNodeProps): JSX.Element { const logic = dataNodeLogic({ ...props, key }) const { response, responseLoading } = useValues(logic) - return responseLoading ? ( - - ) : ( - - {({ height }) => ( - + return ( +
+
+ void} /> +
+ {responseLoading ? ( +
+ +
+ ) : ( + + {({ height }) => ( + + )} + )} - +
) } diff --git a/frontend/src/queries/nodes/DataTable/__mocks__/EventsNode.json b/frontend/src/queries/nodes/DataNode/__mocks__/EventsNode.json similarity index 100% rename from frontend/src/queries/nodes/DataTable/__mocks__/EventsNode.json rename to frontend/src/queries/nodes/DataNode/__mocks__/EventsNode.json diff --git a/frontend/src/queries/nodes/DataNode/__mocks__/PersonsNode.json b/frontend/src/queries/nodes/DataNode/__mocks__/PersonsNode.json new file mode 100644 index 0000000000000..6220c72e6487a --- /dev/null +++ b/frontend/src/queries/nodes/DataNode/__mocks__/PersonsNode.json @@ -0,0 +1,58 @@ +{ + "results": [ + { + "type": "person", + "id": "0184ae06-9d4a-0000-2b26-a771725cc751", + "uuid": "0184ae06-9d4a-0000-2b26-a771725cc751", + "created_at": "2022-11-25T09:02:59.900000Z", + "properties": { + "$os": "Mac OS X", + "email": "marius@posthog.com", + "$browser": "Chrome", + "$referrer": "http://localhost:8000/events", + "$initial_os": "Mac OS X", + "$geoip_latitude": -33.8715, + "$browser_version": 107, + "$geoip_city_name": "Sydney", + "$geoip_longitude": 151.2006, + "$geoip_time_zone": "Australia/Sydney", + "$initial_browser": "Chrome", + "$initial_pathname": "/ingestion", + "$initial_referrer": "http://localhost:8000/dashboard", + "$referring_domain": "localhost:8000", + "$geoip_postal_code": "2000", + "$geoip_country_code": "AU", + "$geoip_country_name": "Australia", + "$initial_current_url": "http://localhost:8000/ingestion", + "$initial_device_type": "Desktop", + "$geoip_continent_code": "OC", + "$geoip_continent_name": "Oceania", + "$initial_geoip_latitude": -33.8715, + "$initial_browser_version": 107, + "$initial_geoip_city_name": "Sydney", + "$initial_geoip_longitude": 151.2006, + "$initial_geoip_time_zone": "Australia/Sydney", + "$geoip_subdivision_1_code": "NSW", + "$geoip_subdivision_1_name": "New South Wales", + "$initial_referring_domain": "localhost:8000", + "$initial_geoip_postal_code": "2000", + "$initial_geoip_country_code": "AU", + "$initial_geoip_country_name": "Australia", + "$initial_geoip_continent_code": "OC", + "$initial_geoip_continent_name": "Oceania", + "$initial_geoip_subdivision_1_code": "NSW", + "$initial_geoip_subdivision_1_name": "New South Wales" + }, + "is_identified": true, + "name": "marius@posthog.com", + "distinct_ids": [ + "184ae069d181757-0c7310c8ee02fe-18525635-201b88-184ae069d192e7c", + "FXFWiYV3IHFXVo2DuWESQmHvywBwAEIJpvr0KrHiM2J" + ], + "matched_recordings": [], + "value_at_data_point": null + } + ], + "next": null, + "previous": null +} diff --git a/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts b/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts index dac99ede76b4c..892c3c5f09c7d 100644 --- a/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts +++ b/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts @@ -3,7 +3,7 @@ import { loaders } from 'kea-loaders' import type { dataNodeLogicType } from './dataNodeLogicType' import { DataNode, EventsNode } from '~/queries/schema' import { query } from '~/queries/query' -import { isEventsNode } from '~/queries/utils' +import { isEventsNode, isPersonsNode } from '~/queries/utils' import { subscriptions } from 'kea-subscriptions' import { objectsEqual } from 'lib/utils' import clsx from 'clsx' @@ -124,7 +124,7 @@ export const dataNodeLogic = kea([ (s) => [s.query, s.response], (query, response) => { return ( - isEventsNode(query) && + (isEventsNode(query) || isPersonsNode(query)) && (response as EventsNode['response'])?.next && ((response as EventsNode['response'])?.results?.length ?? 0) > 0 ) diff --git a/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.tsx b/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.tsx index 51be31408e618..240efe0875294 100644 --- a/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.tsx +++ b/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.tsx @@ -20,8 +20,8 @@ import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { TeamMembershipLevel } from 'lib/constants' import { useState } from 'react' import { columnConfiguratorLogic, ColumnConfiguratorLogicProps } from './columnConfiguratorLogic' -import { defaultDataTableStringColumns } from '../defaults' -import { DataTableNode } from '~/queries/schema' +import { defaultDataTableColumns } from '../defaults' +import { DataTableNode, NodeKind } from '~/queries/schema' import { LemonModal } from 'lib/components/LemonModal' import { PropertyFilterType } from '~/types' import { PropertyFilterIcon } from 'lib/components/PropertyFilters/components/PropertyFilterIcon' @@ -104,7 +104,7 @@ function ColumnConfiguratorModal(): JSX.Element { } return ( -
+
{columnType && } @@ -160,7 +160,10 @@ function ColumnConfiguratorModal(): JSX.Element { footer={ <>
- setColumns(defaultDataTableStringColumns)}> + setColumns(defaultDataTableColumns({ kind: NodeKind.EventsNode }))} + > Reset to defaults
diff --git a/frontend/src/queries/nodes/DataTable/DataTable.examples.ts b/frontend/src/queries/nodes/DataTable/DataTable.examples.ts index 5ee238ddf6cbc..7945789ac27d3 100644 --- a/frontend/src/queries/nodes/DataTable/DataTable.examples.ts +++ b/frontend/src/queries/nodes/DataTable/DataTable.examples.ts @@ -1,5 +1,6 @@ -import { DataTableNode, NodeKind } from '~/queries/schema' +import { DataTableNode, NodeKind, PersonsNode } from '~/queries/schema' import { PropertyFilterType, PropertyOperator } from '~/types' +import { defaultDataTableColumns } from '~/queries/nodes/DataTable/defaults' const AllDefaults: DataTableNode = { kind: NodeKind.DataTableNode, @@ -83,4 +84,27 @@ const ShowAllTheThings: DataTableNode = { showEventsBufferWarning: true, } -export const examples = { AllDefaults, Minimalist, ManyColumns, ShowFilters, ShowTools, ShowAllTheThings } +const Persons: PersonsNode = { + kind: NodeKind.PersonsNode, +} + +const PersonsTable: DataTableNode = { + kind: NodeKind.DataTableNode, + source: Persons, + columns: defaultDataTableColumns(Persons), + showSearch: true, + showPropertyFilter: true, + showExport: true, + showReload: true, +} + +export const examples = { + AllDefaults, + Minimalist, + ManyColumns, + ShowFilters, + ShowTools, + ShowAllTheThings, + Persons, + PersonsTable, +} diff --git a/frontend/src/queries/nodes/DataTable/DataTable.stories.tsx b/frontend/src/queries/nodes/DataTable/DataTable.stories.tsx index c4b9fe39bc2e5..8e5b3555fa9cd 100644 --- a/frontend/src/queries/nodes/DataTable/DataTable.stories.tsx +++ b/frontend/src/queries/nodes/DataTable/DataTable.stories.tsx @@ -1,10 +1,10 @@ import { ComponentMeta, ComponentStory } from '@storybook/react' -import { Query, QueryProps } from '~/queries/Query/Query' -import { useState } from 'react' -import { QueryEditor } from '~/queries/QueryEditor/QueryEditor' +import { Query } from '~/queries/Query/Query' import { examples } from './DataTable.examples' import { mswDecorator } from '~/mocks/browser' -import events from './__mocks__/EventsNode.json' +import events from '../DataNode/__mocks__/EventsNode.json' +import persons from '../DataNode/__mocks__/PersonsNode.json' +import { QueryRunner } from '~/queries/QueryRunner/QueryRunner' export default { title: 'Queries/DataTable', @@ -22,38 +22,34 @@ export default { mswDecorator({ get: { '/api/projects/:projectId/events': events, + '/api/projects/:projectId/persons': persons, }, }), ], } as ComponentMeta -const BasicTemplate: ComponentStory = (props: QueryProps) => { - const [queryString, setQueryString] = useState(JSON.stringify(props.query)) +const QueryTemplate: ComponentStory = QueryRunner - return ( - <> - -
- -
- - ) -} - -export const AllDefaults = BasicTemplate.bind({}) +export const AllDefaults = QueryTemplate.bind({}) AllDefaults.args = { query: examples['AllDefaults'] } -export const Minimalist = BasicTemplate.bind({}) +export const Minimalist = QueryTemplate.bind({}) Minimalist.args = { query: examples['Minimalist'] } -export const ManyColumns = BasicTemplate.bind({}) +export const ManyColumns = QueryTemplate.bind({}) ManyColumns.args = { query: examples['ManyColumns'] } -export const ShowFilters = BasicTemplate.bind({}) +export const ShowFilters = QueryTemplate.bind({}) ShowFilters.args = { query: examples['ShowFilters'] } -export const ShowTools = BasicTemplate.bind({}) +export const ShowTools = QueryTemplate.bind({}) ShowTools.args = { query: examples['ShowTools'] } -export const ShowAllTheThings = BasicTemplate.bind({}) +export const ShowAllTheThings = QueryTemplate.bind({}) ShowAllTheThings.args = { query: examples['ShowAllTheThings'] } + +export const Persons = QueryTemplate.bind({}) +Persons.args = { query: examples['Persons'] } + +export const PersonsTable = QueryTemplate.bind({}) +PersonsTable.args = { query: examples['PersonsTable'] } diff --git a/frontend/src/queries/nodes/DataTable/DataTable.tsx b/frontend/src/queries/nodes/DataTable/DataTable.tsx index e05a238e2284c..ac9b58f7cdf63 100644 --- a/frontend/src/queries/nodes/DataTable/DataTable.tsx +++ b/frontend/src/queries/nodes/DataTable/DataTable.tsx @@ -1,5 +1,5 @@ import './DataTable.scss' -import { DataTableNode, EventsNode, Node, QueryContext } from '~/queries/schema' +import { DataTableNode, EventsNode, Node, PersonsNode, QueryContext } from '~/queries/schema' import { useState } from 'react' import { useValues, BindLogic } from 'kea' import { dataNodeLogic, DataNodeLogicProps } from '~/queries/nodes/DataNode/dataNodeLogic' @@ -18,12 +18,15 @@ import { AutoLoad } from '~/queries/nodes/DataNode/AutoLoad' import { dataTableLogic, DataTableLogicProps } from '~/queries/nodes/DataTable/dataTableLogic' import { ColumnConfigurator } from '~/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator' import { teamLogic } from 'scenes/teamLogic' -import { defaultDataTableStringColumns } from '~/queries/nodes/DataTable/defaults' import { LemonDivider } from 'lib/components/LemonDivider' import { EventBufferNotice } from 'scenes/events/EventBufferNotice' import clsx from 'clsx' import { SessionPlayerModal } from 'scenes/session-recordings/player/modal/SessionPlayerModal' -import { InlineEditor } from '~/queries/nodes/Node/InlineEditor' +import { InlineEditorButton } from '~/queries/nodes/Node/InlineEditorButton' +import { isEventsNode, isPersonsNode } from '~/queries/utils' +import { PersonPropertyFilters } from '~/queries/nodes/PersonsNode/PersonPropertyFilters' +import { PersonsSearch } from '~/queries/nodes/PersonsNode/PersonsSearch' +import { PersonDeleteModal } from 'scenes/persons/PersonDeleteModal' interface DataTableProps { query: DataTableNode @@ -49,13 +52,14 @@ export function DataTable({ query, setQuery, context }: DataTableProps): JSX.Ele } = useValues(dataNodeLogic(dataNodeLogicProps)) const { currentTeam } = useValues(teamLogic) - const defaultColumns = currentTeam?.live_events_columns ?? defaultDataTableStringColumns + const defaultEventsColumns = currentTeam?.live_events_columns ?? undefined - const dataTableLogicProps: DataTableLogicProps = { query, key, defaultColumns } + const dataTableLogicProps: DataTableLogicProps = { query, key, defaultEventsColumns } const { columns, queryWithDefaults } = useValues(dataTableLogic(dataTableLogicProps)) const { showActions, + showSearch, showEventFilter, showPropertyFilter, showReload, @@ -73,7 +77,7 @@ export function DataTable({ query, setQuery, context }: DataTableProps): JSX.Ele return renderColumn(key, record, query, setQuery, context) }, })), - ...(showActions + ...(showActions && isEventsNode(query.source) ? [ { dataIndex: 'more' as any, @@ -86,9 +90,9 @@ export function DataTable({ query, setQuery, context }: DataTableProps): JSX.Ele : []), ] const dataSource = (response as null | EventsNode['response'])?.results ?? [] - const setQuerySource = (source: EventsNode): void => setQuery?.({ ...query, source }) + const setQuerySource = (source: EventsNode | PersonsNode): void => setQuery?.({ ...query, source }) - const showFilters = showEventFilter || showPropertyFilter + const showFilters = showSearch || showEventFilter || showPropertyFilter const showTools = showReload || showExport || showColumnConfigurator const inlineRow = showFilters ? 1 : showTools ? 2 : 0 @@ -98,14 +102,22 @@ export function DataTable({ query, setQuery, context }: DataTableProps): JSX.Ele
{showFilters && (
- {showEventFilter && } - {showPropertyFilter && ( + {showEventFilter && isEventsNode(query.source) && ( + + )} + {showSearch && isPersonsNode(query.source) && ( + + )} + {showPropertyFilter && isEventsNode(query.source) && ( )} + {showPropertyFilter && isPersonsNode(query.source) && ( + + )} {inlineRow === 1 ? ( <>
- void} /> @@ -117,25 +129,31 @@ export function DataTable({ query, setQuery, context }: DataTableProps): JSX.Ele {showTools && (
{showReload && (canLoadNewData ? : )}
- {showColumnConfigurator && } + {showColumnConfigurator && isEventsNode(query.source) && ( + + )} {showExport && } {inlineRow === 2 ? ( - void} /> + void} + /> ) : null}
)} - {showEventsBufferWarning && ( + {showEventsBufferWarning && isEventsNode(query.source) && ( )} {inlineRow === 0 ? (
- void} /> + void} />
) : null} {canLoadNextData && ((response as any).results.length > 0 || !responseLoading) && } + {/* TODO: this doesn't seem like the right solution... */} +
diff --git a/frontend/src/queries/nodes/DataTable/DataTableExport.tsx b/frontend/src/queries/nodes/DataTable/DataTableExport.tsx index 2f4eae29b9e8c..310949938f8b8 100644 --- a/frontend/src/queries/nodes/DataTable/DataTableExport.tsx +++ b/frontend/src/queries/nodes/DataTable/DataTableExport.tsx @@ -3,20 +3,25 @@ import { IconExport } from 'lib/components/icons' import { Popconfirm } from 'antd' import { triggerExport } from 'lib/components/ExportButton/exporter' import { ExporterFormat } from '~/types' -import api from 'lib/api' -import { DataTableNode } from '~/queries/schema' -import { defaultDataTableStringColumns } from '~/queries/nodes/DataTable/defaults' +import { DataNode, DataTableNode } from '~/queries/schema' +import { defaultDataTableColumns } from '~/queries/nodes/DataTable/defaults' +import { isEventsNode, isPersonsNode } from '~/queries/utils' +import { getEventsEndpoint, getPersonsEndpoint } from '~/queries/query' + +const EXPORT_LIMIT_EVENTS = 3500 +const EXPORT_LIMIT_PERSONS = 10000 function startDownload(query: DataTableNode, onlySelectedColumns: boolean): void { - const exportContext = { - path: api.events.determineListEndpoint( - { - ...(query.source.event ? { event: query.source.event } : {}), - ...(query.source.properties ? { properties: query.source.properties } : {}), - }, - 3500 - ), - max_limit: 3500, + const exportContext = isEventsNode(query.source) + ? { + path: getEventsEndpoint({ ...query.source, limit: EXPORT_LIMIT_EVENTS }), + max_limit: query.source.limit ?? EXPORT_LIMIT_EVENTS, + } + : isPersonsNode(query.source) + ? { path: getPersonsEndpoint(query.source), max_limit: EXPORT_LIMIT_PERSONS } + : undefined + if (!exportContext) { + throw new Error('Unsupported node type') } const columnMapping = { @@ -24,13 +29,15 @@ function startDownload(query: DataTableNode, onlySelectedColumns: boolean): void time: 'timestamp', event: 'event', source: 'properties.$lib', - person: ['person.distinct_ids.0', 'person.properties.email'], + person: isPersonsNode(query.source) + ? ['distinct_ids.0', 'properties.email'] + : ['person.distinct_ids.0', 'person.properties.email'], } if (onlySelectedColumns) { - exportContext['columns'] = (query.columns ?? defaultDataTableStringColumns)?.flatMap( - (c) => columnMapping[c] || c - ) + exportContext['columns'] = (query.columns ?? defaultDataTableColumns(query.source)) + ?.flatMap((c) => columnMapping[c] || c) + .filter((c) => c !== 'person.$delete') } triggerExport({ export_format: ExporterFormat.CSV, @@ -44,6 +51,12 @@ interface DataTableExportProps { } export function DataTableExport({ query }: DataTableExportProps): JSX.Element | null { + const source: DataNode = query.source + const filterCount = + (isEventsNode(source) || isPersonsNode(source) ? source.properties?.length || 0 : 0) + + (isEventsNode(source) && source.event ? 1 : 0) + + (isPersonsNode(source) && source.search ? 1 : 0) + return ( { startDownload(query, true) }} @@ -64,6 +78,7 @@ export function DataTableExport({ query }: DataTableExportProps): JSX.Element | startDownload(query, false)} > @@ -75,7 +90,7 @@ export function DataTableExport({ query }: DataTableExportProps): JSX.Element | type="secondary" icon={} > - Export + Export{filterCount > 0 ? ` (${filterCount} filter${filterCount === 1 ? '' : 's'})` : ''} ) } @@ -83,19 +98,22 @@ export function DataTableExport({ query }: DataTableExportProps): JSX.Element | interface ExportWithConfirmationProps { placement: 'topRight' | 'bottomRight' onConfirm: (e?: React.MouseEvent) => void + query: DataTableNode children: React.ReactNode } -function ExportWithConfirmation({ placement, onConfirm, children }: ExportWithConfirmationProps): JSX.Element { +function ExportWithConfirmation({ query, placement, onConfirm, children }: ExportWithConfirmationProps): JSX.Element { + const actor = isPersonsNode(query.source) ? 'events' : 'persons' + const limit = isPersonsNode(query.source) ? EXPORT_LIMIT_EVENTS : EXPORT_LIMIT_PERSONS return ( - Exporting by csv is limited to 3,500 events. + Exporting by csv is limited to {limit} {actor}.
- To return more, please use the API. Do you want to - export by CSV? + To return more, please use the API. Do you want + to export by CSV? } onConfirm={onConfirm} diff --git a/frontend/src/queries/nodes/DataTable/dataTableLogic.ts b/frontend/src/queries/nodes/DataTable/dataTableLogic.ts index ca699a83a6660..c58afa3d34cbb 100644 --- a/frontend/src/queries/nodes/DataTable/dataTableLogic.ts +++ b/frontend/src/queries/nodes/DataTable/dataTableLogic.ts @@ -1,46 +1,27 @@ import { actions, kea, key, path, props, propsChanged, reducers, selectors } from 'kea' import type { dataTableLogicType } from './dataTableLogicType' -import { DataTableNode, DataTableStringColumn } from '~/queries/schema' -import { defaultDataTableStringColumns } from './defaults' +import { DataTableNode, DataTableColumn } from '~/queries/schema' +import { defaultsForDataTable } from './defaults' import { sortedKeys } from 'lib/utils' export interface DataTableLogicProps { key: string query: DataTableNode - defaultColumns?: DataTableStringColumn[] + defaultEventsColumns?: DataTableColumn[] } export const dataTableLogic = kea([ props({} as DataTableLogicProps), key((props) => props.key), path(['queries', 'nodes', 'DataTable', 'dataTableLogic']), - actions({ setColumns: (columns: DataTableStringColumn[]) => ({ columns }) }), + actions({ setColumns: (columns: DataTableColumn[]) => ({ columns }) }), reducers(({ props }) => ({ - storedColumns: [ - (props.query.columns ?? props.defaultColumns ?? defaultDataTableStringColumns) as DataTableStringColumn[], + columns: [ + defaultsForDataTable(props.query, props.defaultEventsColumns), { setColumns: (_, { columns }) => columns }, ], })), selectors({ - columns: [ - (s) => [s.storedColumns], - (storedColumns) => { - // This makes old stored columns (e.g. on the Team model) compatible with the new view that prepends 'properties.' - const topLevelFields = ['event', 'timestamp', 'id', 'distinct_id', 'person', 'url'] - return storedColumns.map((column) => { - if ( - topLevelFields.includes(column) || - column.startsWith('person.properties.') || - column.startsWith('properties.') || - column.startsWith('context.') - ) { - return column - } else { - return `properties.${column}` - } - }) - }, - ], queryWithDefaults: [ (s) => [(_, props) => props.query, s.columns], (query: DataTableNode, columns): Required => { @@ -55,6 +36,7 @@ export const dataTableLogic = kea([ propertiesViaUrl: query.propertiesViaUrl ?? false, showPropertyFilter: query.showPropertyFilter ?? false, showEventFilter: query.showEventFilter ?? false, + showSearch: query.showSearch ?? false, showActions: query.showActions ?? true, showExport: query.showExport ?? false, showReload: query.showReload ?? false, @@ -66,8 +48,8 @@ export const dataTableLogic = kea([ ], }), propsChanged(({ actions, props }, oldProps) => { - const newColumns = props.query.columns ?? props.defaultColumns ?? defaultDataTableStringColumns - const oldColumns = oldProps.query.columns ?? oldProps.defaultColumns ?? defaultDataTableStringColumns + const newColumns = defaultsForDataTable(props.query, props.defaultEventsColumns) + const oldColumns = defaultsForDataTable(oldProps.query, oldProps.defaultEventsColumns) if (JSON.stringify(newColumns) !== JSON.stringify(oldColumns)) { actions.setColumns(newColumns) } diff --git a/frontend/src/queries/nodes/DataTable/defaults.ts b/frontend/src/queries/nodes/DataTable/defaults.ts index c90315ff7c2a3..5ac5dd87ac236 100644 --- a/frontend/src/queries/nodes/DataTable/defaults.ts +++ b/frontend/src/queries/nodes/DataTable/defaults.ts @@ -1,9 +1,22 @@ -import { DataTableStringColumn } from '~/queries/schema' +import { DataNode, DataTableColumn, DataTableNode, NodeKind } from '~/queries/schema' +import { isEventsNode } from '~/queries/utils' -export const defaultDataTableStringColumns: DataTableStringColumn[] = [ +export const defaultDataTableEventColumns: DataTableColumn[] = [ 'event', 'person', 'url', 'properties.$lib', 'timestamp', ] + +export const defaultDataTablePersonColumns: DataTableColumn[] = ['person', 'id', 'created_at', 'person.$delete'] + +export function defaultDataTableColumns(query: DataNode): DataTableColumn[] { + return query.kind === NodeKind.PersonsNode ? defaultDataTablePersonColumns : defaultDataTableEventColumns +} + +export function defaultsForDataTable(query: DataTableNode, defaultColumns?: DataTableColumn[]): DataTableColumn[] { + return ( + query.columns ?? (isEventsNode(query.source) ? defaultColumns : null) ?? defaultDataTableColumns(query.source) + ) +} diff --git a/frontend/src/queries/nodes/DataTable/renderColumn.tsx b/frontend/src/queries/nodes/DataTable/renderColumn.tsx index 18df6ca900cb6..c750942a06a2f 100644 --- a/frontend/src/queries/nodes/DataTable/renderColumn.tsx +++ b/frontend/src/queries/nodes/DataTable/renderColumn.tsx @@ -1,4 +1,4 @@ -import { AnyPropertyFilter, EventType, PropertyFilterType, PropertyOperator } from '~/types' +import { AnyPropertyFilter, EventType, PersonType, PropertyFilterType, PropertyOperator } from '~/types' import { autoCaptureEventToDescription } from 'lib/utils' import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' import { Link } from 'lib/components/Link' @@ -7,22 +7,25 @@ import { Property } from 'lib/components/Property' import { urls } from 'scenes/urls' import { PersonHeader } from 'scenes/persons/PersonHeader' import { DataTableNode, QueryContext } from '~/queries/schema' -import { isEventsNode } from '~/queries/utils' +import { isEventsNode, isPersonsNode } from '~/queries/utils' import { combineUrl, router } from 'kea-router' +import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' +import { DeletePersonButton } from '~/queries/nodes/PersonsNode/DeletePersonButton' export function renderColumn( key: string, - record: EventType, + record: EventType | PersonType, query: DataTableNode, setQuery?: (node: DataTableNode) => void, context?: QueryContext ): JSX.Element | string { - if (key === 'event') { - if (record.event === '$autocapture') { - return autoCaptureEventToDescription(record) + if (key === 'event' && isEventsNode(query.source)) { + const eventRecord = record as EventType + if (eventRecord.event === '$autocapture') { + return autoCaptureEventToDescription(eventRecord) } else { - const content = - const { $sentry_url } = record.properties + const content = + const { $sentry_url } = eventRecord.properties return $sentry_url ? ( {content} @@ -31,17 +34,17 @@ export function renderColumn( content ) } - } else if (key === 'timestamp') { - return + } else if (key === 'timestamp' || key === 'created_at') { + return } else if (key.startsWith('properties.') || key === 'url') { const propertyKey = key === 'url' ? (record.properties['$screen_name'] ? '$screen_name' : '$current_url') : key.substring(11) - if (setQuery && isEventsNode(query.source)) { + if (setQuery && (isEventsNode(query.source) || isPersonsNode(query.source)) && query.showPropertyFilter) { const newProperty: AnyPropertyFilter = { key: propertyKey, value: record.properties[propertyKey], operator: PropertyOperator.Exact, - type: PropertyFilterType.Event, + type: isPersonsNode(query.source) ? PropertyFilterType.Person : PropertyFilterType.Event, } const matchingProperty = (query.source.properties || []).find( (p) => p.key === newProperty.key && p.type === newProperty.type @@ -80,11 +83,12 @@ export function renderColumn( } return } else if (key.startsWith('person.properties.')) { + const eventRecord = record as EventType const propertyKey = key.substring(18) if (setQuery && isEventsNode(query.source)) { const newProperty: AnyPropertyFilter = { key: propertyKey, - value: record.person?.properties[propertyKey], + value: eventRecord.person?.properties[propertyKey], operator: PropertyOperator.Exact, type: PropertyFilterType.Person, } @@ -119,20 +123,41 @@ export function renderColumn( }) }} > - + ) } - return - } else if (key === 'person') { + return + } else if (key === 'person' && isEventsNode(query.source)) { + const eventRecord = record as EventType + return ( + + + + ) + } else if (key === 'person' && isPersonsNode(query.source)) { + const personRecord = record as PersonType return ( - - + + ) + } else if (key === 'person.$delete' && isPersonsNode(query.source)) { + const personRecord = record as PersonType + return } else if (key.startsWith('context.columns.')) { const Component = context?.columns?.[key.substring(16)]?.render return Component ? : '' + } else if (key === 'id' && isPersonsNode(query.source)) { + return ( + + {String(record[key])} + + ) } else { return String(record[key]) } diff --git a/frontend/src/queries/nodes/DataTable/renderTitle.tsx b/frontend/src/queries/nodes/DataTable/renderTitle.tsx index 61ade6ee914eb..39b9e4c4b263e 100644 --- a/frontend/src/queries/nodes/DataTable/renderTitle.tsx +++ b/frontend/src/queries/nodes/DataTable/renderTitle.tsx @@ -3,9 +3,10 @@ import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' import { QueryContext } from '~/queries/schema' export function renderTitle(key: string, context?: QueryContext): JSX.Element | string { - console.log(key) if (key === 'timestamp') { return 'Time' + } else if (key === 'created_at') { + return 'First seen' } else if (key === 'event') { return 'Event' } else if (key === 'person') { @@ -16,6 +17,8 @@ export function renderTitle(key: string, context?: QueryContext): JSX.Element | return } else if (key.startsWith('context.columns.')) { return context?.columns?.[key.substring(16)]?.title ?? key.substring(16).replace('_', ' ') + } else if (key === 'person.$delete') { + return '' } else if (key.startsWith('person.properties.')) { // NOTE: PropertyFilterType.Event is not a mistake. PropertyKeyInfo only knows events vs elements ¯\_(ツ)_/¯ return diff --git a/frontend/src/queries/nodes/Node/InlineEditor.tsx b/frontend/src/queries/nodes/Node/InlineEditorButton.tsx similarity index 89% rename from frontend/src/queries/nodes/Node/InlineEditor.tsx rename to frontend/src/queries/nodes/Node/InlineEditorButton.tsx index 38cf311313f57..399bbb886458b 100644 --- a/frontend/src/queries/nodes/Node/InlineEditor.tsx +++ b/frontend/src/queries/nodes/Node/InlineEditorButton.tsx @@ -6,12 +6,12 @@ import { Node } from '~/queries/schema' import { Drawer } from 'lib/components/Drawer' import { urls } from 'scenes/urls' -export interface InlineEditorProps { +export interface InlineEditorButtonProps { query: Node setQuery?: (query: Node) => void } -export function InlineEditor({ query, setQuery }: InlineEditorProps): JSX.Element { +export function InlineEditorButton({ query, setQuery }: InlineEditorButtonProps): JSX.Element { const [open, setOpen] = useState(false) return ( diff --git a/frontend/src/queries/nodes/PersonsNode/DeletePersonButton.tsx b/frontend/src/queries/nodes/PersonsNode/DeletePersonButton.tsx new file mode 100644 index 0000000000000..40b3b53217ba6 --- /dev/null +++ b/frontend/src/queries/nodes/PersonsNode/DeletePersonButton.tsx @@ -0,0 +1,22 @@ +import { LemonButton } from 'lib/components/LemonButton' +import { IconDelete } from 'lib/components/icons' +import { useActions } from 'kea' +import { PersonType } from '~/types' +import { personDeleteModalLogic } from 'scenes/persons/personDeleteModalLogic' +import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic' + +interface DeletePersonButtonProps { + person: PersonType +} +export function DeletePersonButton({ person }: DeletePersonButtonProps): JSX.Element { + const { showPersonDeleteModal } = useActions(personDeleteModalLogic) + const { loadData } = useActions(dataNodeLogic) + return ( + showPersonDeleteModal(person, () => loadData())} + icon={} + status="danger" + size="small" + /> + ) +} diff --git a/frontend/src/queries/nodes/PersonsNode/PersonPropertyFilters.tsx b/frontend/src/queries/nodes/PersonsNode/PersonPropertyFilters.tsx new file mode 100644 index 0000000000000..1749ed78ed6d8 --- /dev/null +++ b/frontend/src/queries/nodes/PersonsNode/PersonPropertyFilters.tsx @@ -0,0 +1,26 @@ +import { PersonsNode } from '~/queries/schema' +import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters' +import { AnyPropertyFilter } from '~/types' +import { useState } from 'react' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' + +interface PersonPropertyFiltersProps { + query: PersonsNode + setQuery?: (node: PersonsNode) => void +} + +let uniqueNode = 0 +export function PersonPropertyFilters({ query, setQuery }: PersonPropertyFiltersProps): JSX.Element { + const [id] = useState(uniqueNode++) + return !query.properties || Array.isArray(query.properties) ? ( + setQuery?.({ ...query, properties: value })} + pageKey={`PersonPropertyFilters.${id}`} + taxonomicGroupTypes={[TaxonomicFilterGroupType.PersonProperties]} + style={{ marginBottom: 0, marginTop: 0 }} + /> + ) : ( +
Error: property groups are not supported.
+ ) +} diff --git a/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx b/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx new file mode 100644 index 0000000000000..be4bc642461ae --- /dev/null +++ b/frontend/src/queries/nodes/PersonsNode/PersonsSearch.tsx @@ -0,0 +1,34 @@ +import { PersonsNode } from '~/queries/schema' +import { LemonInput } from 'lib/components/LemonInput/LemonInput' +import { IconInfo } from 'lib/components/icons' +import { Tooltip } from 'lib/components/Tooltip' + +interface PersonSearchProps { + query: PersonsNode + setQuery?: (node: PersonsNode) => void +} + +export function PersonsSearch({ query, setQuery }: PersonSearchProps): JSX.Element { + return ( +
+ setQuery?.({ ...query, search: value })} + /> + + Search by email or Distinct ID. Email will match partially, for example: "@gmail.com". Distinct + ID needs to match exactly. + + } + > + + +
+ ) +} diff --git a/frontend/src/queries/query.ts b/frontend/src/queries/query.ts index 66100c9dec657..d4f6b70aa5264 100644 --- a/frontend/src/queries/query.ts +++ b/frontend/src/queries/query.ts @@ -1,5 +1,5 @@ -import { DataNode } from './schema' -import { isEventsNode, isLegacyQuery } from './utils' +import { DataNode, EventsNode, PersonsNode } from './schema' +import { isEventsNode, isLegacyQuery, isPersonsNode } from './utils' import api, { ApiMethodOptions } from 'lib/api' import { getCurrentTeamId } from 'lib/utils/logics' import { AnyPartialFilterType } from '~/types' @@ -20,17 +20,9 @@ export async function query( methodOptions?: ApiMethodOptions ): Promise { if (isEventsNode(query)) { - return await api.events.list( - { - properties: [...(query.fixedProperties || []), ...(query.properties || [])], - ...(query.event ? { event: query.event } : {}), - ...(query.actionId ? { action_id: query.actionId } : {}), - ...(query.personId ? { person_id: query.personId } : {}), - before: query.before, - after: query.after, - }, - query.limit - ) + return await api.get(getEventsEndpoint(query)) + } else if (isPersonsNode(query)) { + return await api.get(getPersonsEndpoint(query)) } else if (isLegacyQuery(query)) { const [response] = await legacyInsightQuery({ filters: query.filters, @@ -42,6 +34,29 @@ export async function query( throw new Error(`Unsupported query: ${query.kind}`) } +export function getEventsEndpoint(query: EventsNode): string { + return api.events.determineListEndpoint( + { + properties: [...(query.fixedProperties || []), ...(query.properties || [])], + ...(query.event ? { event: query.event } : {}), + ...(query.actionId ? { action_id: query.actionId } : {}), + ...(query.personId ? { person_id: query.personId } : {}), + ...(query.before ? { before: query.before } : {}), + ...(query.after ? { after: query.after } : {}), + }, + query.limit ?? 3500 + ) +} + +export function getPersonsEndpoint(query: PersonsNode): string { + return api.persons.determineListUrl({ + properties: [...(query.fixedProperties || []), ...(query.properties || [])], + ...(query.search ? { search: query.search } : {}), + ...(query.cohort ? { cohort: query.cohort } : {}), + ...(query.distinctId ? { distinct_id: query.distinctId } : {}), + }) +} + interface LegacyInsightQueryParams { filters: AnyPartialFilterType currentTeamId: number diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 5c6315e1ee4d1..df64950e34555 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -1255,13 +1255,16 @@ ], "type": "string" }, + "DataTableColumn": { + "type": "string" + }, "DataTableNode": { "additionalProperties": false, "properties": { "columns": { "description": "Columns shown in the table", "items": { - "$ref": "#/definitions/DataTableStringColumn" + "$ref": "#/definitions/DataTableColumn" }, "type": "array" }, @@ -1282,11 +1285,11 @@ "type": "boolean" }, "showColumnConfigurator": { - "description": "Show a button to configure the table's columns", + "description": "Show a button to configure the table's columns if possible", "type": "boolean" }, "showEventFilter": { - "description": "Include an event filter above the table (default: true)", + "description": "Include an event filter above the table (EventsNode only)", "type": "boolean" }, "showEventsBufferWarning": { @@ -1298,24 +1301,32 @@ "type": "boolean" }, "showPropertyFilter": { - "description": "Include a property filter above the table (default: true)", + "description": "Include a property filter above the table", "type": "boolean" }, "showReload": { "description": "Show a reload button", "type": "boolean" }, + "showSearch": { + "description": "Include a free text search field (PersonsNode only)", + "type": "boolean" + }, "source": { - "$ref": "#/definitions/EventsNode", + "anyOf": [ + { + "$ref": "#/definitions/EventsNode" + }, + { + "$ref": "#/definitions/PersonsNode" + } + ], "description": "Source of the events" } }, "required": ["kind", "source"], "type": "object" }, - "DataTableStringColumn": { - "type": "string" - }, "DateRange": { "additionalProperties": false, "properties": { @@ -1902,6 +1913,44 @@ "required": ["kind"], "type": "object" }, + "PersonsNode": { + "additionalProperties": false, + "properties": { + "cohort": { + "type": "number" + }, + "distinctId": { + "type": "string" + }, + "fixedProperties": { + "description": "Fixed properties in the query, can't be edited in the interface (e.g. scoping down by person)", + "items": { + "$ref": "#/definitions/AnyPropertyFilter" + }, + "type": "array" + }, + "kind": { + "const": "PersonsNode", + "type": "string" + }, + "properties": { + "description": "Properties configurable in the interface", + "items": { + "$ref": "#/definitions/AnyPropertyFilter" + }, + "type": "array" + }, + "response": { + "description": "Cached query response", + "type": "object" + }, + "search": { + "type": "string" + } + }, + "required": ["kind"], + "type": "object" + }, "PropertyFilterValue": { "anyOf": [ { @@ -1997,6 +2046,9 @@ { "$ref": "#/definitions/ActionsNode" }, + { + "$ref": "#/definitions/PersonsNode" + }, { "$ref": "#/definitions/DataTableNode" }, diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 0cb0a72fb357a..1218631d15f4d 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -23,6 +23,7 @@ export enum NodeKind { // Data nodes EventsNode = 'EventsNode', ActionsNode = 'ActionsNode', + PersonsNode = 'PersonsNode', // Interface nodes DataTableNode = 'DataTableNode', @@ -41,6 +42,7 @@ export type QuerySchema = // Data nodes (see utils.ts) | EventsNode | ActionsNode + | PersonsNode // Interface nodes | DataTableNode @@ -101,17 +103,30 @@ export interface ActionsNode extends EntityNode { id: number } +export interface PersonsNode extends DataNode { + kind: NodeKind.PersonsNode + search?: string + cohort?: number + distinctId?: string + /** Properties configurable in the interface */ + properties?: AnyPropertyFilter[] + /** Fixed properties in the query, can't be edited in the interface (e.g. scoping down by person) */ + fixedProperties?: AnyPropertyFilter[] +} + // Data table node export interface DataTableNode extends Node { kind: NodeKind.DataTableNode /** Source of the events */ - source: EventsNode + source: EventsNode | PersonsNode /** Columns shown in the table */ - columns?: DataTableStringColumn[] - /** Include an event filter above the table (default: true) */ + columns?: DataTableColumn[] + /** Include an event filter above the table (EventsNode only) */ showEventFilter?: boolean - /** Include a property filter above the table (default: true) */ + /** Include a free text search field (PersonsNode only) */ + showSearch?: boolean + /** Include a property filter above the table */ showPropertyFilter?: boolean /** Show the kebab menu at the end of the row */ showActions?: boolean @@ -119,7 +134,7 @@ export interface DataTableNode extends Node { showExport?: boolean /** Show a reload button */ showReload?: boolean - /** Show a button to configure the table's columns */ + /** Show a button to configure the table's columns if possible */ showColumnConfigurator?: boolean /** Can expand row to show raw event data (default: true) */ expandable?: boolean @@ -201,7 +216,7 @@ export type InsightQueryNode = | LifecycleQuery export type InsightNodeKind = InsightQueryNode['kind'] -export type DataTableStringColumn = string +export type DataTableColumn = string // Legacy queries diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts index 0d975d942467a..35909be0f2542 100644 --- a/frontend/src/queries/utils.ts +++ b/frontend/src/queries/utils.ts @@ -12,10 +12,11 @@ import { Node, NodeKind, InsightQueryNode, + PersonsNode, } from '~/queries/schema' -export function isDataNode(node?: Node): node is EventsNode | ActionsNode { - return isEventsNode(node) +export function isDataNode(node?: Node): node is EventsNode | ActionsNode | PersonsNode { + return isEventsNode(node) || isActionsNode(node) || isPersonsNode(node) } export function isEventsNode(node?: Node): node is EventsNode { @@ -26,6 +27,10 @@ export function isActionsNode(node?: Node): node is ActionsNode { return node?.kind === NodeKind.ActionsNode } +export function isPersonsNode(node?: Node): node is PersonsNode { + return node?.kind === NodeKind.PersonsNode +} + export function isDataTableNode(node?: Node): node is DataTableNode { return node?.kind === NodeKind.DataTableNode } diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index 48db5e12dda78..87021021e36b6 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -22,7 +22,7 @@ export const appScenes: Record any> = { [Scene.SessionRecording]: () => import('./session-recordings/detail/SessionRecordingDetail'), [Scene.SessionRecordingPlaylist]: () => import('./session-recordings/playlist/SessionRecordingsPlaylist'), [Scene.Person]: () => import('./persons/Person'), - [Scene.Persons]: () => import('./persons/Persons'), + [Scene.Persons]: () => import('./persons/PersonsScene'), [Scene.Groups]: () => import('./groups/Groups'), [Scene.Group]: () => import('./groups/Group'), [Scene.Action]: () => import('./actions/Action'), // TODO diff --git a/frontend/src/scenes/cohorts/CohortEdit.tsx b/frontend/src/scenes/cohorts/CohortEdit.tsx index 1f623d06501f5..70ad45b833e00 100644 --- a/frontend/src/scenes/cohorts/CohortEdit.tsx +++ b/frontend/src/scenes/cohorts/CohortEdit.tsx @@ -12,7 +12,7 @@ import { LemonInput } from 'lib/components/LemonInput/LemonInput' import { Tooltip } from 'lib/components/Tooltip' import { LemonSelect } from 'lib/components/LemonSelect' import { COHORT_TYPE_OPTIONS } from 'scenes/cohorts/CohortFilters/constants' -import { CohortTypeEnum } from 'lib/constants' +import { CohortTypeEnum, FEATURE_FLAGS } from 'lib/constants' import { AvailableFeature } from '~/types' import { LemonTextArea } from 'lib/components/LemonTextArea/LemonTextArea' import Dragger from 'antd/lib/upload/Dragger' @@ -25,6 +25,9 @@ import { Persons } from 'scenes/persons/Persons' import { LemonLabel } from 'lib/components/LemonLabel/LemonLabel' import { Form } from 'kea-forms' import { NotFound } from 'lib/components/NotFound' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { NodeKind } from '~/queries/schema' +import { Query } from '~/queries/Query/Query' import { pluralize } from 'lib/utils' export function CohortEdit({ id }: CohortLogicProps): JSX.Element { @@ -34,6 +37,8 @@ export function CohortEdit({ id }: CohortLogicProps): JSX.Element { const { cohort, cohortLoading, cohortMissing } = useValues(logic) const { hasAvailableFeature } = useValues(userLogic) const isNewCohort = cohort.id === 'new' || cohort.id === undefined + const { featureFlags } = useValues(featureFlagLogic) + const featureDataExploration = featureFlags[FEATURE_FLAGS.DATA_EXPLORATION_LIVE_EVENTS] if (cohortMissing) { return @@ -194,7 +199,7 @@ export function CohortEdit({ id }: CohortLogicProps): JSX.Element { )} - {!isNewCohort && ( + {typeof cohort.id === 'number' && ( <>
@@ -216,6 +221,21 @@ export function CohortEdit({ id }: CohortLogicProps): JSX.Element { We're recalculating who belongs to this cohort. This could take up to a couple of minutes.
+ ) : featureDataExploration ? ( + ) : ( )} diff --git a/frontend/src/scenes/persons/Person.tsx b/frontend/src/scenes/persons/Person.tsx index 59cd8cbad48a4..bc6a82f5e0478 100644 --- a/frontend/src/scenes/persons/Person.tsx +++ b/frontend/src/scenes/persons/Person.tsx @@ -31,6 +31,7 @@ import { Query } from '~/queries/Query/Query' import { NodeKind } from '~/queries/schema' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { FEATURE_FLAGS } from 'lib/constants' +import { personDeleteModalLogic } from 'scenes/persons/personDeleteModalLogic' const { TabPane } = Tabs @@ -89,16 +90,11 @@ function PersonCaption({ person }: { person: PersonType }): JSX.Element { } export function Person(): JSX.Element | null { - const { person, personLoading, deletedPersonLoading, currentTab, splitMergeModalShown, urlId, distinctId } = - useValues(personsLogic) - const { - editProperty, - deleteProperty, - navigateToTab, - setSplitMergeModalShown, - showPersonDeleteModal, - setDistinctId, - } = useActions(personsLogic) + const { person, personLoading, currentTab, splitMergeModalShown, urlId, distinctId } = useValues(personsLogic) + const { loadPersons, editProperty, deleteProperty, navigateToTab, setSplitMergeModalShown, setDistinctId } = + useActions(personsLogic) + const { showPersonDeleteModal } = useActions(personDeleteModalLogic) + const { deletedPersonLoading } = useValues(personDeleteModalLogic) const { groupsEnabled } = useValues(groupsAccessLogic) const { currentTeam } = useValues(teamLogic) const { featureFlags } = useValues(featureFlagLogic) @@ -116,7 +112,7 @@ export function Person(): JSX.Element | null { buttons={
showPersonDeleteModal(person)} + onClick={() => showPersonDeleteModal(person, () => loadPersons())} disabled={deletedPersonLoading} loading={deletedPersonLoading} type="secondary" diff --git a/frontend/src/scenes/persons/PersonDeleteModal.tsx b/frontend/src/scenes/persons/PersonDeleteModal.tsx index 438dfea8ce781..7e3826e2c627d 100644 --- a/frontend/src/scenes/persons/PersonDeleteModal.tsx +++ b/frontend/src/scenes/persons/PersonDeleteModal.tsx @@ -1,12 +1,12 @@ import { useActions, useValues } from 'kea' -import { personsLogic } from './personsLogic' import { asDisplay } from './PersonHeader' import { LemonButton, LemonModal } from '@posthog/lemon-ui' import { PersonType } from '~/types' +import { personDeleteModalLogic } from 'scenes/persons/personDeleteModalLogic' export function PersonDeleteModal(): JSX.Element | null { - const { personDeleteModal } = useValues(personsLogic) - const { deletePerson, showPersonDeleteModal } = useActions(personsLogic) + const { personDeleteModal } = useValues(personDeleteModalLogic) + const { deletePerson, showPersonDeleteModal } = useActions(personDeleteModalLogic) return ( { - deletePerson({ person: personDeleteModal as PersonType, deleteEvents: true }) - showPersonDeleteModal(null) + deletePerson(personDeleteModal as PersonType, true) }} data-attr="delete-person-with-events" > @@ -53,8 +52,7 @@ export function PersonDeleteModal(): JSX.Element | null { type="primary" status="danger" onClick={() => { - deletePerson({ person: personDeleteModal as PersonType, deleteEvents: false }) - showPersonDeleteModal(null) + deletePerson(personDeleteModal as PersonType, false) }} data-attr="delete-person-no-events" > diff --git a/frontend/src/scenes/persons/Persons.tsx b/frontend/src/scenes/persons/Persons.tsx index fe2b6cf0a08fe..d87a2b22e8905 100644 --- a/frontend/src/scenes/persons/Persons.tsx +++ b/frontend/src/scenes/persons/Persons.tsx @@ -4,20 +4,12 @@ import { Popconfirm } from 'antd' import { personsLogic } from './personsLogic' import { CohortType } from '~/types' import { PersonsSearch } from './PersonsSearch' -import { SceneExport } from 'scenes/sceneTypes' -import { PersonPageHeader } from './PersonPageHeader' import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { LemonButton } from 'lib/components/LemonButton' import { IconExport } from 'lib/components/icons' import { triggerExport } from 'lib/components/ExportButton/exporter' -export const scene: SceneExport = { - component: PersonsScene, - logic: personsLogic, - paramsToProps: () => ({ syncWithUrl: true }), -} - interface PersonsProps { cohort?: CohortType['id'] } @@ -32,11 +24,10 @@ export function Persons({ cohort }: PersonsProps = {}): JSX.Element { export function PersonsScene(): JSX.Element { const { loadPersons, setListFilters } = useActions(personsLogic) - const { cohortId, persons, listFilters, personsLoading, exporterProps, apiDocsURL } = useValues(personsLogic) + const { persons, listFilters, personsLoading, exporterProps, apiDocsURL } = useValues(personsLogic) return (
- {!cohortId && }
diff --git a/frontend/src/scenes/persons/PersonsScene.tsx b/frontend/src/scenes/persons/PersonsScene.tsx new file mode 100644 index 0000000000000..da80fdc1d18fb --- /dev/null +++ b/frontend/src/scenes/persons/PersonsScene.tsx @@ -0,0 +1,26 @@ +import { SceneExport } from 'scenes/sceneTypes' +import { useActions, useValues } from 'kea' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' +import { Query } from '~/queries/Query/Query' +import { Persons } from 'scenes/persons/Persons' +import { PersonPageHeader } from 'scenes/persons/PersonPageHeader' +import { personsSceneLogic } from 'scenes/persons/personsSceneLogic' + +export const scene: SceneExport = { + component: PersonsScene, +} + +export function PersonsScene(): JSX.Element { + const { featureFlags } = useValues(featureFlagLogic) + const featureDataExploration = featureFlags[FEATURE_FLAGS.DATA_EXPLORATION_LIVE_EVENTS] + const { query } = useValues(personsSceneLogic) + const { setQuery } = useActions(personsSceneLogic) + + return ( + <> + + {featureDataExploration ? : } + + ) +} diff --git a/frontend/src/scenes/persons/PersonsTable.tsx b/frontend/src/scenes/persons/PersonsTable.tsx index 4e7ce0f1fbc2a..0b00bff9bf76e 100644 --- a/frontend/src/scenes/persons/PersonsTable.tsx +++ b/frontend/src/scenes/persons/PersonsTable.tsx @@ -8,8 +8,9 @@ import { LemonTable, LemonTableColumn, LemonTableColumns } from 'lib/components/ import { LemonButton } from '@posthog/lemon-ui' import { IconDelete } from 'lib/components/icons' import { useActions } from 'kea' -import { personsLogic } from 'scenes/persons/personsLogic' import { PersonDeleteModal } from 'scenes/persons/PersonDeleteModal' +import { personDeleteModalLogic } from 'scenes/persons/personDeleteModalLogic' +import { personsLogic } from 'scenes/persons/personsLogic' interface PersonsTableType { people: PersonType[] @@ -30,7 +31,8 @@ export function PersonsTable({ loadNext, compact, }: PersonsTableType): JSX.Element { - const { showPersonDeleteModal } = useActions(personsLogic) + const { showPersonDeleteModal } = useActions(personDeleteModalLogic) + const { loadPersons } = useActions(personsLogic) const columns: LemonTableColumns = [ { @@ -72,7 +74,7 @@ export function PersonsTable({ render: function Render(_, person: PersonType) { return ( showPersonDeleteModal(person)} + onClick={() => showPersonDeleteModal(person, () => loadPersons())} icon={} status="danger" size="small" diff --git a/frontend/src/scenes/persons/personDeleteModalLogic.tsx b/frontend/src/scenes/persons/personDeleteModalLogic.tsx new file mode 100644 index 0000000000000..9435c665f6a2a --- /dev/null +++ b/frontend/src/scenes/persons/personDeleteModalLogic.tsx @@ -0,0 +1,62 @@ +import { actions, kea, props, reducers, path } from 'kea' +import api from 'lib/api' +import { PersonType } from '~/types' +import { toParams } from 'lib/utils' +import { asDisplay } from 'scenes/persons/PersonHeader' +import { lemonToast } from 'lib/components/lemonToast' +import type { personDeleteModalLogicType } from './personDeleteModalLogicType' +import { loaders } from 'kea-loaders' + +export interface PersonDeleteModalLogicProps { + person: PersonType +} +export type PersonDeleteCallback = (person: PersonType, deleteEvents: boolean) => void + +export const personDeleteModalLogic = kea([ + path(['scenes', 'persons', 'personDeleteModalLogic']), + props({} as PersonDeleteModalLogicProps), + actions({ + showPersonDeleteModal: (person: PersonType | null, callback?: PersonDeleteCallback) => ({ + person, + callback, + }), + deletePerson: (person: PersonType, deleteEvents: boolean) => ({ person, deleteEvents }), + }), + reducers({ + personDeleteModal: [ + null as PersonType | null, + { + showPersonDeleteModal: (_, { person }) => person, + }, + ], + personDeleteCallback: [ + null as PersonDeleteCallback | null, + { + showPersonDeleteModal: (_, { callback }) => callback ?? null, + }, + ], + }), + loaders(({ actions, values }) => ({ + deletedPerson: [ + null as PersonType | null, + { + deletePerson: async ({ person, deleteEvents }) => { + const params = deleteEvents ? { delete_events: true } : {} + await api.delete(`api/person/${person.id}?${toParams(params)}`) + lemonToast.success( + <> + The person {asDisplay(person)} was removed from the project. + {deleteEvents + ? ' Corresponding events will be deleted on a set schedule during non-peak usage times.' + : ' Their ID(s) will be usable again in an hour or so.'} + + ) + console.log('personDeleteCallback', values.personDeleteCallback) + values.personDeleteCallback?.(person, deleteEvents) + actions.showPersonDeleteModal(null) + return person + }, + }, + ], + })), +]) diff --git a/frontend/src/scenes/persons/personsLogic.tsx b/frontend/src/scenes/persons/personsLogic.tsx index 6ea853a29f3f5..44b45540c0aa9 100644 --- a/frontend/src/scenes/persons/personsLogic.tsx +++ b/frontend/src/scenes/persons/personsLogic.tsx @@ -47,8 +47,6 @@ export const personsLogic = kea({ navigateToCohort: (cohort: CohortType) => ({ cohort }), navigateToTab: (tab: PersonsTabType) => ({ tab }), setSplitMergeModalShown: (shown: boolean) => ({ shown }), - showPersonDeleteModal: (person: PersonType | null) => ({ person }), - deletePerson: (payload: { person: PersonType; deleteEvents: boolean }) => payload, setDistinctId: (distinctId: string) => ({ distinctId }), }, reducers: { @@ -91,12 +89,6 @@ export const personsLogic = kea({ loadPerson: () => null, setPerson: (_, { person }): PersonType | null => person, }, - personDeleteModal: [ - null as PersonType | null, - { - showPersonDeleteModal: (_, { person }) => person, - }, - ], distinctId: [ null as string | null, { @@ -155,20 +147,6 @@ export const personsLogic = kea({ urlId: [() => [(_, props) => props.urlId], (urlId) => urlId], }), listeners: ({ actions, values }) => ({ - deletePersonSuccess: ({ deletedPerson }) => { - // The deleted person's distinct IDs won't be usable until the person disappears from PersonManager's LRU. - // This can take up to an hour. Until then, the plugin server won't know to regenerate the person. - lemonToast.success( - <> - The person {asDisplay(deletedPerson.person)} was removed from the project. - {deletedPerson.deleteEvents - ? ' Corresponding events will be deleted on a set schedule during non-peak usage times.' - : ' Their ID(s) will be usable again in an hour or so.'} - - ) - actions.loadPersons() - router.actions.push(urls.persons()) - }, editProperty: async ({ key, newValue }) => { const person = values.person @@ -275,16 +253,6 @@ export const personsLogic = kea({ }, }, ], - deletedPerson: [ - {} as { person?: PersonType; deleteEvents?: boolean }, - { - deletePerson: async ({ person, deleteEvents }) => { - const params = deleteEvents ? { delete_events: true } : {} - await api.delete(`api/person/${person.id}?${toParams(params)}`) - return { person, deleteEvents } - }, - }, - ], }), actionToUrl: ({ values, props }) => ({ setListFilters: () => { diff --git a/frontend/src/scenes/persons/personsSceneLogic.ts b/frontend/src/scenes/persons/personsSceneLogic.ts new file mode 100644 index 0000000000000..f7aaa0893aa7e --- /dev/null +++ b/frontend/src/scenes/persons/personsSceneLogic.ts @@ -0,0 +1,58 @@ +import { actions, kea, path, reducers } from 'kea' + +import { actionToUrl, urlToAction } from 'kea-router' +import equal from 'fast-deep-equal' +import { DataTableNode, Node, NodeKind } from '~/queries/schema' +import { urls } from 'scenes/urls' +import { objectsEqual } from 'lib/utils' +import { lemonToast } from 'lib/components/lemonToast' + +import type { personsSceneLogicType } from './personsSceneLogicType' + +const getDefaultQuery = (): DataTableNode => ({ + kind: NodeKind.DataTableNode, + source: { kind: NodeKind.PersonsNode }, + columns: undefined, + propertiesViaUrl: true, + showSearch: true, + showPropertyFilter: true, + showExport: true, + showReload: true, +}) + +export const personsSceneLogic = kea([ + path(['scenes', 'persons', 'personsSceneLogic']), + + actions({ setQuery: (query: Node) => ({ query }) }), + reducers({ query: [getDefaultQuery() as Node, { setQuery: (_, { query }) => query }] }), + + actionToUrl(({ values }) => ({ + setQuery: () => [ + urls.persons(), + {}, + objectsEqual(values.query, getDefaultQuery()) ? {} : { q: values.query }, + { replace: true }, + ], + })), + + urlToAction(({ actions, values }) => ({ + [urls.persons()]: (_, __, { q: queryParam }): void => { + if (!equal(queryParam, values.query)) { + // nothing in the URL + if (!queryParam) { + // set the default unless it's already there + if (!objectsEqual(values.query, getDefaultQuery())) { + actions.setQuery(getDefaultQuery()) + } + } else { + if (typeof queryParam === 'object') { + actions.setQuery(queryParam) + } else { + lemonToast.error('Invalid query in URL') + console.error({ queryParam }) + } + } + } + }, + })), +])