diff --git a/frontend/src/layout/navigation-3000/sidebars/personsAndGroups.ts b/frontend/src/layout/navigation-3000/sidebars/personsAndGroups.ts index 20f4d520dbd..4337d81b6c0 100644 --- a/frontend/src/layout/navigation-3000/sidebars/personsAndGroups.ts +++ b/frontend/src/layout/navigation-3000/sidebars/personsAndGroups.ts @@ -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([ path(['layout', 'navigation-3000', 'sidebars', 'personsAndGroupsSidebarLogic']), connect(() => ({ values: [ + personsLogic, + ['persons', 'personsLoading'], groupsModel, ['groupTypes'], sceneLogic, @@ -23,12 +28,68 @@ export const personsAndGroupsSidebarLogic = kea ({ + 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 [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() + }), ]) diff --git a/frontend/src/lib/components/Cards/InsightCard/InsightCard.tsx b/frontend/src/lib/components/Cards/InsightCard/InsightCard.tsx index db5aac1c9ef..48e7fd0c79b 100644 --- a/frontend/src/lib/components/Cards/InsightCard/InsightCard.tsx +++ b/frontend/src/lib/components/Cards/InsightCard/InsightCard.tsx @@ -225,7 +225,7 @@ export function FilterBasedCardContent({ ) : invalidFunnelExclusion ? ( ) : empty ? ( - context?.emptyState ?? + ) : !loading && timedOut ? ( ) : apiErrored && !loading ? ( diff --git a/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts b/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts index 9b04274a60d..f4b238af624 100644 --- a/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts +++ b/frontend/src/queries/nodes/DataNode/dataNodeLogic.ts @@ -344,15 +344,13 @@ export const dataNodeLogic = kea([ } } 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 }, diff --git a/frontend/src/queries/nodes/DataTable/DataTable.tsx b/frontend/src/queries/nodes/DataTable/DataTable.tsx index d8254013b7e..5822470c9f2 100644 --- a/frontend/src/queries/nodes/DataTable/DataTable.tsx +++ b/frontend/src/queries/nodes/DataTable/DataTable.tsx @@ -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 ) ) : ( - context?.emptyState ?? ( - - ) + ) } expandable={ - expandable - ? isEventsQuery(query.source) && columnsInResponse?.includes('*') - ? { - expandedRowRender: function renderExpand({ result }) { - if (isEventsQuery(query.source) && Array.isArray(result)) { - return ( - - ) - } - if (result && !Array.isArray(result)) { - return - } - }, - 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 ( - ) - }, - rowExpandable: ({ result }) => !!(result as any)?.properties, - noIndent: true, - } - : undefined + } + if (result && !Array.isArray(result)) { + return + } + }, + 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 }) => diff --git a/frontend/src/queries/nodes/DataTable/dataTableLogic.ts b/frontend/src/queries/nodes/DataTable/dataTableLogic.ts index e45ee7399da..1bf5b5c58f3 100644 --- a/frontend/src/queries/nodes/DataTable/dataTableLogic.ts +++ b/frontend/src/queries/nodes/DataTable/dataTableLogic.ts @@ -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([ (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) diff --git a/frontend/src/queries/nodes/InsightViz/InsightContainer.tsx b/frontend/src/queries/nodes/InsightViz/InsightContainer.tsx index 48bc88f541f..688883f12e4 100644 --- a/frontend/src/queries/nodes/InsightViz/InsightContainer.tsx +++ b/frontend/src/queries/nodes/InsightViz/InsightContainer.tsx @@ -106,7 +106,7 @@ export function InsightContainer({ return } if (!hasFunnelResults && !erroredQueryId && !insightDataLoading) { - return context?.emptyState ?? + return } } diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index f76a06d7536..2d77d6b9319 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -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 { diff --git a/frontend/src/scenes/cohorts/cohortEditLogic.ts b/frontend/src/scenes/cohorts/cohortEditLogic.ts index 2e155ca9e7b..f5afc26926c 100644 --- a/frontend/src/scenes/cohorts/cohortEditLogic.ts +++ b/frontend/src/scenes/cohorts/cohortEditLogic.ts @@ -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([ } 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) diff --git a/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx b/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx index 954602c9c6a..02ac527f18e 100644 --- a/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx +++ b/frontend/src/scenes/early-access-features/EarlyAccessFeature.tsx @@ -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 )} - -
- } - > - Implement public opt-in - -
{earlyAccessFeature.stage == EarlyAccessFeatureStage.Archived && ( +
+ { +
+ +
+ } +
+ { + setListFilters({ properties }) + loadPersons() + }} + endpoint="person" + taxonomicGroupTypes={[TaxonomicFilterGroupType.PersonProperties]} + showConditionBadge + /> + } + > + Implement public opt-in + +
+ loadPersons(persons.previous)} + loadNext={() => loadPersons(persons.next)} + compact={true} + extraColumns={[]} + emptyState={emptyState} + /> +
) } diff --git a/frontend/src/scenes/persons/Person.tsx b/frontend/src/scenes/persons/Person.tsx index 72ca713313f..17e3c37359a 100644 --- a/frontend/src/scenes/persons/Person.tsx +++ b/frontend/src/scenes/persons/Person.tsx @@ -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={
showPersonDeleteModal(person)} + onClick={() => showPersonDeleteModal(person, () => loadPersons())} disabled={deletedPersonLoading} loading={deletedPersonLoading} type="secondary" diff --git a/frontend/src/scenes/persons/Person.scss b/frontend/src/scenes/persons/Persons.scss similarity index 100% rename from frontend/src/scenes/persons/Person.scss rename to frontend/src/scenes/persons/Persons.scss diff --git a/frontend/src/scenes/persons/Persons.tsx b/frontend/src/scenes/persons/Persons.tsx new file mode 100644 index 00000000000..a8d91faa203 --- /dev/null +++ b/frontend/src/scenes/persons/Persons.tsx @@ -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 ( + + + + ) +} + +interface PersonsSceneProps { + extraSceneActions?: JSX.Element[] + compact?: boolean + showFilters?: boolean + showExportAction?: boolean + extraColumns?: LemonTableColumn[] + 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 ? ( + router.actions.push(urls.ingestion() + '/platform')}> + Start sending data + + } + isEmpty={true} + /> + ) : ( +
+
+
+ {showSearch && ( + + + + )} + + {showExportAction && ( + + Exporting by CSV is limited to 10,000 users. +
+ To export more, please use the API. Do you want + to export by CSV? + + } + onConfirm={() => triggerExport(exporterProps[0])} + > + } + > + {listFilters.properties && listFilters.properties.length > 0 ? ( +
+ Export ({listFilters.properties.length} filter) +
+ ) : ( + 'Export' + )} +
+
+ )} + {extraSceneActions ? extraSceneActions : null} + +
+ {showFilters && ( + { + setListFilters({ properties }) + loadPersons() + }} + endpoint="person" + taxonomicGroupTypes={[TaxonomicFilterGroupType.PersonProperties]} + showConditionBadge + /> + )} + loadPersons(persons.previous)} + loadNext={() => loadPersons(persons.next)} + compact={compact} + extraColumns={extraColumns} + emptyState={emptyState} + /> +
+
+ )} + + ) +} diff --git a/frontend/src/scenes/persons/PersonsSearch.tsx b/frontend/src/scenes/persons/PersonsSearch.tsx new file mode 100644 index 00000000000..8b4c5166cad --- /dev/null +++ b/frontend/src/scenes/persons/PersonsSearch.tsx @@ -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 ( +
+ + + 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/scenes/persons/PersonsTable.tsx b/frontend/src/scenes/persons/PersonsTable.tsx new file mode 100644 index 00000000000..4f71a6ce058 --- /dev/null +++ b/frontend/src/scenes/persons/PersonsTable.tsx @@ -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 + 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 = [ + { + title: 'Person', + key: 'person', + render: function Render(_, person: PersonType) { + return + }, + }, + ...(!compact + ? ([ + { + title: 'ID', + key: 'id', + render: function Render(_, person: PersonType) { + return ( +
+ {person.distinct_ids.length && ( + + {person.distinct_ids[0]} + + )} +
+ ) + }, + }, + { + title: 'First seen', + dataIndex: 'created_at', + render: function Render(created_at: PersonType['created_at']) { + return created_at ? : <> + }, + }, + { + render: function Render(_, person: PersonType) { + return ( + showPersonDeleteModal(person, () => loadPersons())} + icon={} + status="danger" + size="small" + /> + ) + }, + }, + ] as Array>) + : []), + ...(extraColumns || []), + ] + + return ( + <> + { + loadNext?.() + window.scrollTo(0, 0) + } + : undefined, + onBackward: hasPrevious + ? () => { + loadPrevious?.() + window.scrollTo(0, 0) + } + : undefined, + }} + expandable={{ + expandedRowRender: function RenderPropertiesTable({ properties }) { + return Object.keys(properties).length ? ( + + ) : ( + 'This person has no properties.' + ) + }, + }} + dataSource={people} + emptyState={emptyState ? emptyState : 'No persons'} + nouns={['person', 'persons']} + /> + + + ) +} diff --git a/frontend/src/scenes/persons/mergeSplitPersonLogic.ts b/frontend/src/scenes/persons/mergeSplitPersonLogic.ts index 1a7307e4ae8..b695f4f3611 100644 --- a/frontend/src/scenes/persons/mergeSplitPersonLogic.ts +++ b/frontend/src/scenes/persons/mergeSplitPersonLogic.ts @@ -18,7 +18,11 @@ export const mergeSplitPersonLogic = kea({ 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({ ], }), listeners: ({ actions, values }) => ({ + setListFilters: () => { + actions.loadPersons() + }, cancel: () => { if (!values.executedLoading) { actions.setSplitMergeModalShown(false) @@ -64,4 +71,7 @@ export const mergeSplitPersonLogic = kea({ }, ], }), + events: ({ actions }) => ({ + afterMount: [actions.loadPersons], + }), }) diff --git a/frontend/src/scenes/persons/personsLogic.test.ts b/frontend/src/scenes/persons/personsLogic.test.ts index 8e70838a873..f8bc7fbc3a5 100644 --- a/frontend/src/scenes/persons/personsLogic.test.ts +++ b/frontend/src/scenes/persons/personsLogic.test.ts @@ -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({ diff --git a/frontend/src/scenes/persons/personsLogic.tsx b/frontend/src/scenes/persons/personsLogic.tsx index 12d2645fc8b..6ca89485f93 100644 --- a/frontend/src/scenes/persons/personsLogic.tsx +++ b/frontend/src/scenes/persons/personsLogic.tsx @@ -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({ }, 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({ 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({ 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({ 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({ 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 & { + offset: number + }, + { + loadPersons: async ({ url }) => { + let result: CountedPaginatedResponse & { 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({ ], }), 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({ } }, }), + 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() + } + }, + }), }) diff --git a/frontend/src/scenes/trends/viz/ActionsLineGraph.tsx b/frontend/src/scenes/trends/viz/ActionsLineGraph.tsx index d998f189667..de451287a73 100644 --- a/frontend/src/scenes/trends/viz/ActionsLineGraph.tsx +++ b/frontend/src/scenes/trends/viz/ActionsLineGraph.tsx @@ -102,6 +102,6 @@ export function ActionsLineGraph({ } /> ) : ( - context?.emptyState ?? + ) }