Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(prop-defs): add hidden option to property definitions #29085

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions ee/api/ee_property_definition.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -31,6 +32,7 @@ class Meta:
"verified",
"verified_at",
"verified_by",
"hidden",
)
read_only_fields = [
"id",
Expand All @@ -41,7 +43,7 @@ class Meta:
"verified_by",
]

def update(self, property_definition: EnterprisePropertyDefinition, validated_data):
def update(self, property_definition: EnterprisePropertyDefinition, validated_data: dict):
validated_data["updated_by"] = self.context["request"].user
if "property_type" in validated_data:
if validated_data["property_type"] == "Numeric":
Expand Down
120 changes: 115 additions & 5 deletions ee/api/test/test_property_definition.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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.models import ActivityLog, EventProperty, Tag
from posthog.models.property_definition import PropertyDefinition
from posthog.test.base import APIBaseTest

Expand Down Expand Up @@ -378,6 +379,116 @@ 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 a hidden property (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_verified_property_cannot_be_hidden(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

# Try to hide a verified property (should fail)
response = self.client.patch(
f"/api/projects/@current/property_definitions/{property.id}",
{"hidden": True},
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("cannot be both hidden and verified", response.json()["detail"].lower())

def test_list_hidden_properties(self):
super(LicenseManager, cast(LicenseManager, License.objects)).create(
plan="enterprise", valid_until=timezone.datetime(2500, 1, 19, 3, 14, 7)
)

properties: list[dict] = [
{"name": "1_test", "verified": True, "hidden": False},
{"name": "2_test", "verified": False, "hidden": True},
{"name": "3_test", "verified": False, "hidden": True},
{"name": "4_test", "verified": False, "hidden": False},
]

for property in properties:
EnterprisePropertyDefinition.objects.create(
team=self.team, name=property["name"], verified=property["verified"], hidden=property["hidden"]
)

response = self.client.get("/api/projects/@current/property_definitions/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["count"], len(properties))

results = response.json()["results"]
# Verify properties should be first, then standard properties, then hidden properties
assert [(r["name"], r["verified"], r["hidden"]) for r in results] == [
("1_test", True, False), # Verified first
("4_test", False, False), # Then standard
("2_test", False, True), # Then hidden
("3_test", False, True), # Then hidden
]

# We should prefer properties that have been seen on an event
EventProperty.objects.get_or_create(team=self.team, event="$pageview", property="3_test")
EventProperty.objects.get_or_create(team=self.team, event="$pageview", property="4_test")

response = self.client.get("/api/projects/@current/property_definitions/?event_names=%5B%22%24pageview%22%5D")
results = response.json()["results"]

# Within each category (verified, standard, hidden), properties seen on events should be first
assert [(r["name"], r["verified"], r["hidden"], r["is_seen_on_filtered_events"]) for r in results] == [
("1_test", True, False, False), # Verified first (not seen)
("4_test", False, False, True), # Standard seen on event
("3_test", False, True, True), # Hidden seen on event
("2_test", False, True, False), # Hidden not seen
]

@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)
Expand Down Expand Up @@ -445,7 +556,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)
)
Expand Down Expand Up @@ -494,7 +605,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")

Expand Down
17 changes: 17 additions & 0 deletions ee/migrations/0022_enterprisepropertydefinition_hidden.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Generated by Django 4.2.18 on 2025-02-21 04:20

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("ee", "0021_conversation_status"),
]

operations = [
migrations.AddField(
model_name="enterprisepropertydefinition",
name="hidden",
field=models.BooleanField(blank=True, default=False, null=True),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider making null=False since default=False is provided. Having both nullable and a default value can lead to ambiguous states.

),
]
2 changes: 1 addition & 1 deletion ee/migrations/max_migration.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0021_conversation_status
0022_enterprisepropertydefinition_hidden
1 change: 1 addition & 0 deletions ee/models/property_definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class EnterprisePropertyDefinition(PropertyDefinition):
blank=True,
related_name="property_verifying_user",
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hidden_at, hidden_by (following the verified patter) could be helpful, esp if we don't have audit logs on prop defs (which I'm not sure whether or not we do)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I'd rather this go into the activity log tbh... and I don't feel like doing that now 😬 Is that cool with you or do you really think it should be included?

hidden = models.BooleanField(blank=True, null=True, default=False)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: Consider making hidden field non-nullable since it has a default value. This would simplify logic and prevent potential null checks.

Suggested change
hidden = models.BooleanField(blank=True, null=True, default=False)
hidden = models.BooleanField(blank=True, default=False)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good suggestion, same as verified above

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have to allow null I think so that the existing rows don't all have to be updated, if anything I would remove the default. Thoughts?


# 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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 { IconCheckCircle, IconEye, IconHide, IconInfo } from '@posthog/icons'
import { LemonButton, LemonDivider, LemonSegmentedButton, LemonSelect, LemonSwitch, LemonTag } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { ActionPopoverInfo } from 'lib/components/DefinitionPopover/ActionPopoverInfo'
import { CohortPopoverInfo } from 'lib/components/DefinitionPopover/CohortPopoverInfo'
Expand All @@ -21,6 +21,7 @@ import { Popover } from 'lib/lemon-ui/Popover'
import { Tooltip } from 'lib/lemon-ui/Tooltip'
import { CORE_FILTER_DEFINITIONS_BY_GROUP, isCoreFilter } from 'lib/taxonomy'
import { Fragment, useEffect, useMemo } from 'react'
import { PropertyDefinitionStatus } from 'scenes/data-management/definition/definitionLogic'
import { DataWarehouseTableForInsight } from 'scenes/data-warehouse/types'

import { ActionType, CohortType, EventDefinition, PropertyDefinition } from '~/types'
Expand Down Expand Up @@ -69,6 +70,82 @@ export function VerifiedDefinitionCheckbox({
)
}

export function PropertyStatusControl({
verified,
hidden,
showHiddenOption,
allowVerification,
onChange,
compact = false,
}: {
verified: boolean
hidden: boolean
showHiddenOption: boolean
allowVerification: boolean
onChange: (status: { verified: boolean; hidden: boolean }) => void
compact?: boolean
}): JSX.Element {
const copy = {
verified:
'Prioritize this property in filters and other selection components to signal to collaborators that this property should be used in favor of similar properties.',
standard: 'Property is available for use but has not been verified by the team.',
hidden: 'Hide this property from filters and other selection components by default. Use this for deprecated or irrelevant properties.',
}

const verifiedDisabledCorePropCopy = 'Core PostHog properties cannot be verified, but they can be hidden.'

const icon = {
verified: <IconCheckCircle />,
standard: <IconEye />,
hidden: <IconHide />,
}

const currentStatus: PropertyDefinitionStatus = hidden ? 'hidden' : verified ? 'verified' : 'standard'

return (
<>
<div>
<LemonSegmentedButton
value={currentStatus}
onChange={(value) => {
const status = value as PropertyDefinitionStatus
onChange({
verified: status === 'verified',
hidden: status === 'hidden',
})
}}
options={[
{
value: 'verified',
label: 'Verified',
tooltip: allowVerification ? copy.verified : undefined,
icon: icon.verified,
disabledReason: !allowVerification ? verifiedDisabledCorePropCopy : undefined,
},
{
value: 'standard',
label: 'Standard',
tooltip: copy.standard,
icon: icon.standard,
},
...(showHiddenOption
? [
{
value: 'hidden',
label: 'Hidden',
tooltip: copy.hidden,
icon: icon.hidden,
},
]
: []),
]}
/>
</div>
{!compact && <p className="italic">{copy[currentStatus]}</p>}
</>
)
}

function DefinitionView({ group }: { group: TaxonomicFilterGroup }): JSX.Element {
const {
definition,
Expand Down Expand Up @@ -201,6 +278,15 @@ function DefinitionView({ group }: { group: TaxonomicFilterGroup }): JSX.Element
return (
<>
{sharedComponents}
{_definition.verified && (
<div className="mb-4">
<Tooltip title="This property is verified by the team. It is prioritized in filters and other selection components.">
<LemonTag type="success">
<IconCheckCircle /> Verified
</LemonTag>
</Tooltip>
</div>
)}
<DefinitionPopover.Grid cols={2}>
<DefinitionPopover.Card title="Property Type" value={_definition.property_type ?? '-'} />
</DefinitionPopover.Grid>
Expand Down Expand Up @@ -382,6 +468,10 @@ function DefinitionEdit(): JSX.Element {
return <></>
}

const showHiddenOption = hasTaxonomyFeatures && 'hidden' in localDefinition
const allowVerification =
hasTaxonomyFeatures && !isCoreFilter(definition.name || '') && 'verified' in localDefinition

return (
<>
<LemonDivider className="DefinitionPopover my-4" />
Expand Down Expand Up @@ -421,15 +511,30 @@ function DefinitionEdit(): JSX.Element {
</div>
</>
)}
{definition && definition.name && !isCoreFilter(definition.name) && 'verified' in localDefinition && (
<VerifiedDefinitionCheckbox
verified={!!localDefinition.verified}
isProperty={isProperty}
onChange={(nextVerified) => {
setLocalDefinition({ verified: nextVerified })
}}
compact
/>
{definition && definition.name && (showHiddenOption || allowVerification) && (
<div className="mb-4">
{isProperty ? (
<PropertyStatusControl
verified={!!localDefinition.verified}
hidden={!!(localDefinition as Partial<PropertyDefinition>).hidden}
onChange={({ verified, hidden }) => {
setLocalDefinition({ verified, hidden } as Partial<PropertyDefinition>)
}}
compact
showHiddenOption={showHiddenOption}
allowVerification={allowVerification}
/>
) : (
<VerifiedDefinitionCheckbox
verified={!!localDefinition.verified}
isProperty={isProperty}
onChange={(nextVerified) => {
setLocalDefinition({ verified: nextVerified })
}}
compact
/>
)}
</div>
)}
<LemonDivider className="DefinitionPopover mt-0" />
<div className="flex items-center justify-between gap-2 click-outside-block">
Expand Down
Loading
Loading