diff --git a/app.json b/app.json
index bb7c20a149c..a2d9c067965 100644
--- a/app.json
+++ b/app.json
@@ -1,6 +1,8 @@
{
"name": "PostHog - Product analytics",
"website": "https://www.posthog.com",
+ "repository": "https://github.com/posthog/posthog",
+ "logo": "https://posthog.com/wp-content/uploads/2020/02/Group-10.png",
"environments": {
"review": {
"scripts": {
@@ -35,6 +37,5 @@
"SECRET_KEY": {
"generator": "secret"
}
- },
- "logo": "https://posthog.com/wp-content/uploads/elementor/thumbs/Group-9-okswmm1u72v1i0der1e2tk8jeqjrowpdyt4euc6pu6.png"
+ }
}
\ No newline at end of file
diff --git a/frontend/src/People.js b/frontend/src/People.js
index 0a2f108056d..e75458dec66 100644
--- a/frontend/src/People.js
+++ b/frontend/src/People.js
@@ -2,18 +2,22 @@ import React, { Component } from 'react'
import api from './Api';
import { Link } from 'react-router-dom';
import moment from 'moment';
+import { debounce } from './utils';
let toParams = (obj) => Object.entries(obj).map(([key, val]) => `${key}=${encodeURIComponent(val)}`).join('&')
export default class People extends Component {
constructor(props) {
super(props)
- this.state = {}
+ this.state = {loading: true}
this.FilterLink = this.FilterLink.bind(this);
- this.fetchPeople.call(this);
+ this.fetchPeople = this.fetchPeople.bind(this);
+ this.debounceFetchPeople = debounce(this.fetchPeople.bind(this), 250)
+ this.fetchPeople();
+ this.clickNext = this.clickNext.bind(this);
}
- fetchPeople() {
- api.get('api/person/?include_last_event=1').then((data) => this.setState({people: data.results}))
+ fetchPeople(search) {
+ api.get('api/person/?include_last_event=1&' + (!!search ? 'search=' + search : '')).then((data) => this.setState({people: data.results, hasNext: data.next, loading: false}))
}
FilterLink(props) {
let filters = {...this.state.filters};
@@ -27,21 +31,39 @@ export default class People extends Component {
}}
>{typeof props.value === 'object' ? JSON.stringify(props.value) : props.value}
}
+ clickNext() {
+ let { people, hasNext } = this.state;
+ this.setState({hasNext: false})
+ api.get(hasNext).then((olderPeople) => {
+ this.setState({people: [...people, ...olderPeople.results], hasNext: olderPeople.next, loading: false})
+ });
+ }
render() {
+ let { hasNext, people, loading } = this.state;
+ let exampleEmail = (people && people.map((person) => person.properties.email).filter((d) => d)[0]) || 'example@gmail.com';
return (
Users
+ {people &&
e.keyCode == "13" ? this.fetchPeople(e.target.value) : this.debounceFetchPeople(e.target.value)}
+ placeholder={people && "Try " + exampleEmail + " or has:email"} />}
+ {loading && }
- Person | Last seen |
- {this.state.people && this.state.people.length == 0 && We haven't seen any data yet. If you haven't integrated PostHog, click here to set PostHog up on your app |
}
- {this.state.people && this.state.people.map((person) => [
+ Person | Last seen | First seen |
+ {people && people.length == 0 && We haven't seen any data yet. If you haven't integrated PostHog, click here to set PostHog up on your app |
}
+ {people && people.map((person) => [
this.setState({personSelected: person.id})}>
{person.name} |
{person.last_event && moment(person.last_event.timestamp).fromNow()} |
+ {moment(person.created_at).fromNow()} |
,
this.state.personSelected == person.id &&
+ {Object.keys(person.properties).length == 0 && "This person has no properties."}
{Object.keys(person.properties).sort().map((key) =>
{key}:
@@ -52,6 +74,9 @@ export default class People extends Component {
])}
|
+ {people && people.length > 0 &&
}
)
}
diff --git a/frontend/src/utils.js b/frontend/src/utils.js
index 08f543b82d1..a75ce9824dd 100644
--- a/frontend/src/utils.js
+++ b/frontend/src/utils.js
@@ -15,6 +15,20 @@ export let fromParams = () => window.location.search != '' ? window.location.sea
export let colors = ['success', 'secondary', 'warning', 'primary', 'danger', 'info', 'dark', 'light']
export let percentage = (division) => division.toLocaleString(undefined, {style: 'percent', maximumFractionDigits: 2})
+export let debounce = function debounce(func, wait, immediate) {
+ var timeout;
+ return function() {
+ var context = this, args = arguments;
+ var later = function() {
+ timeout = null;
+ if (!immediate) func.apply(context, args);
+ };
+ var callNow = immediate && !timeout;
+ clearTimeout(timeout);
+ timeout = setTimeout(later, wait);
+ if (callNow) func.apply(context, args);
+ };
+};
export function Card(props) {
return
diff --git a/posthog/api/base.py b/posthog/api/base.py
new file mode 100644
index 00000000000..27c70c89a6d
--- /dev/null
+++ b/posthog/api/base.py
@@ -0,0 +1,4 @@
+from rest_framework.pagination import CursorPagination as RestCursorPagination
+
+class CursorPagination(RestCursorPagination):
+ ordering = '-created_at'
\ No newline at end of file
diff --git a/posthog/api/person.py b/posthog/api/person.py
index b31fb83631b..52c922e2151 100644
--- a/posthog/api/person.py
+++ b/posthog/api/person.py
@@ -1,9 +1,10 @@
from posthog.models import Event, Team, Person, PersonDistinctId
-from rest_framework import serializers, viewsets, response
+from rest_framework import serializers, viewsets, response, request
from rest_framework.decorators import action
-from django.db.models import Q, Prefetch
+from django.db.models import Q, Prefetch, QuerySet
from .event import EventSerializer
from typing import Union
+from .base import CursorPagination
class PersonSerializer(serializers.HyperlinkedModelSerializer):
last_event = serializers.SerializerMethodField()
@@ -11,7 +12,7 @@ class PersonSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Person
- fields = ['id', 'name', 'distinct_ids', 'properties', 'last_event']
+ fields = ['id', 'name', 'distinct_ids', 'properties', 'last_event', 'created_at']
def get_last_event(self, person: Person) -> Union[dict, None]:
if not self.context['request'].GET.get('include_last_event'):
@@ -32,20 +33,33 @@ class PersonSerializer(serializers.HyperlinkedModelSerializer):
class PersonViewSet(viewsets.ModelViewSet):
queryset = Person.objects.all()
serializer_class = PersonSerializer
+ pagination_class = CursorPagination
+
+ def _filter_request(self, request: request.Request, queryset: QuerySet) -> QuerySet:
+ if request.GET.get('id'):
+ request.GET.pop('id')
+ people = request.GET['id'].split(',')
+ queryset = queryset.filter(id__in=people)
+ if request.GET.get('search'):
+ parts = request.GET['search'].split(' ')
+ contains = []
+ for part in parts:
+ if ':' in part:
+ queryset = queryset.filter(properties__has_key=part.split(':')[1])
+ else:
+ contains.append(part)
+ queryset = queryset.filter(properties__icontains=' '.join(contains))
+
+ queryset = queryset.prefetch_related(Prefetch('persondistinctid_set', to_attr='distinct_ids_cache'))
+ return queryset
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.filter(team=self.request.user.team_set.get())
- if self.action == 'list':
- if self.request.GET.get('id'):
- people = self.request.GET['id'].split(',')
- queryset = queryset.filter(id__in=people)
- queryset = queryset.prefetch_related(Prefetch('persondistinctid_set', to_attr='distinct_ids_cache'))
+ queryset = self._filter_request(self.request, queryset)
return queryset.order_by('-id')
@action(methods=['GET'], detail=False)
def by_distinct_id(self, request):
- # sometimes race condition creates 2
- person = self.get_queryset().filter(persondistinctid__distinct_id=str(request.GET['distinct_id'])).first()
-
+ person = self.get_queryset().get(persondistinctid__distinct_id=str(request.GET['distinct_id']))
return response.Response(PersonSerializer(person, context={'request': request}).data)
\ No newline at end of file
diff --git a/posthog/api/test/test_person.py b/posthog/api/test/test_person.py
index 461cce3b813..e15eb5d19a2 100644
--- a/posthog/api/test/test_person.py
+++ b/posthog/api/test/test_person.py
@@ -12,5 +12,17 @@ class TestPerson(BaseTest):
Person.objects.create(team=self.team, distinct_ids=['distinct_id_3', 'anonymous_id_3'])
Event.objects.create(team=self.team, distinct_id='distinct_id_3')
- with self.assertNumQueries(6):
- response = self.client.get('/api/person/').json()
\ No newline at end of file
+ with self.assertNumQueries(5):
+ response = self.client.get('/api/person/').json()
+
+ def test_search(self):
+ Person.objects.create(team=self.team, distinct_ids=['distinct_id'], properties={'email': 'someone@gmail.com'})
+ Person.objects.create(team=self.team, distinct_ids=['distinct_id_2'], properties={'email': 'another@gmail.com'})
+ Person.objects.create(team=self.team, distinct_ids=['distinct_id_3'], properties={})
+
+
+ response = self.client.get('/api/person/?search=has:email').json()
+ self.assertEqual(len(response['results']), 2)
+
+ response = self.client.get('/api/person/?search=another@gm').json()
+ self.assertEqual(len(response['results']), 1)
\ No newline at end of file