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 &&
} - - {this.state.people && this.state.people.length == 0 && } - {this.state.people && this.state.people.map((person) => [ + + {people && people.length == 0 && } + {people && people.map((person) => [ this.setState({personSelected: person.id})}> + , this.state.personSelected == person.id &&
PersonLast seen
We haven't seen any data yet. If you haven't integrated PostHog, click here to set PostHog up on your app
PersonLast seenFirst seen
We haven't seen any data yet. If you haven't integrated PostHog, click here to set PostHog up on your app
{person.name} {person.last_event && moment(person.last_event.timestamp).fromNow()}{moment(person.created_at).fromNow()}
+ {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