From a936eb9d4fc85ec08ae2fa2e9820c441a4ec8066 Mon Sep 17 00:00:00 2001
From: Raquel Smith
Date: Sat, 1 Mar 2025 08:36:53 -0800
Subject: [PATCH] feat(prop-defs): add hidden option to property definitions
(#29085)
---
ee/api/ee_event_definition.py | 33 +++-
ee/api/ee_property_definition.py | 35 ++++-
ee/api/test/test_event_definition.py | 51 +++++-
ee/api/test/test_property_definition.py | 122 ++++++++++++++-
...terpriseeventdefinition_hidden_and_more.py | 22 +++
ee/migrations/max_migration.txt | 2 +-
ee/models/event_definition.py | 1 +
ee/models/property_definition.py | 1 +
.../DefinitionPopoverContents.tsx | 128 ++++++++++-----
.../TaxonomicFilter/infiniteListLogic.ts | 6 +-
.../TaxonomicFilter/taxonomicFilterLogic.tsx | 6 +
.../definition/DefinitionEdit.tsx | 35 +++--
.../definition/DefinitionView.tsx | 66 +++++++-
.../definition/definitionLogic.ts | 2 +
.../events/DefinitionHeader.tsx | 146 +++++++++++-------
frontend/src/types.ts | 3 +
posthog/api/event_definition.py | 24 +--
posthog/api/test/test_property_definition.py | 2 +-
posthog/taxonomy/property_definition_api.py | 46 +++++-
19 files changed, 580 insertions(+), 151 deletions(-)
create mode 100644 ee/migrations/0022_enterpriseeventdefinition_hidden_and_more.py
diff --git a/ee/api/ee_event_definition.py b/ee/api/ee_event_definition.py
index 2c36958e505c7..d90c079726e8d 100644
--- a/ee/api/ee_event_definition.py
+++ b/ee/api/ee_event_definition.py
@@ -1,21 +1,21 @@
+from typing import cast
+
+import posthoganalytics
from django.utils import timezone
+from loginas.utils import is_impersonated_session
from rest_framework import serializers
from ee.models.event_definition import EnterpriseEventDefinition
from posthog.api.shared import UserBasicSerializer
from posthog.api.tagged_item import TaggedItemSerializerMixin
+from posthog.event_usage import groups
+from posthog.models import User
from posthog.models.activity_logging.activity_log import (
+ Detail,
dict_changes_between,
log_activity,
- Detail,
)
-from loginas.utils import is_impersonated_session
-from typing import cast
-import posthoganalytics
-from posthog.event_usage import groups
-from posthog.models import User
-
class EnterpriseEventDefinitionSerializer(TaggedItemSerializerMixin, serializers.ModelSerializer):
updated_by = UserBasicSerializer(read_only=True)
@@ -44,6 +44,7 @@ class Meta:
"verified",
"verified_at",
"verified_by",
+ "hidden",
# Action fields
"is_action",
"action_id",
@@ -69,9 +70,27 @@ class Meta:
"created_by",
]
+ def validate(self, data):
+ validated_data = super().validate(data)
+
+ if "hidden" in validated_data and "verified" in validated_data:
+ if validated_data["hidden"] and validated_data["verified"]:
+ raise serializers.ValidationError("An event cannot be both hidden and verified")
+
+ return validated_data
+
def update(self, event_definition: EnterpriseEventDefinition, validated_data):
validated_data["updated_by"] = self.context["request"].user
+ # If setting hidden=True, ensure verified becomes false
+ if validated_data.get("hidden", False):
+ validated_data["verified"] = False
+ validated_data["verified_by"] = None
+ validated_data["verified_at"] = None
+ # If setting verified=True, ensure hidden becomes false
+ elif validated_data.get("verified", False):
+ validated_data["hidden"] = False
+
if "verified" in validated_data:
if validated_data["verified"] and not event_definition.verified:
# Verify event only if previously unverified
diff --git a/ee/api/ee_property_definition.py b/ee/api/ee_property_definition.py
index 308e7461942ca..4478cb68178f9 100644
--- a/ee/api/ee_property_definition.py
+++ b/ee/api/ee_property_definition.py
@@ -1,15 +1,16 @@
-from rest_framework import serializers
from django.utils import timezone
+from loginas.utils import is_impersonated_session
+from rest_framework import serializers
+
from ee.models.property_definition import EnterprisePropertyDefinition
from posthog.api.shared import UserBasicSerializer
from posthog.api.tagged_item import TaggedItemSerializerMixin
from posthog.models import PropertyDefinition
from posthog.models.activity_logging.activity_log import (
+ Detail,
dict_changes_between,
log_activity,
- Detail,
)
-from loginas.utils import is_impersonated_session
class EnterprisePropertyDefinitionSerializer(TaggedItemSerializerMixin, serializers.ModelSerializer):
@@ -31,6 +32,7 @@ class Meta:
"verified",
"verified_at",
"verified_by",
+ "hidden",
)
read_only_fields = [
"id",
@@ -41,7 +43,32 @@ class Meta:
"verified_by",
]
- def update(self, property_definition: EnterprisePropertyDefinition, validated_data):
+ def validate(self, data):
+ validated_data = super().validate(data)
+
+ if "hidden" in validated_data and "verified" in validated_data:
+ if validated_data["hidden"] and validated_data["verified"]:
+ raise serializers.ValidationError("A property cannot be both hidden and verified")
+
+ # If setting hidden=True, ensure verified becomes false
+ if validated_data.get("hidden", False):
+ validated_data["verified"] = False
+ # If setting verified=True, ensure hidden becomes false
+ elif validated_data.get("verified", False):
+ validated_data["hidden"] = False
+
+ return validated_data
+
+ def update(self, property_definition: EnterprisePropertyDefinition, validated_data: dict):
+ # If setting hidden=True, ensure verified becomes false
+ if validated_data.get("hidden", False):
+ validated_data["verified"] = False
+ validated_data["verified_by"] = None
+ validated_data["verified_at"] = None
+ # If setting verified=True, ensure hidden becomes false
+ elif validated_data.get("verified", False):
+ validated_data["hidden"] = False
+
validated_data["updated_by"] = self.context["request"].user
if "property_type" in validated_data:
if validated_data["property_type"] == "Numeric":
diff --git a/ee/api/test/test_event_definition.py b/ee/api/test/test_event_definition.py
index 2aa87e63e2e65..972cd90db7756 100644
--- a/ee/api/test/test_event_definition.py
+++ b/ee/api/test/test_event_definition.py
@@ -1,5 +1,5 @@
from datetime import datetime
-from typing import cast, Optional, Any
+from typing import Any, Optional, cast
import dateutil.parser
from django.utils import timezone
@@ -7,14 +7,14 @@
from rest_framework import status
from ee.models.event_definition import EnterpriseEventDefinition
-from ee.models.license import License, LicenseManager
-from posthog.api.test.test_event_definition import capture_event, EventData
+from ee.models.license import AvailableFeature, License, LicenseManager
+from posthog.api.test.test_event_definition import EventData, capture_event
+from posthog.api.test.test_organization import create_organization
from posthog.api.test.test_team import create_team
from posthog.api.test.test_user import create_user
-from posthog.models import Tag, ActivityLog, Team, User
+from posthog.models import ActivityLog, Tag, Team, User
from posthog.models.event_definition import EventDefinition
from posthog.test.base import APIBaseTest
-from posthog.api.test.test_organization import create_organization
@freeze_time("2020-01-02")
@@ -368,6 +368,47 @@ def test_event_definition_no_duplicate_tags(self):
self.assertListEqual(sorted(response.json()["tags"]), ["a", "b"])
+ def test_exclude_hidden_events(self):
+ super(LicenseManager, cast(LicenseManager, License.objects)).create(
+ plan="enterprise", valid_until=timezone.datetime(2500, 1, 19, 3, 14, 7)
+ )
+ # Create some events with hidden flag
+ EnterpriseEventDefinition.objects.create(team=self.demo_team, name="visible_event")
+ EnterpriseEventDefinition.objects.create(team=self.demo_team, name="hidden_event1", hidden=True)
+ EnterpriseEventDefinition.objects.create(team=self.demo_team, name="hidden_event2", hidden=True)
+
+ # Test without enterprise taxonomy - hidden events should still be shown even with exclude_hidden=true
+ self.organization.available_features = []
+ self.organization.save()
+
+ response = self.client.get(f"/api/projects/{self.demo_team.pk}/event_definitions/?exclude_hidden=true")
+ assert response.status_code == status.HTTP_200_OK
+ event_names = {p["name"] for p in response.json()["results"]}
+ assert "visible_event" in event_names
+ assert "hidden_event1" in event_names
+ assert "hidden_event2" in event_names
+
+ # Test with enterprise taxonomy enabled - hidden events should be excluded when exclude_hidden=true
+ self.demo_team.organization.available_product_features = [
+ {"key": AvailableFeature.INGESTION_TAXONOMY, "name": "ingestion-taxonomy"}
+ ]
+ self.demo_team.organization.save()
+
+ response = self.client.get(f"/api/projects/{self.demo_team.pk}/event_definitions/?exclude_hidden=true")
+ assert response.status_code == status.HTTP_200_OK
+ event_names = {p["name"] for p in response.json()["results"]}
+ assert "visible_event" in event_names
+ assert "hidden_event1" not in event_names
+ assert "hidden_event2" not in event_names
+
+ # Test with exclude_hidden=false (should be same as not setting it)
+ response = self.client.get(f"/api/projects/{self.demo_team.pk}/event_definitions/?exclude_hidden=false")
+ assert response.status_code == status.HTTP_200_OK
+ event_names = {p["name"] for p in response.json()["results"]}
+ assert "visible_event" in event_names
+ assert "hidden_event1" in event_names
+ assert "hidden_event2" in event_names
+
def test_event_type_event(self):
super(LicenseManager, cast(LicenseManager, License.objects)).create(
plan="enterprise", valid_until=timezone.datetime(2500, 1, 19, 3, 14, 7)
diff --git a/ee/api/test/test_property_definition.py b/ee/api/test/test_property_definition.py
index effa43a9f4b18..42c55b5ca3d49 100644
--- a/ee/api/test/test_property_definition.py
+++ b/ee/api/test/test_property_definition.py
@@ -1,13 +1,15 @@
-from typing import cast, Optional
-from freezegun import freeze_time
+from typing import Optional, cast
+
import pytest
from django.db.utils import IntegrityError
from django.utils import timezone
+from freezegun import freeze_time
from rest_framework import status
from ee.models.license import License, LicenseManager
from ee.models.property_definition import EnterprisePropertyDefinition
-from posthog.models import EventProperty, Tag, ActivityLog
+from posthog.constants import AvailableFeature
+from posthog.models import ActivityLog, EventProperty, Tag
from posthog.models.property_definition import PropertyDefinition
from posthog.test.base import APIBaseTest
@@ -378,6 +380,72 @@ def test_verify_then_unverify(self):
assert response.json()["verified_by"] is None
assert response.json()["verified_at"] is None
+ @freeze_time("2021-08-25T22:09:14.252Z")
+ def test_hidden_property_behavior(self):
+ super(LicenseManager, cast(LicenseManager, License.objects)).create(
+ plan="enterprise", valid_until=timezone.datetime(2500, 1, 19, 3, 14, 7)
+ )
+ property = EnterprisePropertyDefinition.objects.create(team=self.team, name="hidden test property")
+ response = self.client.get(f"/api/projects/@current/property_definitions/{property.id}")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ assert response.json()["hidden"] is False
+ assert response.json()["verified"] is False
+
+ # Hide the property
+ self.client.patch(
+ f"/api/projects/@current/property_definitions/{property.id}",
+ {"hidden": True},
+ )
+ response = self.client.get(f"/api/projects/@current/property_definitions/{property.id}")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ assert response.json()["hidden"] is True
+ assert response.json()["verified"] is False # Hiding should ensure verified is False
+
+ # Try to verify and hide a property at the same time (should fail)
+ response = self.client.patch(
+ f"/api/projects/@current/property_definitions/{property.id}",
+ {"verified": True, "hidden": True},
+ )
+ self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
+ self.assertIn("cannot be both hidden and verified", response.json()["detail"].lower())
+
+ # Unhide the property
+ self.client.patch(
+ f"/api/projects/@current/property_definitions/{property.id}",
+ {"hidden": False},
+ )
+ response = self.client.get(f"/api/projects/@current/property_definitions/{property.id}")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ assert response.json()["hidden"] is False
+ assert response.json()["verified"] is False
+
+ @freeze_time("2021-08-25T22:09:14.252Z")
+ def test_marking_hidden_removes_verified_status(self):
+ super(LicenseManager, cast(LicenseManager, License.objects)).create(
+ plan="enterprise", valid_until=timezone.datetime(2500, 1, 19, 3, 14, 7)
+ )
+ property = EnterprisePropertyDefinition.objects.create(
+ team=self.team, name="verified test property", verified=True
+ )
+ response = self.client.get(f"/api/projects/@current/property_definitions/{property.id}")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+
+ assert response.json()["hidden"] is False
+ assert response.json()["verified"] is True
+
+ response = self.client.patch(
+ f"/api/projects/@current/property_definitions/{property.id}",
+ {"hidden": True},
+ )
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ property.refresh_from_db()
+ assert property.hidden is True
+ assert property.verified is False
+
+ @freeze_time("2021-08-25T22:09:14.252Z")
def test_verify_then_verify_again_no_change(self):
super(LicenseManager, cast(LicenseManager, License.objects)).create(
plan="enterprise", valid_until=timezone.datetime(2500, 1, 19, 3, 14, 7)
@@ -445,7 +513,7 @@ def test_cannot_update_verified_meta_properties_directly(self):
assert response.json()["verified_by"] is None
assert response.json()["verified_at"] is None
- def test_list_property_definitions(self):
+ def test_list_property_definitions_verified_ordering(self):
super(LicenseManager, cast(LicenseManager, License.objects)).create(
plan="enterprise", valid_until=timezone.datetime(2500, 1, 19, 3, 14, 7)
)
@@ -494,7 +562,6 @@ def test_list_property_definitions(self):
]
# We should prefer properties that have been seen on an event if that is available
-
EventProperty.objects.get_or_create(team=self.team, event="$pageview", property="3_when_verified")
EventProperty.objects.get_or_create(team=self.team, event="$pageview", property="4_when_verified")
@@ -508,3 +575,48 @@ def test_list_property_definitions(self):
("5_when_verified", False, False),
("6_when_verified", False, False),
]
+
+ def test_exclude_hidden_properties(self):
+ super(LicenseManager, cast(LicenseManager, License.objects)).create(
+ plan="enterprise", valid_until=timezone.datetime(2500, 1, 19, 3, 14, 7)
+ )
+ # Create some properties with hidden flag
+ EnterprisePropertyDefinition.objects.create(team=self.team, name="visible_property", property_type="String")
+ EnterprisePropertyDefinition.objects.create(
+ team=self.team, name="hidden_property1", property_type="String", hidden=True
+ )
+ EnterprisePropertyDefinition.objects.create(
+ team=self.team, name="hidden_property2", property_type="String", hidden=True
+ )
+
+ # Test without enterprise taxonomy - hidden properties should still be shown even with exclude_hidden=true
+ self.organization.available_features = []
+ self.organization.save()
+
+ response = self.client.get(f"/api/projects/{self.team.pk}/property_definitions/?exclude_hidden=true")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ property_names = {p["name"] for p in response.json()["results"]}
+ self.assertIn("visible_property", property_names)
+ self.assertIn("hidden_property1", property_names)
+ self.assertIn("hidden_property2", property_names)
+
+ # Test with enterprise taxonomy enabled - hidden properties should be excluded when exclude_hidden=true
+ self.team.organization.available_product_features = [
+ {"key": AvailableFeature.INGESTION_TAXONOMY, "name": "ingestion-taxonomy"}
+ ]
+ self.team.organization.save()
+
+ response = self.client.get(f"/api/projects/{self.team.pk}/property_definitions/?exclude_hidden=true")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ property_names = {p["name"] for p in response.json()["results"]}
+ self.assertIn("visible_property", property_names)
+ self.assertNotIn("hidden_property1", property_names)
+ self.assertNotIn("hidden_property2", property_names)
+
+ # Test with exclude_hidden=false (should be same as not setting it)
+ response = self.client.get(f"/api/projects/{self.team.pk}/property_definitions/?exclude_hidden=false")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ property_names = {p["name"] for p in response.json()["results"]}
+ self.assertIn("visible_property", property_names)
+ self.assertIn("hidden_property1", property_names)
+ self.assertIn("hidden_property2", property_names)
diff --git a/ee/migrations/0022_enterpriseeventdefinition_hidden_and_more.py b/ee/migrations/0022_enterpriseeventdefinition_hidden_and_more.py
new file mode 100644
index 0000000000000..202fe413f963c
--- /dev/null
+++ b/ee/migrations/0022_enterpriseeventdefinition_hidden_and_more.py
@@ -0,0 +1,22 @@
+# Generated by Django 4.2.18 on 2025-02-27 19:54
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("ee", "0021_conversation_status"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="enterpriseeventdefinition",
+ name="hidden",
+ field=models.BooleanField(blank=True, default=False, null=True),
+ ),
+ migrations.AddField(
+ model_name="enterprisepropertydefinition",
+ name="hidden",
+ field=models.BooleanField(blank=True, default=False, null=True),
+ ),
+ ]
diff --git a/ee/migrations/max_migration.txt b/ee/migrations/max_migration.txt
index 0dd473a0b9c5e..9cb4a4808bc3a 100644
--- a/ee/migrations/max_migration.txt
+++ b/ee/migrations/max_migration.txt
@@ -1 +1 @@
-0021_conversation_status
+0022_enterpriseeventdefinition_hidden_and_more
diff --git a/ee/models/event_definition.py b/ee/models/event_definition.py
index fc172c4ac3c8f..aa4ec79acd9bf 100644
--- a/ee/models/event_definition.py
+++ b/ee/models/event_definition.py
@@ -23,6 +23,7 @@ class EnterpriseEventDefinition(EventDefinition):
blank=True,
related_name="verifying_user",
)
+ hidden = models.BooleanField(null=True, blank=True, default=False)
# Deprecated in favour of app-wide tagging model. See EnterpriseTaggedItem
deprecated_tags: ArrayField = ArrayField(models.CharField(max_length=32), null=True, blank=True, default=list)
diff --git a/ee/models/property_definition.py b/ee/models/property_definition.py
index 3354afacb4184..b339cc98e6ebb 100644
--- a/ee/models/property_definition.py
+++ b/ee/models/property_definition.py
@@ -19,6 +19,7 @@ class EnterprisePropertyDefinition(PropertyDefinition):
blank=True,
related_name="property_verifying_user",
)
+ hidden = models.BooleanField(blank=True, null=True, default=False)
# Deprecated in favour of app-wide tagging model. See EnterpriseTaggedItem
deprecated_tags: ArrayField = ArrayField(models.CharField(max_length=32), null=True, blank=True, default=list)
diff --git a/frontend/src/lib/components/DefinitionPopover/DefinitionPopoverContents.tsx b/frontend/src/lib/components/DefinitionPopover/DefinitionPopoverContents.tsx
index a9df100402cb5..6b8a82ba3820c 100644
--- a/frontend/src/lib/components/DefinitionPopover/DefinitionPopoverContents.tsx
+++ b/frontend/src/lib/components/DefinitionPopover/DefinitionPopoverContents.tsx
@@ -1,6 +1,6 @@
import { hide } from '@floating-ui/react'
-import { IconInfo } from '@posthog/icons'
-import { LemonButton, LemonDivider, LemonSelect, LemonSwitch } from '@posthog/lemon-ui'
+import { IconBadge, IconEye, IconHide } from '@posthog/icons'
+import { LemonButton, LemonDivider, LemonSegmentedButton, LemonSelect, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { ActionPopoverInfo } from 'lib/components/DefinitionPopover/ActionPopoverInfo'
import { CohortPopoverInfo } from 'lib/components/DefinitionPopover/CohortPopoverInfo'
@@ -23,48 +23,86 @@ import { CORE_FILTER_DEFINITIONS_BY_GROUP, isCoreFilter } from 'lib/taxonomy'
import { Fragment, useEffect, useMemo } from 'react'
import { DataWarehouseTableForInsight } from 'scenes/data-warehouse/types'
-import { ActionType, CohortType, EventDefinition, PropertyDefinition } from '~/types'
+import {
+ ActionType,
+ CohortType,
+ EventDefinition,
+ PropertyDefinition,
+ PropertyDefinitionVerificationStatus,
+} from '~/types'
import { HogQLDropdown } from '../HogQLDropdown/HogQLDropdown'
import { taxonomicFilterLogic } from '../TaxonomicFilter/taxonomicFilterLogic'
import { TZLabel } from '../TZLabel'
-export function VerifiedDefinitionCheckbox({
+export function PropertyStatusControl({
verified,
- isProperty,
+ hidden,
+ showHiddenOption,
+ allowVerification,
onChange,
compact = false,
+ isProperty,
}: {
verified: boolean
- isProperty: boolean
- onChange: (nextVerified: boolean) => void
+ hidden: boolean
+ showHiddenOption: boolean
+ allowVerification: boolean
+ onChange: (status: { verified: boolean; hidden: boolean }) => void
compact?: boolean
+ isProperty: boolean
}): JSX.Element {
- const copy = isProperty
- ? 'Verifying a property is a signal to collaborators that this property should be used in favor of similar properties.'
- : 'Verified events are prioritized in filters and other selection components. Verifying an event is a signal to collaborators that this event should be used in favor of similar events.'
+ const definitionType = isProperty ? 'property' : 'event'
+ const copy = {
+ verified: `Prioritize this ${definitionType} in filters and other selection components to signal to collaborators that this ${definitionType} should be used in favor of similar ${definitionType}s.`,
+ visible: `${
+ definitionType.charAt(0).toUpperCase() + definitionType.slice(1)
+ } is available for use but has not been verified by the team.`,
+ hidden: `Hide this ${definitionType} from filters and other selection components by default. Use this for deprecated or irrelevant ${definitionType}s.`,
+ }
+
+ const verifiedDisabledCorePropCopy = `Core PostHog ${definitionType}s are inherently treated as if verified, but they can still be hidden.`
+
+ const currentStatus: PropertyDefinitionVerificationStatus = hidden ? 'hidden' : verified ? 'verified' : 'visible'
return (
<>
- {!compact && {copy}
}
-
- {
- onChange(!verified)
+ {
+ const status = value as PropertyDefinitionVerificationStatus
+ onChange({
+ verified: status === 'verified',
+ hidden: status === 'hidden',
+ })
}}
- bordered
- label={
- <>
- Mark as verified {isProperty ? 'property' : 'event'}
- {compact && (
-
-
-
- )}
- >
- }
+ options={[
+ {
+ value: 'verified',
+ label: 'Verified',
+ tooltip: allowVerification ? copy.verified : undefined,
+ icon: ,
+ disabledReason: !allowVerification ? verifiedDisabledCorePropCopy : undefined,
+ },
+ {
+ value: 'visible',
+ label: 'Visible',
+ tooltip: copy.visible,
+ icon: ,
+ },
+ ...(showHiddenOption
+ ? [
+ {
+ value: 'hidden',
+ label: 'Hidden',
+ tooltip: copy.hidden,
+ icon: ,
+ },
+ ]
+ : []),
+ ]}
/>
+ {!compact && {copy[currentStatus]}
}
>
)
}
@@ -201,6 +239,15 @@ function DefinitionView({ group }: { group: TaxonomicFilterGroup }): JSX.Element
return (
<>
{sharedComponents}
+ {_definition.verified && (
+
+
+
+ Verified
+
+
+
+ )}
@@ -382,6 +429,10 @@ function DefinitionEdit(): JSX.Element {
return <>>
}
+ const showHiddenOption = hasTaxonomyFeatures && 'hidden' in localDefinition
+ const allowVerification =
+ hasTaxonomyFeatures && !isCoreFilter(definition.name || '') && 'verified' in localDefinition
+
return (
<>
@@ -421,15 +472,20 @@ function DefinitionEdit(): JSX.Element {
>
)}
- {definition && definition.name && !isCoreFilter(definition.name) && 'verified' in localDefinition && (
- {
- setLocalDefinition({ verified: nextVerified })
- }}
- compact
- />
+ {definition && definition.name && (showHiddenOption || allowVerification) && (
+
+
).hidden}
+ onChange={({ verified, hidden }) => {
+ setLocalDefinition({ verified, hidden } as Partial)
+ }}
+ compact
+ showHiddenOption={showHiddenOption}
+ allowVerification={allowVerification}
+ />
+
)}
diff --git a/frontend/src/lib/components/TaxonomicFilter/infiniteListLogic.ts b/frontend/src/lib/components/TaxonomicFilter/infiniteListLogic.ts
index c2f1e3a8605a1..4e879c5438c1a 100644
--- a/frontend/src/lib/components/TaxonomicFilter/infiniteListLogic.ts
+++ b/frontend/src/lib/components/TaxonomicFilter/infiniteListLogic.ts
@@ -186,9 +186,13 @@ export const infiniteListLogic = kea([
// On updating item, invalidate cache
apiCache = {}
apiCacheTimers = {}
+ const popFromResults = 'hidden' in item && item.hidden
+ const results: TaxonomicDefinitionTypes[] = values.remoteItems.results
+ .map((i) => (i.name === item.name ? (popFromResults ? null : item) : i))
+ .filter((i): i is TaxonomicDefinitionTypes => i !== null)
return {
...values.remoteItems,
- results: values.remoteItems.results.map((i) => (i.name === item.name ? item : i)),
+ results,
}
},
},
diff --git a/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx b/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx
index 766f65c64058a..152023e6de7d5 100644
--- a/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx
+++ b/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx
@@ -212,6 +212,7 @@ export const taxonomicFilterLogic = kea([
),
endpoint: combineUrl(`api/projects/${projectId}/event_definitions`, {
event_type: EventDefinitionType.Event,
+ exclude_hidden: true,
}).url,
getName: (eventDefinition: Record) => eventDefinition.name,
getValue: (eventDefinition: Record) =>
@@ -292,6 +293,7 @@ export const taxonomicFilterLogic = kea([
properties: propertyAllowList?.[TaxonomicFilterGroupType.EventProperties]
? propertyAllowList[TaxonomicFilterGroupType.EventProperties].join(',')
: undefined,
+ exclude_hidden: true,
}).url,
scopedEndpoint:
eventNames.length > 0
@@ -302,6 +304,7 @@ export const taxonomicFilterLogic = kea([
properties: propertyAllowList?.[TaxonomicFilterGroupType.EventProperties]
? propertyAllowList[TaxonomicFilterGroupType.EventProperties].join(',')
: undefined,
+ exclude_hidden: true,
}).url
: undefined,
expandLabel: ({ count, expandedCount }: { count: number; expandedCount: number }) =>
@@ -366,6 +369,7 @@ export const taxonomicFilterLogic = kea([
properties: propertyAllowList?.[TaxonomicFilterGroupType.PersonProperties]
? propertyAllowList[TaxonomicFilterGroupType.PersonProperties].join(',')
: undefined,
+ exclude_hidden: true,
}).url,
getName: (personProperty: PersonProperty) => personProperty.name,
getValue: (personProperty: PersonProperty) => personProperty.name,
@@ -424,6 +428,7 @@ export const taxonomicFilterLogic = kea([
type: TaxonomicFilterGroupType.CustomEvents,
endpoint: combineUrl(`api/projects/${projectId}/event_definitions`, {
event_type: EventDefinitionType.EventCustom,
+ exclude_hidden: true,
}).url,
getName: (eventDefinition: EventDefinition) => eventDefinition.name,
getValue: (eventDefinition: EventDefinition) => eventDefinition.name,
@@ -576,6 +581,7 @@ export const taxonomicFilterLogic = kea([
endpoint: combineUrl(`api/projects/${projectId}/property_definitions`, {
type: 'group',
group_type_index: type.group_type_index,
+ exclude_hidden: true,
}).url,
valuesEndpoint: (key) =>
`api/projects/${projectId}/groups/property_values?${toParams({
diff --git a/frontend/src/scenes/data-management/definition/DefinitionEdit.tsx b/frontend/src/scenes/data-management/definition/DefinitionEdit.tsx
index 834f3a25af00d..928fc5d34f4a4 100644
--- a/frontend/src/scenes/data-management/definition/DefinitionEdit.tsx
+++ b/frontend/src/scenes/data-management/definition/DefinitionEdit.tsx
@@ -1,7 +1,7 @@
import { LemonSkeleton, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { Form } from 'kea-forms'
-import { VerifiedDefinitionCheckbox } from 'lib/components/DefinitionPopover/DefinitionPopoverContents'
+import { PropertyStatusControl } from 'lib/components/DefinitionPopover/DefinitionPopoverContents'
import { NotFound } from 'lib/components/NotFound'
import { ObjectTags } from 'lib/components/ObjectTags/ObjectTags'
import { PageHeader } from 'lib/components/PageHeader'
@@ -33,8 +33,9 @@ export function DefinitionEdit(props: DefinitionLogicProps = {}): JSX.Element {
const { saveDefinition } = useActions(logic)
const { tags, tagsLoading } = useValues(tagsModel)
- const showVerifiedCheckbox =
- hasTaxonomyFeatures && !isCoreFilter(editDefinition.name) && 'verified' in editDefinition
+ const allowVerification = hasTaxonomyFeatures && !isCoreFilter(editDefinition.name) && 'verified' in editDefinition
+
+ const showHiddenOption = hasTaxonomyFeatures && 'hidden' in editDefinition
if (definitionMissing) {
return
@@ -108,17 +109,25 @@ export function DefinitionEdit(props: DefinitionLogicProps = {}): JSX.Element {
)}
- {showVerifiedCheckbox && (
+ {(allowVerification || showHiddenOption) && (
-
- {({ value, onChange }) => (
- {
- onChange(nextVerified)
- }}
- />
+
+ {({ value: verified, onChange }) => (
+
+ {({ value: hidden, onChange: onHiddenChange }) => (
+ {
+ onChange(newVerified)
+ onHiddenChange(newHidden)
+ }}
+ />
+ )}
+
)}
diff --git a/frontend/src/scenes/data-management/definition/DefinitionView.tsx b/frontend/src/scenes/data-management/definition/DefinitionView.tsx
index 5e22f38e1043e..c4e17f47ef6a1 100644
--- a/frontend/src/scenes/data-management/definition/DefinitionView.tsx
+++ b/frontend/src/scenes/data-management/definition/DefinitionView.tsx
@@ -1,4 +1,6 @@
-import { LemonDivider, LemonTag } from '@posthog/lemon-ui'
+import { IconBadge, IconEye } from '@posthog/icons'
+import { IconHide } from '@posthog/icons'
+import { LemonDivider, LemonTag, LemonTagType, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { EditableField } from 'lib/components/EditableField/EditableField'
import { NotFound } from 'lib/components/NotFound'
@@ -22,7 +24,7 @@ import { urls } from 'scenes/urls'
import { defaultDataTableColumns } from '~/queries/nodes/DataTable/utils'
import { Query } from '~/queries/Query/Query'
import { NodeKind } from '~/queries/schema/schema-general'
-import { FilterLogicalOperator, PropertyDefinition, ReplayTabs } from '~/types'
+import { FilterLogicalOperator, PropertyDefinition, PropertyDefinitionVerificationStatus, ReplayTabs } from '~/types'
export const scene: SceneExport = {
component: DefinitionView,
@@ -32,6 +34,40 @@ export const scene: SceneExport = {
}),
}
+type StatusProps = {
+ tagType: LemonTagType
+ label: string
+ icon: React.ReactNode
+ tooltip: string
+}
+
+const getStatusProps = (isProperty: boolean): Record => ({
+ verified: {
+ tagType: 'success',
+ label: 'Verified',
+ icon: ,
+ tooltip: `This ${
+ isProperty ? 'property' : 'event'
+ } is verified and can be used in filters and other selection components.`,
+ },
+ hidden: {
+ tagType: 'danger',
+ label: 'Hidden',
+ icon: ,
+ tooltip: `This ${
+ isProperty ? 'property' : 'event'
+ } is hidden and will not appear in filters and other selection components.`,
+ },
+ visible: {
+ tagType: 'default',
+ label: 'Visible',
+ icon: ,
+ tooltip: `This ${
+ isProperty ? 'property' : 'event'
+ } is visible and can be used in filters and other selection components.`,
+ },
+})
+
export function DefinitionView(props: DefinitionLogicProps = {}): JSX.Element {
const logic = definitionLogic(props)
const { definition, definitionLoading, definitionMissing, hasTaxonomyFeatures, singular, isEvent, isProperty } =
@@ -60,6 +96,10 @@ export function DefinitionView(props: DefinitionLogicProps = {}): JSX.Element {
return
}
+ const definitionStatus = definition.verified ? 'verified' : definition.hidden ? 'hidden' : 'visible'
+
+ const statusProps = getStatusProps(isProperty)
+
return (
<>
{' '}
- will no longer appear in selectors. Associated data will remain
- in the database.
+ will no longer appear in selectors. Associated data will remain in the
+ database.
- This definition will be recreated if the {singular} is ever seen again
- in the event stream.
+ This definition will be recreated if the ${singular} is ever seen again
+ in the event stream.
>
),
@@ -199,6 +239,20 @@ export function DefinitionView(props: DefinitionLogicProps = {}): JSX.Element {
)}
+ {definitionStatus && (
+
+
Verification status
+
+
+
+ {statusProps[definitionStatus].icon}
+ {statusProps[definitionStatus].label}
+
+
+
+
+ )}
+
{isProperty && (
Property type
diff --git a/frontend/src/scenes/data-management/definition/definitionLogic.ts b/frontend/src/scenes/data-management/definition/definitionLogic.ts
index 8a814720b6d74..c469ead44d6fb 100644
--- a/frontend/src/scenes/data-management/definition/definitionLogic.ts
+++ b/frontend/src/scenes/data-management/definition/definitionLogic.ts
@@ -19,6 +19,8 @@ import type { definitionLogicType } from './definitionLogicType'
export const createNewDefinition = (isEvent: boolean): Definition => ({
id: 'new',
name: `New ${isEvent ? 'Event' : 'Event property'}`,
+ verified: false,
+ hidden: false,
})
export interface SetDefinitionProps {
diff --git a/frontend/src/scenes/data-management/events/DefinitionHeader.tsx b/frontend/src/scenes/data-management/events/DefinitionHeader.tsx
index 9ecf8b43ce2ed..074bedf7ad0ec 100644
--- a/frontend/src/scenes/data-management/events/DefinitionHeader.tsx
+++ b/frontend/src/scenes/data-management/events/DefinitionHeader.tsx
@@ -1,33 +1,63 @@
import { IconBadge, IconBolt, IconCursor, IconEye, IconLeave, IconList, IconLogomark } from '@posthog/icons'
import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo'
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
-import { IconSelectAll } from 'lib/lemon-ui/icons'
+import { IconEyeHidden, IconSelectAll } from 'lib/lemon-ui/icons'
import { LemonTableLink } from 'lib/lemon-ui/LemonTable/LemonTableLink'
import { LinkProps } from 'lib/lemon-ui/Link'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { CORE_FILTER_DEFINITIONS_BY_GROUP, getCoreFilterDefinition } from 'lib/taxonomy'
+import React from 'react'
import { EventDefinition, PropertyDefinition } from '~/types'
+interface IconWithBadgeProps {
+ icon: JSX.Element
+ verified?: boolean
+ hidden?: boolean
+ tooltipTitle: string
+ className?: string
+}
+
+function IconWithBadge({ icon, verified, hidden, tooltipTitle, className }: IconWithBadgeProps): JSX.Element {
+ const wrappedIcon = (
+
+ {React.cloneElement(icon, { className: className || icon.props.className })}
+ {(verified || hidden) && (
+
+ {hidden ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+ )
+
+ const tooltipSuffix = hidden ? ', hidden' : verified ? ', verified' : ''
+ return
{wrappedIcon}
+}
+
export function getPropertyDefinitionIcon(definition: PropertyDefinition): JSX.Element {
if (CORE_FILTER_DEFINITIONS_BY_GROUP.event_properties[definition.name]) {
return (
-
-
-
- )
- }
- if (definition.verified) {
- return (
-
-
-
+
}
+ tooltipTitle="PostHog event property"
+ className="taxonomy-icon taxonomy-icon-muted"
+ verified={definition.verified}
+ hidden={definition.hidden}
+ />
)
}
return (
-
-
-
+
}
+ tooltipTitle="Event property"
+ className="taxonomy-icon taxonomy-icon-muted"
+ verified={definition.verified}
+ hidden={definition.hidden}
+ />
)
}
@@ -35,46 +65,67 @@ export function getEventDefinitionIcon(definition: EventDefinition & { value?: s
// Rest are events
if (definition.name === '$pageview' || definition.name === '$screen') {
return (
-
-
-
+
}
+ verified={definition.verified}
+ hidden={definition.hidden}
+ tooltipTitle="Pageview"
+ className="taxonomy-icon taxonomy-icon-ph taxonomy-icon-muted"
+ />
)
}
if (definition.name === '$pageleave') {
return (
-
-
-
+
}
+ verified={definition.verified}
+ hidden={definition.hidden}
+ tooltipTitle="PostHog event"
+ className="taxonomy-icon taxonomy-icon-ph taxonomy-icon-muted"
+ />
)
}
if (definition.name === '$autocapture') {
- return
- }
- if (definition.name && definition.verified) {
return (
-
-
-
+
}
+ verified={definition.verified}
+ hidden={definition.hidden}
+ tooltipTitle="Autocapture event"
+ className="taxonomy-icon taxonomy-icon-ph taxonomy-icon-muted"
+ />
)
}
if (definition.name && !!CORE_FILTER_DEFINITIONS_BY_GROUP.events[definition.name]) {
return (
-
-
-
+
}
+ verified={definition.verified}
+ hidden={definition.hidden}
+ tooltipTitle="PostHog event"
+ className="taxonomy-icon taxonomy-icon-muted"
+ />
)
}
if (definition.value === null) {
return (
-
-
-
+
}
+ verified={definition.verified}
+ hidden={definition.hidden}
+ tooltipTitle="All events"
+ className="taxonomy-icon taxonomy-icon-built-in"
+ />
)
}
return (
-
-
-
+
}
+ verified={definition.verified}
+ hidden={definition.hidden}
+ tooltipTitle="Custom event"
+ className="taxonomy-icon taxonomy-icon-muted"
+ />
)
}
@@ -95,30 +146,7 @@ export function DefinitionHeader({
to={to}
description={description}
title={
- <>
-
- {definition.verified && (
- <>
-
-
-
- >
- )}
- {!!CORE_FILTER_DEFINITIONS_BY_GROUP.events[definition.name] && (
-
-
-
- )}
- >
+
}
/>
)
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 37d59493ba52e..356a95f68d57d 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -3305,6 +3305,7 @@ export interface EventDefinition {
verified_at?: string
verified_by?: string
is_action?: boolean
+ hidden?: boolean
}
// TODO duplicated from plugin server. Follow-up to de-duplicate
@@ -3345,6 +3346,7 @@ export interface PropertyDefinition {
verified?: boolean
verified_at?: string
verified_by?: string
+ hidden?: boolean
}
export enum PropertyDefinitionState {
@@ -3354,6 +3356,7 @@ export enum PropertyDefinitionState {
Error = 'error',
}
+export type PropertyDefinitionVerificationStatus = 'verified' | 'hidden' | 'visible'
export type Definition = EventDefinition | PropertyDefinition
export interface PersonProperty {
diff --git a/posthog/api/event_definition.py b/posthog/api/event_definition.py
index 4156d10f82793..e767d8ba2910c 100644
--- a/posthog/api/event_definition.py
+++ b/posthog/api/event_definition.py
@@ -1,14 +1,8 @@
from typing import Any, Literal, cast
from django.db.models import Manager
-from rest_framework import (
- mixins,
- serializers,
- viewsets,
- request,
- status,
- response,
-)
+from loginas.utils import is_impersonated_session
+from rest_framework import mixins, request, response, serializers, status, viewsets
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.shared import UserBasicSerializer
@@ -23,7 +17,6 @@
from posthog.models.user import User
from posthog.models.utils import UUIDT
from posthog.settings import EE_AVAILABLE
-from loginas.utils import is_impersonated_session
# If EE is enabled, we use ee.api.ee_event_definition.EnterpriseEventDefinitionSerializer
@@ -55,6 +48,15 @@ class Meta:
"post_to_slack",
)
+ def validate(self, data):
+ validated_data = super().validate(data)
+
+ if "hidden" in validated_data and "verified" in validated_data:
+ if validated_data["hidden"] and validated_data["verified"]:
+ raise serializers.ValidationError("An event cannot be both hidden and verified")
+
+ return validated_data
+
def update(self, event_definition: EventDefinition, validated_data):
raise EnterpriseFeatureException()
@@ -103,6 +105,10 @@ def dangerously_get_queryset(self):
else:
event_definition_object_manager = EventDefinition.objects
+ exclude_hidden = self.request.GET.get("exclude_hidden", "false").lower() == "true"
+ if exclude_hidden and is_enterprise:
+ search_query = search_query + " AND (hidden IS NULL OR hidden = false)"
+
sql = create_event_definitions_sql(
event_type,
is_enterprise=is_enterprise,
diff --git a/posthog/api/test/test_property_definition.py b/posthog/api/test/test_property_definition.py
index 2a568e1e843d7..c3a275e14a11f 100644
--- a/posthog/api/test/test_property_definition.py
+++ b/posthog/api/test/test_property_definition.py
@@ -5,12 +5,12 @@
from rest_framework import status
from posthog.models import (
+ ActivityLog,
EventDefinition,
EventProperty,
Organization,
PropertyDefinition,
Team,
- ActivityLog,
)
from posthog.taxonomy.property_definition_api import PropertyDefinitionQuerySerializer
from posthog.test.base import APIBaseTest, BaseTest
diff --git a/posthog/taxonomy/property_definition_api.py b/posthog/taxonomy/property_definition_api.py
index 401f9c86264c4..56eb233098dad 100644
--- a/posthog/taxonomy/property_definition_api.py
+++ b/posthog/taxonomy/property_definition_api.py
@@ -1,19 +1,19 @@
import dataclasses
import json
-from django.db.models.functions import Coalesce
-from typing import Any, Optional, cast, Self
+from typing import Any, Optional, Self, cast
from django.db import connection, models
+from django.db.models.functions import Coalesce
from django.shortcuts import get_object_or_404
from loginas.utils import is_impersonated_session
from rest_framework import mixins, request, response, serializers, status, viewsets
-from posthog.api.utils import action
from rest_framework.exceptions import ValidationError
from rest_framework.pagination import LimitOffsetPagination
from posthog.api.documentation import extend_schema
from posthog.api.routing import TeamAndOrgViewSetMixin
from posthog.api.tagged_item import TaggedItemSerializerMixin, TaggedItemViewSetMixin
+from posthog.api.utils import action
from posthog.constants import GROUP_TYPES_LIMIT, AvailableFeature
from posthog.event_usage import report_user_action
from posthog.exceptions import EnterpriseFeatureException
@@ -78,6 +78,12 @@ class PropertyDefinitionQuerySerializer(serializers.Serializer):
required=False,
)
+ exclude_hidden = serializers.BooleanField(
+ help_text="Whether to exclude properties marked as hidden",
+ required=False,
+ default=False,
+ )
+
def validate(self, attrs):
type_ = attrs.get("type", "event")
@@ -259,6 +265,19 @@ def with_excluded_properties(self, excluded_properties: Optional[str], type: str
},
)
+ def with_hidden_filter(self, exclude_hidden: bool, use_enterprise_taxonomy: bool) -> Self:
+ if exclude_hidden and use_enterprise_taxonomy:
+ hidden_filter = " AND (hidden IS NULL OR hidden = false)"
+ return dataclasses.replace(
+ self,
+ excluded_properties_filter=(
+ self.excluded_properties_filter + hidden_filter
+ if self.excluded_properties_filter
+ else hidden_filter
+ ),
+ )
+ return self
+
def as_sql(self, order_by_verified: bool):
verified_ordering = "verified DESC NULLS LAST," if order_by_verified else ""
query = f"""
@@ -374,7 +393,23 @@ class Meta:
"is_seen_on_filtered_events",
)
- def update(self, property_definition: PropertyDefinition, validated_data):
+ def validate(self, data):
+ validated_data = super().validate(data)
+
+ if "hidden" in validated_data and "verified" in validated_data:
+ if validated_data["hidden"] and validated_data["verified"]:
+ raise serializers.ValidationError("A property cannot be both hidden and verified")
+
+ return validated_data
+
+ def update(self, property_definition: PropertyDefinition, validated_data: dict):
+ # If setting hidden=True, ensure verified becomes false
+ if validated_data.get("hidden", False):
+ validated_data["verified"] = False
+ # If setting verified=True, ensure hidden becomes false
+ elif validated_data.get("verified", False):
+ validated_data["hidden"] = False
+
changed_fields = {
k: v
for k, v in validated_data.items()
@@ -527,6 +562,9 @@ def dangerously_get_queryset(self):
query.validated_data.get("excluded_properties"),
type=query.validated_data.get("type"),
)
+ .with_hidden_filter(
+ query.validated_data.get("exclude_hidden", False), use_enterprise_taxonomy=use_enterprise_taxonomy
+ )
)
with connection.cursor() as cursor: