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:
parent
72dcf73b14
commit
04c54ddb2c
@ -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()
|
||||
}),
|
||||
])
|
||||
|
@ -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 ? (
|
||||
|
@ -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
|
||||
},
|
||||
|
@ -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 }) =>
|
||||
|
@ -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)
|
||||
|
@ -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} />
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
@ -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"
|
||||
|
137
frontend/src/scenes/persons/Persons.tsx
Normal file
137
frontend/src/scenes/persons/Persons.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
46
frontend/src/scenes/persons/PersonsSearch.tsx
Normal file
46
frontend/src/scenes/persons/PersonsSearch.tsx
Normal 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>
|
||||
)
|
||||
}
|
133
frontend/src/scenes/persons/PersonsTable.tsx
Normal file
133
frontend/src/scenes/persons/PersonsTable.tsx
Normal 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 />
|
||||
</>
|
||||
)
|
||||
}
|
@ -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],
|
||||
}),
|
||||
})
|
||||
|
@ -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({
|
||||
|
@ -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()
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
@ -102,6 +102,6 @@ export function ActionsLineGraph({
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
context?.emptyState ?? <InsightEmptyState />
|
||||
<InsightEmptyState heading={context?.emptyStateHeading} detail={context?.emptyStateDetail} />
|
||||
)
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user