0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-24 18:07:17 +01:00

refactor(early-access): use query node (REVERT) (#16457)

Revert "refactor(early-access): use query node (#16301)"

This reverts commit 2dc07323df.
This commit is contained in:
Marius Andra 2023-07-10 15:27:02 +02:00 committed by GitHub
parent 72dcf73b14
commit 04c54ddb2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 655 additions and 125 deletions

View File

@ -1,21 +1,26 @@
import { connect, kea, path, selectors } from 'kea'
import { afterMount, connect, kea, listeners, path, reducers, selectors } from 'kea'
import { sceneLogic } from 'scenes/sceneLogic'
import { Scene } from 'scenes/sceneTypes'
import type { personsAndGroupsSidebarLogicType } from './personsAndGroupsType'
import { personsLogic } from 'scenes/persons/personsLogic'
import { subscriptions } from 'kea-subscriptions'
import { navigation3000Logic } from '../navigationLogic'
import { SidebarCategory, BasicListItem } from '../types'
import { urls } from '@posthog/apps-common'
import { asDisplay, asLink, urls } from '@posthog/apps-common'
import { findSearchTermInItemName } from './utils'
import { groupsModel } from '~/models/groupsModel'
import { capitalizeFirstLetter } from 'lib/utils'
import { GroupsPaginatedResponse, groupsListLogic } from 'scenes/groups/groupsListLogic'
import { groupDisplayId } from 'scenes/persons/GroupActorHeader'
import { combineUrl } from 'kea-router'
import { PersonType } from '~/types'
export const personsAndGroupsSidebarLogic = kea<personsAndGroupsSidebarLogicType>([
path(['layout', 'navigation-3000', 'sidebars', 'personsAndGroupsSidebarLogic']),
connect(() => ({
values: [
personsLogic,
['persons', 'personsLoading'],
groupsModel,
['groupTypes'],
sceneLogic,
@ -23,12 +28,68 @@ export const personsAndGroupsSidebarLogic = kea<personsAndGroupsSidebarLogicType
navigation3000Logic,
['searchTerm'],
],
actions: [personsLogic, ['setListFilters as setPersonsListFilters', 'loadPersons']],
})),
selectors(({ values }) => ({
reducers(() => ({
infinitePersons: [
[] as (PersonType | undefined)[],
{
[personsLogic.actionTypes.loadPersonsSuccess]: (state, { persons }) => {
// Reset array if offset is 0
const items: (PersonType | undefined)[] = persons.offset === 0 ? [] : state.slice()
for (let i = 0; i < persons.results.length; i++) {
items[persons.offset + i] = persons.results[i]
}
return items
},
},
],
})),
selectors(({ values, cache }) => ({
contents: [
(s) => [s.groupTypes, s.groups, s.groupsLoading],
(groupTypes, groups, groupsLoading): SidebarCategory[] => {
(s) => [s.persons, s.infinitePersons, s.personsLoading, s.groupTypes, s.groups, s.groupsLoading],
(persons, infinitePersons, personsLoading, groupTypes, groups, groupsLoading): SidebarCategory[] => {
return [
{
key: 'persons',
title: 'Persons',
items: infinitePersons.map((person) => {
if (!person) {
return person
}
const name = asDisplay(person)
// It is not typical to use `values` in a selector instead of a selector dependency,
// but this is intentional: we only want to take the new search term into account AFTER
// person results using it have been loaded.
const { searchTerm } = values
return {
key: person.distinct_ids,
name: asDisplay(person),
url: asLink(person),
searchMatch: findSearchTermInItemName(name, searchTerm),
} as BasicListItem
}),
loading: personsLoading,
remote: {
isItemLoaded: (index) => !!(cache.requestedPersons[index] || infinitePersons[index]),
loadMoreItems: async (startIndex, stopIndex) => {
let moreUrl = persons.next || persons.previous
if (!moreUrl) {
throw new Error('No URL for loading more persons is known')
}
for (let i = startIndex; i <= stopIndex; i++) {
cache.requestedPersons[i] = true
}
moreUrl = combineUrl(moreUrl, {
offset: startIndex,
limit: stopIndex - startIndex + 1,
}).url
await personsLogic.asyncActions.loadPersons(moreUrl)
},
itemCount: persons.count,
minimumBatchSize: 100,
},
} as SidebarCategory,
...groupTypes.map(
(groupType) =>
({
@ -108,11 +169,25 @@ export const personsAndGroupsSidebarLogic = kea<personsAndGroupsSidebarLogicType
// kea-typegen doesn't like selectors without deps, so searchTerm is just for appearances
debounceSearch: [(s) => [s.searchTerm], () => true],
})),
subscriptions(({ values }) => ({
listeners(({ cache }) => ({
loadPersons: async ({ url }) => {
const offset = url ? parseInt(new URL(url).searchParams.get('offset') || '0') : 0
if (offset === 0) {
cache.requestedPersons.length = 0 // Clear cache
}
},
})),
subscriptions(({ actions, values }) => ({
searchTerm: (searchTerm) => {
actions.setPersonsListFilters({ search: searchTerm })
actions.loadPersons()
for (const { group_type_index: groupTypeIndex } of values.groupTypes) {
groupsListLogic({ groupTypeIndex }).actions.setSearch(searchTerm, false)
}
},
})),
afterMount(({ actions, cache }) => {
cache.requestedPersons = []
actions.loadPersons()
}),
])

View File

@ -225,7 +225,7 @@ export function FilterBasedCardContent({
) : invalidFunnelExclusion ? (
<FunnelInvalidExclusionState />
) : empty ? (
context?.emptyState ?? <InsightEmptyState />
<InsightEmptyState heading={context?.emptyStateHeading} detail={context?.emptyStateDetail} />
) : !loading && timedOut ? (
<InsightTimeoutState isLoading={false} insightProps={{ dashboardItemId: undefined }} />
) : apiErrored && !loading ? (

View File

@ -344,15 +344,13 @@ export const dataNodeLogic = kea<dataNodeLogicType>([
}
}
if (isPersonsNode(query) && response && !responseError) {
if (!!(response as PersonsNode['response'])?.next) {
const personsResults = (response as PersonsNode['response'])?.results
const nextQuery: PersonsNode = {
...query,
limit: query.limit || 100,
offset: personsResults.length,
}
return nextQuery
const personsResults = (response as PersonsNode['response'])?.results
const nextQuery: PersonsNode = {
...query,
limit: query.limit || 100,
offset: personsResults.length,
}
return nextQuery
}
return null
},

View File

@ -39,10 +39,9 @@ import { TaxonomicPopover } from 'lib/components/TaxonomicPopover/TaxonomicPopov
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { extractExpressionComment, removeExpressionComment } from '~/queries/nodes/DataTable/utils'
import { InsightEmptyState, InsightErrorState } from 'scenes/insights/EmptyStates'
import { EventType, PropertyDefinitionType } from '~/types'
import { EventType } from '~/types'
import { SavedQueries } from '~/queries/nodes/DataTable/SavedQueries'
import { HogQLQueryEditor } from '~/queries/nodes/HogQLQuery/HogQLQueryEditor'
import { PropertiesTable } from 'lib/components/PropertiesTable'
interface DataTableProps {
query: DataTableNode
@ -82,15 +81,9 @@ export function DataTable({ query, setQuery, context, cachedResults }: DataTable
} = useValues(builtDataNodeLogic)
const dataTableLogicProps: DataTableLogicProps = { query, key, context }
const {
dataTableRows,
columnsInQuery,
columnsInResponse,
queryWithDefaults,
canSort,
emptyStateHeading,
emptyStateDetail,
} = useValues(dataTableLogic(dataTableLogicProps))
const { dataTableRows, columnsInQuery, columnsInResponse, queryWithDefaults, canSort } = useValues(
dataTableLogic(dataTableLogicProps)
)
const {
showActions,
@ -443,51 +436,37 @@ export function DataTable({ query, setQuery, context, cachedResults }: DataTable
<InsightErrorState />
)
) : (
context?.emptyState ?? (
<InsightEmptyState heading={emptyStateHeading} detail={emptyStateDetail} />
)
<InsightEmptyState
heading={context?.emptyStateHeading}
detail={context?.emptyStateDetail}
/>
)
}
expandable={
expandable
? isEventsQuery(query.source) && columnsInResponse?.includes('*')
? {
expandedRowRender: function renderExpand({ result }) {
if (isEventsQuery(query.source) && Array.isArray(result)) {
return (
<EventDetails
event={result[columnsInResponse.indexOf('*')] ?? {}}
useReactJsonView
/>
)
}
if (result && !Array.isArray(result)) {
return <EventDetails event={result as EventType} useReactJsonView />
}
},
rowExpandable: ({ result }) => !!result,
noIndent: true,
expandedRowClassName: ({ result }) => {
const record = Array.isArray(result) ? result[0] : result
return record && record['event'] === '$exception'
? 'border border-danger-dark bg-danger-highlight'
: null
},
}
: isPersonsNode(query.source) && columnsInQuery?.includes('*')
? {
expandedRowRender: function renderExpand({ result }) {
expandable && isEventsQuery(query.source) && columnsInResponse?.includes('*')
? {
expandedRowRender: function renderExpand({ result }) {
if (isEventsQuery(query.source) && Array.isArray(result)) {
return (
<PropertiesTable
type={PropertyDefinitionType.Person}
properties={(result as any)?.properties}
<EventDetails
event={result[columnsInResponse.indexOf('*')] ?? {}}
useReactJsonView
/>
)
},
rowExpandable: ({ result }) => !!(result as any)?.properties,
noIndent: true,
}
: undefined
}
if (result && !Array.isArray(result)) {
return <EventDetails event={result as EventType} useReactJsonView />
}
},
rowExpandable: ({ result }) => !!result,
noIndent: true,
expandedRowClassName: ({ result }) => {
const record = Array.isArray(result) ? result[0] : result
return record && record['event'] === '$exception'
? 'border border-danger-dark bg-danger-highlight'
: null
},
}
: undefined
}
rowClassName={({ result, label }) =>

View File

@ -11,7 +11,7 @@ import {
} from '~/queries/schema'
import { getColumnsForQuery, removeExpressionComment } from './utils'
import { objectsEqual, sortedKeys } from 'lib/utils'
import { isDataTableNode, isEventsQuery, isHogQLQuery, isPersonsNode } from '~/queries/utils'
import { isDataTableNode, isEventsQuery } from '~/queries/utils'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { FEATURE_FLAGS } from 'lib/constants'
import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic'
@ -176,24 +176,6 @@ export const dataTableLogic = kea<dataTableLogicType>([
(s) => [s.queryWithDefaults],
(query: DataTableNode): boolean => isEventsQuery(query.source) && !!query.allowSorting,
],
emptyStateHeading: [
(s) => [s.queryWithDefaults],
(query: DataTableNode) =>
`There are no ${
isHogQLQuery(query.source)
? 'results'
: isPersonsNode(query.source)
? 'matching persons'
: 'matching events'
} for this query`,
],
emptyStateDetail: [
(s) => [s.queryWithDefaults],
(query: DataTableNode) =>
isPersonsNode(query.source)
? 'Try adjusting the filters.'
: 'Try changing the date range, or pick another action, event or breakdown.',
],
}),
propsChanged(({ actions, props }, oldProps) => {
const newColumns = getColumnsForQuery(props.query)

View File

@ -106,7 +106,7 @@ export function InsightContainer({
return <FunnelInvalidExclusionState />
}
if (!hasFunnelResults && !erroredQueryId && !insightDataLoading) {
return context?.emptyState ?? <InsightEmptyState />
return <InsightEmptyState heading={context?.emptyStateHeading} detail={context?.emptyStateDetail} />
}
}

View File

@ -552,7 +552,8 @@ export interface QueryContext {
/* Adds help and examples to the query editor component */
showQueryHelp?: boolean
insightProps?: InsightLogicProps
emptyState?: JSX.Element
emptyStateHeading?: string
emptyStateDetail?: string
}
interface QueryContextColumn {

View File

@ -10,6 +10,7 @@ import {
CohortType,
FilterLogicalOperator,
} from '~/types'
import { personsLogic } from 'scenes/persons/personsLogic'
import { lemonToast } from 'lib/lemon-ui/lemonToast'
import { urls } from 'scenes/urls'
import { router } from 'kea-router'
@ -297,6 +298,7 @@ export const cohortEditLogic = kea<cohortEditLogicType>([
} else {
actions.setCohort(cohort)
cohortsModel.actions.updateCohort(cohort)
personsLogic.findMounted({ syncWithUrl: true })?.actions.loadCohorts() // To ensure sync on person page
if (values.pollTimeout) {
clearTimeout(values.pollTimeout)
actions.setPollTimeout(null)

View File

@ -22,12 +22,14 @@ import { TaxonomicFilter } from 'lib/components/TaxonomicFilter/TaxonomicFilter'
import { TaxonomicFilterLogicProps } from 'lib/components/TaxonomicFilter/types'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { featureFlagLogic } from 'scenes/feature-flags/featureFlagLogic'
import { PersonsLogicProps, personsLogic } from 'scenes/persons/personsLogic'
import clsx from 'clsx'
import { InstructionsModal } from './InstructionsModal'
import { PersonsTable } from 'scenes/persons/PersonsTable'
import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters'
import { PersonsSearch } from 'scenes/persons/PersonsSearch'
import { LemonDialog } from 'lib/lemon-ui/LemonDialog'
import { LemonTabs } from 'lib/lemon-ui/LemonTabs'
import { Query } from '~/queries/Query/Query'
import { NodeKind } from '~/queries/schema'
export const scene: SceneExport = {
component: EarlyAccessFeature,
@ -40,14 +42,8 @@ export const scene: SceneExport = {
export function EarlyAccessFeature({ id }: { id?: string } = {}): JSX.Element {
const { earlyAccessFeature, earlyAccessFeatureLoading, isEarlyAccessFeatureSubmitting, isEditingFeature } =
useValues(earlyAccessFeatureLogic)
const {
submitEarlyAccessFeatureRequest,
cancel,
editFeature,
updateStage,
deleteEarlyAccessFeature,
toggleImplementOptInInstructionsModal,
} = useActions(earlyAccessFeatureLogic)
const { submitEarlyAccessFeatureRequest, cancel, editFeature, updateStage, deleteEarlyAccessFeature } =
useActions(earlyAccessFeatureLogic)
const isNewEarlyAccessFeature = id === 'new' || id === undefined
return (
@ -117,17 +113,6 @@ export function EarlyAccessFeature({ id }: { id?: string } = {}): JSX.Element {
Archive
</LemonButton>
)}
<LemonDivider vertical />
<div className="flex flex-row justify-between">
<LemonButton
key="help-button"
type="secondary"
onClick={toggleImplementOptInInstructionsModal}
sideIcon={<IconHelpOutline />}
>
Implement public opt-in
</LemonButton>
</div>
{earlyAccessFeature.stage == EarlyAccessFeatureStage.Archived && (
<LemonButton
data-attr="reactive-feature"
@ -408,15 +393,55 @@ interface PersonsTableByFilterProps {
}
function PersonsTableByFilter({ properties, emptyState }: PersonsTableByFilterProps): JSX.Element {
const { toggleImplementOptInInstructionsModal } = useActions(earlyAccessFeatureLogic)
const personsLogicProps: PersonsLogicProps = {
cohort: undefined,
syncWithUrl: false,
fixedProperties: properties,
}
const logic = personsLogic(personsLogicProps)
const { persons, personsLoading, listFilters } = useValues(logic)
const { loadPersons, setListFilters } = useActions(logic)
return (
<Query
query={{
kind: NodeKind.DataTableNode,
columns: ['*', 'person'],
source: { kind: NodeKind.PersonsNode, fixedProperties: properties },
full: true,
}}
context={{ emptyState: emptyState }}
/>
<div className="space-y-2">
{
<div className="flex-col">
<PersonsSearch />
</div>
}
<div className="flex flex-row justify-between">
<PropertyFilters
pageKey="persons-list-page"
propertyFilters={listFilters.properties}
onChange={(properties) => {
setListFilters({ properties })
loadPersons()
}}
endpoint="person"
taxonomicGroupTypes={[TaxonomicFilterGroupType.PersonProperties]}
showConditionBadge
/>
<LemonButton
key="help-button"
onClick={toggleImplementOptInInstructionsModal}
sideIcon={<IconHelpOutline />}
>
Implement public opt-in
</LemonButton>
</div>
<PersonsTable
people={persons.results}
loading={personsLoading}
hasPrevious={!!persons.previous}
hasNext={!!persons.next}
loadPrevious={() => loadPersons(persons.previous)}
loadNext={() => loadPersons(persons.next)}
compact={true}
extraColumns={[]}
emptyState={emptyState}
/>
</div>
)
}

View File

@ -3,7 +3,7 @@ import { DownOutlined } from '@ant-design/icons'
import { useActions, useValues } from 'kea'
import { personsLogic } from './personsLogic'
import { asDisplay } from './PersonHeader'
import './Person.scss'
import './Persons.scss'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { MergeSplitPerson } from './MergeSplitPerson'
import { PersonCohorts } from './PersonCohorts'
@ -107,7 +107,7 @@ function PersonCaption({ person }: { person: PersonType }): JSX.Element {
export function Person(): JSX.Element | null {
const { person, personLoading, currentTab, splitMergeModalShown, urlId, distinctId } = useValues(personsLogic)
const { editProperty, deleteProperty, navigateToTab, setSplitMergeModalShown, setDistinctId } =
const { loadPersons, editProperty, deleteProperty, navigateToTab, setSplitMergeModalShown, setDistinctId } =
useActions(personsLogic)
const { showPersonDeleteModal } = useActions(personDeleteModalLogic)
const { deletedPersonLoading } = useValues(personDeleteModalLogic)
@ -135,7 +135,7 @@ export function Person(): JSX.Element | null {
buttons={
<div className="flex gap-2">
<LemonButton
onClick={() => showPersonDeleteModal(person)}
onClick={() => showPersonDeleteModal(person, () => loadPersons())}
disabled={deletedPersonLoading}
loading={deletedPersonLoading}
type="secondary"

View File

@ -0,0 +1,137 @@
import { useValues, useActions, BindLogic } from 'kea'
import { PersonsTable } from './PersonsTable'
import { Col, Popconfirm } from 'antd'
import { personsLogic } from './personsLogic'
import { CohortType, PersonType, ProductKey } from '~/types'
import { PersonsSearch } from './PersonsSearch'
import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
import { LemonButton } from 'lib/lemon-ui/LemonButton'
import { IconExport } from 'lib/lemon-ui/icons'
import { triggerExport } from 'lib/components/ExportButton/exporter'
import { LemonTableColumn } from 'lib/lemon-ui/LemonTable'
import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction'
import { router } from 'kea-router'
import { urls } from 'scenes/urls'
interface PersonsProps {
cohort?: CohortType['id']
}
export function Persons({ cohort }: PersonsProps = {}): JSX.Element {
return (
<BindLogic logic={personsLogic} props={{ cohort: cohort, syncWithUrl: !cohort, fixedProperties: undefined }}>
<PersonsScene />
</BindLogic>
)
}
interface PersonsSceneProps {
extraSceneActions?: JSX.Element[]
compact?: boolean
showFilters?: boolean
showExportAction?: boolean
extraColumns?: LemonTableColumn<PersonType, keyof PersonType | undefined>[]
showSearch?: boolean
emptyState?: JSX.Element
}
export function PersonsScene({
extraSceneActions,
compact,
extraColumns,
emptyState,
showFilters = true,
showExportAction = true,
showSearch = true,
}: PersonsSceneProps): JSX.Element {
const { loadPersons, setListFilters } = useActions(personsLogic)
const { persons, listFilters, personsLoading, exporterProps, apiDocsURL } = useValues(personsLogic)
const shouldShowEmptyState = !personsLoading && persons.results.length === 0 && !listFilters.search
return (
<>
{shouldShowEmptyState ? (
<ProductIntroduction
productName="Persons"
thingName="person"
productKey={ProductKey.PERSONS}
description="PostHog tracks user behaviour, whether or not the user is logged in or anonymous. Once you've sent some data, the associated persons will show up here."
docsURL="https://posthog.com/docs/getting-started/install"
actionElementOverride={
<LemonButton type="primary" onClick={() => router.actions.push(urls.ingestion() + '/platform')}>
Start sending data
</LemonButton>
}
isEmpty={true}
/>
) : (
<div className="persons-list">
<div className="space-y-2">
<div className="flex justify-between items-center gap-2">
{showSearch && (
<Col>
<PersonsSearch />
</Col>
)}
<Col className="flex flex-row gap-2">
{showExportAction && (
<Popconfirm
placement="topRight"
title={
<>
Exporting by CSV is limited to 10,000 users.
<br />
To export more, please use <a href={apiDocsURL}>the API</a>. Do you want
to export by CSV?
</>
}
onConfirm={() => triggerExport(exporterProps[0])}
>
<LemonButton
type="secondary"
icon={<IconExport style={{ color: 'var(--primary)' }} />}
>
{listFilters.properties && listFilters.properties.length > 0 ? (
<div style={{ display: 'block' }}>
Export (<strong>{listFilters.properties.length}</strong> filter)
</div>
) : (
'Export'
)}
</LemonButton>
</Popconfirm>
)}
{extraSceneActions ? extraSceneActions : null}
</Col>
</div>
{showFilters && (
<PropertyFilters
pageKey="persons-list-page"
propertyFilters={listFilters.properties}
onChange={(properties) => {
setListFilters({ properties })
loadPersons()
}}
endpoint="person"
taxonomicGroupTypes={[TaxonomicFilterGroupType.PersonProperties]}
showConditionBadge
/>
)}
<PersonsTable
people={persons.results}
loading={personsLoading}
hasPrevious={!!persons.previous}
hasNext={!!persons.next}
loadPrevious={() => loadPersons(persons.previous)}
loadNext={() => loadPersons(persons.next)}
compact={compact}
extraColumns={extraColumns}
emptyState={emptyState}
/>
</div>
</div>
)}
</>
)
}

View File

@ -0,0 +1,46 @@
import { useEffect, useState } from 'react'
import { useValues, useActions } from 'kea'
import { personsLogic } from './personsLogic'
import { IconInfo } from 'lib/lemon-ui/icons'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { LemonInput } from '@posthog/lemon-ui'
import { useDebouncedCallback } from 'use-debounce'
export const PersonsSearch = (): JSX.Element => {
const { loadPersons, setListFilters } = useActions(personsLogic)
const { listFilters } = useValues(personsLogic)
const [searchTerm, setSearchTerm] = useState('')
const loadPersonsDebounced = useDebouncedCallback(loadPersons, 800)
useEffect(() => {
setSearchTerm(listFilters.search || '')
}, [])
useEffect(() => {
setListFilters({ search: searchTerm || undefined })
loadPersonsDebounced()
}, [searchTerm])
return (
<div className="flex items-center gap-2">
<LemonInput
type="search"
placeholder="Search for persons"
onChange={setSearchTerm}
value={searchTerm}
data-attr="persons-search"
/>
<Tooltip
title={
<>
Search by email or Distinct ID. Email will match partially, for example: "@gmail.com". Distinct
ID needs to match exactly.
</>
}
>
<IconInfo className="text-2xl text-muted-alt shrink-0" />
</Tooltip>
</div>
)
}

View File

@ -0,0 +1,133 @@
import { TZLabel } from 'lib/components/TZLabel'
import { PropertiesTable } from 'lib/components/PropertiesTable'
import { PersonType, PropertyDefinitionType } from '~/types'
import './Persons.scss'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { PersonHeader } from './PersonHeader'
import { LemonTable, LemonTableColumn, LemonTableColumns } from 'lib/lemon-ui/LemonTable'
import { LemonButton } from '@posthog/lemon-ui'
import { IconDelete } from 'lib/lemon-ui/icons'
import { useActions } from 'kea'
import { PersonDeleteModal } from 'scenes/persons/PersonDeleteModal'
import { personDeleteModalLogic } from 'scenes/persons/personDeleteModalLogic'
import { personsLogic } from 'scenes/persons/personsLogic'
interface PersonsTableType {
people: PersonType[]
loading?: boolean
hasPrevious?: boolean
hasNext?: boolean
loadPrevious?: () => void
loadNext?: () => void
compact?: boolean
extraColumns?: LemonTableColumns<PersonType>
emptyState?: JSX.Element
}
export function PersonsTable({
people,
loading = false,
hasPrevious,
hasNext,
loadPrevious,
loadNext,
compact,
extraColumns,
emptyState,
}: PersonsTableType): JSX.Element {
const { showPersonDeleteModal } = useActions(personDeleteModalLogic)
const { loadPersons } = useActions(personsLogic)
const columns: LemonTableColumns<PersonType> = [
{
title: 'Person',
key: 'person',
render: function Render(_, person: PersonType) {
return <PersonHeader withIcon person={person} />
},
},
...(!compact
? ([
{
title: 'ID',
key: 'id',
render: function Render(_, person: PersonType) {
return (
<div className={'overflow-hidden'}>
{person.distinct_ids.length && (
<CopyToClipboardInline
explicitValue={person.distinct_ids[0]}
iconStyle={{ color: 'var(--primary)' }}
description="person distinct ID"
>
{person.distinct_ids[0]}
</CopyToClipboardInline>
)}
</div>
)
},
},
{
title: 'First seen',
dataIndex: 'created_at',
render: function Render(created_at: PersonType['created_at']) {
return created_at ? <TZLabel time={created_at} /> : <></>
},
},
{
render: function Render(_, person: PersonType) {
return (
<LemonButton
onClick={() => showPersonDeleteModal(person, () => loadPersons())}
icon={<IconDelete />}
status="danger"
size="small"
/>
)
},
},
] as Array<LemonTableColumn<PersonType, keyof PersonType | undefined>>)
: []),
...(extraColumns || []),
]
return (
<>
<LemonTable
data-attr="persons-table"
columns={columns}
loading={loading}
rowKey="id"
pagination={{
controlled: true,
pageSize: 100, // From `posthog/api/person.py`
onForward: hasNext
? () => {
loadNext?.()
window.scrollTo(0, 0)
}
: undefined,
onBackward: hasPrevious
? () => {
loadPrevious?.()
window.scrollTo(0, 0)
}
: undefined,
}}
expandable={{
expandedRowRender: function RenderPropertiesTable({ properties }) {
return Object.keys(properties).length ? (
<PropertiesTable type={PropertyDefinitionType.Person} properties={properties} />
) : (
'This person has no properties.'
)
},
}}
dataSource={people}
emptyState={emptyState ? emptyState : 'No persons'}
nouns={['person', 'persons']}
/>
<PersonDeleteModal />
</>
)
}

View File

@ -18,7 +18,11 @@ export const mergeSplitPersonLogic = kea<mergeSplitPersonLogicType>({
key: (props) => props.person.id ?? 'new',
path: (key) => ['scenes', 'persons', 'mergeSplitPersonLogic', key],
connect: () => ({
actions: [personsLogic({ syncWithUrl: true }), ['setPerson', 'setSplitMergeModalShown']],
actions: [
personsLogic({ syncWithUrl: true }),
['setListFilters', 'loadPersons', 'setPerson', 'setSplitMergeModalShown'],
],
values: [personsLogic({ syncWithUrl: true }), ['persons']],
}),
actions: {
setSelectedPersonToAssignSplit: (id: string) => ({ id }),
@ -34,6 +38,9 @@ export const mergeSplitPersonLogic = kea<mergeSplitPersonLogicType>({
],
}),
listeners: ({ actions, values }) => ({
setListFilters: () => {
actions.loadPersons()
},
cancel: () => {
if (!values.executedLoading) {
actions.setSplitMergeModalShown(false)
@ -64,4 +71,7 @@ export const mergeSplitPersonLogic = kea<mergeSplitPersonLogicType>({
},
],
}),
events: ({ actions }) => ({
afterMount: [actions.loadPersons],
}),
})

View File

@ -2,6 +2,7 @@ import { expectLogic } from 'kea-test-utils'
import { initKeaTests } from '~/test/init'
import { personsLogic } from './personsLogic'
import { router } from 'kea-router'
import { PropertyFilterType, PropertyOperator } from '~/types'
import { useMocks } from '~/mocks/jest'
import api from 'lib/api'
@ -41,6 +42,25 @@ describe('personsLogic', () => {
logic.mount()
})
describe('syncs with insightLogic', () => {
it('setAllFilters properties works', async () => {
router.actions.push('/persons')
await expectLogic(logic, () => {
logic.actions.setListFilters({
properties: [{ key: 'email', operator: PropertyOperator.IsSet, type: PropertyFilterType.Person }],
})
logic.actions.loadPersons()
})
.toMatchValues(logic, {
listFilters: { properties: [{ key: 'email', operator: 'is_set', type: 'person' }] },
})
.toDispatchActions(router, ['replace', 'locationChanged'])
.toMatchValues(router, {
searchParams: { properties: [{ key: 'email', operator: 'is_set', type: 'person' }] },
})
})
})
describe('loads a person', () => {
it('starts with a null person', async () => {
await expectLogic(logic).toMatchValues({

View File

@ -1,14 +1,27 @@
import { kea } from 'kea'
import { router } from 'kea-router'
import api from 'lib/api'
import { decodeParams, router } from 'kea-router'
import api, { CountedPaginatedResponse } from 'lib/api'
import type { personsLogicType } from './personsLogicType'
import { PersonPropertyFilter, Breadcrumb, CohortType, PersonsTabType, PersonType } from '~/types'
import {
PersonPropertyFilter,
Breadcrumb,
CohortType,
ExporterFormat,
PersonListParams,
PersonsTabType,
PersonType,
AnyPropertyFilter,
} from '~/types'
import { eventUsageLogic } from 'lib/utils/eventUsageLogic'
import { urls } from 'scenes/urls'
import { teamLogic } from 'scenes/teamLogic'
import { convertPropertyGroupToProperties, toParams } from 'lib/utils'
import { asDisplay } from 'scenes/persons/PersonHeader'
import { isValidPropertyFilter } from 'lib/components/PropertyFilters/utils'
import { lemonToast } from 'lib/lemon-ui/lemonToast'
import { TriggerExportProps } from 'lib/components/ExportButton/exporter'
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
import { FEATURE_FLAGS } from 'lib/constants'
export interface PersonsLogicProps {
cohort?: number | 'new'
@ -33,7 +46,11 @@ export const personsLogic = kea<personsLogicType>({
},
actions: {
setPerson: (person: PersonType | null) => ({ person }),
setPersons: (persons: PersonType[]) => ({ persons }),
loadPerson: (id: string) => ({ id }),
loadPersons: (url: string | null = '') => ({ url }),
setListFilters: (payload: PersonListParams) => ({ payload }),
setHiddenListProperties: (payload: AnyPropertyFilter[]) => ({ payload }),
editProperty: (key: string, newValue?: string | number | boolean | null) => ({ key, newValue }),
deleteProperty: (key: string) => ({ key }),
navigateToCohort: (cohort: CohortType) => ({ cohort }),
@ -43,6 +60,37 @@ export const personsLogic = kea<personsLogicType>({
setDistinctId: (distinctId: string) => ({ distinctId }),
},
reducers: () => ({
listFilters: [
{} as PersonListParams,
{
setListFilters: (state, { payload }) => {
const newFilters = { ...state, ...payload }
if (newFilters.properties?.length === 0) {
delete newFilters['properties']
}
if (newFilters.properties) {
newFilters.properties = convertPropertyGroupToProperties(
newFilters.properties.filter(isValidPropertyFilter)
)
}
return newFilters
},
},
],
hiddenListProperties: [
[] as AnyPropertyFilter[],
{
setHiddenListProperties: (state, { payload }) => {
let newProperties = [...state, ...payload]
if (newProperties) {
newProperties =
convertPropertyGroupToProperties(newProperties.filter(isValidPropertyFilter)) || []
}
return newProperties
},
},
],
activeTab: [
null as PersonsTabType | null,
{
@ -56,6 +104,16 @@ export const personsLogic = kea<personsLogicType>({
setSplitMergeModalShown: (_, { shown }) => shown,
},
],
persons: {
setPerson: (state, { person }) => ({
...state,
results: state.results.map((p) => (person && p.id === person.id ? person : p)),
}),
setPersons: (state, { persons }) => ({
...state,
results: [...persons, ...state.results],
}),
},
person: {
loadPerson: () => null,
setPerson: (_, { person }): PersonType | null => person,
@ -100,6 +158,21 @@ export const personsLogic = kea<personsLogicType>({
return breadcrumbs
},
],
exporterProps: [
(s) => [s.listFilters, (_, { cohort }) => cohort],
(listFilters, cohort: number | 'new' | undefined): TriggerExportProps[] => [
{
export_format: ExporterFormat.CSV,
export_context: {
path: cohort
? api.cohorts.determineListUrl(cohort, listFilters)
: api.persons.determineListUrl(listFilters),
max_limit: 10000,
},
},
],
],
urlId: [() => [(_, props) => props.urlId], (urlId) => urlId],
}),
listeners: ({ actions, values }) => ({
@ -168,7 +241,38 @@ export const personsLogic = kea<personsLogicType>({
router.actions.push(urls.cohort(cohort.id))
},
}),
loaders: ({ values, actions }) => ({
loaders: ({ values, actions, props }) => ({
persons: [
{ next: null, previous: null, count: 0, results: [], offset: 0 } as CountedPaginatedResponse<PersonType> & {
offset: number
},
{
loadPersons: async ({ url }) => {
let result: CountedPaginatedResponse<PersonType> & { offset: number }
if (!url) {
const newFilters: PersonListParams = { ...values.listFilters }
newFilters.properties = [
...(values.listFilters.properties || []),
...values.hiddenListProperties,
]
if (values.featureFlags[FEATURE_FLAGS.POSTHOG_3000]) {
newFilters.include_total = true // The total count is slow, but needed for infinite loading
}
if (props.cohort) {
result = {
...(await api.get(`api/cohort/${props.cohort}/persons/?${toParams(newFilters)}`)),
offset: 0,
}
} else {
result = { ...(await api.persons.list(newFilters)), offset: 0 }
}
} else {
result = { ...(await api.get(url)), offset: parseInt(decodeParams(url).offset) }
}
return result
},
},
],
person: [
null as PersonType | null,
{
@ -196,6 +300,11 @@ export const personsLogic = kea<personsLogicType>({
],
}),
actionToUrl: ({ values, props }) => ({
setListFilters: () => {
if (props.syncWithUrl && router.values.location.pathname.indexOf('/persons') > -1) {
return ['/persons', values.listFilters, undefined, { replace: true }]
}
},
navigateToTab: () => {
if (props.syncWithUrl && router.values.location.pathname.indexOf('/person') > -1) {
const searchParams = {}
@ -235,4 +344,17 @@ export const personsLogic = kea<personsLogicType>({
}
},
}),
events: ({ props, actions }) => ({
afterMount: () => {
if (props.cohort && typeof props.cohort === 'number') {
actions.setListFilters({ cohort: props.cohort })
actions.loadPersons()
}
if (props.fixedProperties) {
actions.setHiddenListProperties(props.fixedProperties)
actions.loadPersons()
}
},
}),
})

View File

@ -102,6 +102,6 @@ export function ActionsLineGraph({
}
/>
) : (
context?.emptyState ?? <InsightEmptyState />
<InsightEmptyState heading={context?.emptyStateHeading} detail={context?.emptyStateDetail} />
)
}