diff --git a/.github/pr-deploy/hobby.yaml.tmpl b/.github/pr-deploy/hobby.yaml.tmpl index ee405b997111b..f284937c6a2a5 100644 --- a/.github/pr-deploy/hobby.yaml.tmpl +++ b/.github/pr-deploy/hobby.yaml.tmpl @@ -20,7 +20,7 @@ spec: privileged: true resources: requests: - cpu: 250m + cpu: 2 memory: 500M ports: - containerPort: 2375 @@ -72,4 +72,4 @@ spec: service: name: hobby-service-$HOSTNAME port: - number: 80 \ No newline at end of file + number: 80 diff --git a/bin/mprocs.yaml b/bin/mprocs.yaml index f57efc845d834..aeb843bab051a 100644 --- a/bin/mprocs.yaml +++ b/bin/mprocs.yaml @@ -1,9 +1,9 @@ procs: celery-worker: - shell: 'bin/check_kafka_clickhouse_up && source ./bin/celery-queues.env && python manage.py run_autoreload_celery --type=worker' + shell: 'bin/check_kafka_clickhouse_up && ./bin/start-celery worker' celery-beat: - shell: 'bin/check_kafka_clickhouse_up && source ./bin/celery-queues.env && python manage.py run_autoreload_celery --type=beat' + shell: 'bin/check_kafka_clickhouse_up && ./bin/start-celery beat' plugin-server: shell: 'bin/check_kafka_clickhouse_up && ./bin/plugin-server' diff --git a/bin/start-celery b/bin/start-celery new file mode 100755 index 0000000000000..ffefe0298e296 --- /dev/null +++ b/bin/start-celery @@ -0,0 +1,9 @@ +#!/bin/bash +# Starts a celery worker / heartbeat job. Must be run with a type of process: worker | beat + +set -e + +source ./bin/celery-queues.env + +# start celery worker with heartbeat (-B) +python manage.py run_autoreload_celery --type=$1 diff --git a/cypress/productAnalytics/index.ts b/cypress/productAnalytics/index.ts index 5bfdeae781326..7949e7eced1e9 100644 --- a/cypress/productAnalytics/index.ts +++ b/cypress/productAnalytics/index.ts @@ -167,6 +167,7 @@ export const dashboard = { cy.get('[data-attr=dashboard-add-graph-header]').contains('Add insight').click() cy.get('[data-attr=toast-close-button]').click({ multiple: true }) + cy.get('[data-attr=dashboard-add-new-insight]').contains('New insight').click() if (insightName) { cy.get('[data-attr="top-bar-name"] button').click() diff --git a/ee/api/test/__snapshots__/test_organization_resource_access.ambr b/ee/api/test/__snapshots__/test_organization_resource_access.ambr index bf8927ee81e66..507fdfb3fb632 100644 --- a/ee/api/test/__snapshots__/test_organization_resource_access.ambr +++ b/ee/api/test/__snapshots__/test_organization_resource_access.ambr @@ -19,6 +19,7 @@ "posthog_user"."has_seen_product_intro_for", "posthog_user"."strapi_id", "posthog_user"."is_active", + "posthog_user"."role_at_organization", "posthog_user"."theme_mode", "posthog_user"."partial_notification_settings", "posthog_user"."anonymize_data", @@ -191,6 +192,7 @@ "posthog_user"."has_seen_product_intro_for", "posthog_user"."strapi_id", "posthog_user"."is_active", + "posthog_user"."role_at_organization", "posthog_user"."theme_mode", "posthog_user"."partial_notification_settings", "posthog_user"."anonymize_data", diff --git a/ee/clickhouse/views/experiments.py b/ee/clickhouse/views/experiments.py index 9a21a668c273a..a0045f96f26df 100644 --- a/ee/clickhouse/views/experiments.py +++ b/ee/clickhouse/views/experiments.py @@ -192,6 +192,7 @@ class Meta: "type", "metrics", "metrics_secondary", + "stats_config", ] read_only_fields = [ "id", @@ -300,6 +301,9 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment: feature_flag_serializer.is_valid(raise_exception=True) feature_flag = feature_flag_serializer.save() + if not validated_data.get("stats_config"): + validated_data["stats_config"] = {"version": 2} + experiment = Experiment.objects.create( team_id=self.context["team_id"], feature_flag=feature_flag, **validated_data ) @@ -376,6 +380,7 @@ def update(self, instance: Experiment, validated_data: dict, *args: Any, **kwarg "holdout", "metrics", "metrics_secondary", + "stats_config", } given_keys = set(validated_data.keys()) extra_keys = given_keys - expected_keys diff --git a/ee/clickhouse/views/test/test_clickhouse_experiments.py b/ee/clickhouse/views/test/test_clickhouse_experiments.py index 4501301e3befd..308dbdc207752 100644 --- a/ee/clickhouse/views/test/test_clickhouse_experiments.py +++ b/ee/clickhouse/views/test/test_clickhouse_experiments.py @@ -102,6 +102,11 @@ def test_creating_updating_basic_experiment(self): self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.json()["name"], "Test Experiment") self.assertEqual(response.json()["feature_flag_key"], ff_key) + self.assertEqual(response.json()["stats_config"], {"version": 2}) + + id = response.json()["id"] + experiment = Experiment.objects.get(pk=id) + self.assertEqual(experiment.get_stats_config("version"), 2) created_ff = FeatureFlag.objects.get(key=ff_key) @@ -110,13 +115,12 @@ def test_creating_updating_basic_experiment(self): self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test") self.assertEqual(created_ff.filters["groups"][0]["properties"], []) - id = response.json()["id"] end_date = "2021-12-10T00:00" # Now update response = self.client.patch( f"/api/projects/{self.team.id}/experiments/{id}", - {"description": "Bazinga", "end_date": end_date}, + {"description": "Bazinga", "end_date": end_date, "stats_config": {"version": 1}}, ) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -124,6 +128,7 @@ def test_creating_updating_basic_experiment(self): experiment = Experiment.objects.get(pk=id) self.assertEqual(experiment.description, "Bazinga") self.assertEqual(experiment.end_date.strftime("%Y-%m-%dT%H:%M"), end_date) + self.assertEqual(experiment.get_stats_config("version"), 1) def test_creating_updating_web_experiment(self): ff_key = "a-b-tests" diff --git a/ee/hogai/assistant.py b/ee/hogai/assistant.py index 17a1c6341b667..d7c7570cb65c6 100644 --- a/ee/hogai/assistant.py +++ b/ee/hogai/assistant.py @@ -10,14 +10,11 @@ from pydantic import BaseModel from ee import settings -from ee.hogai.funnels.nodes import ( - FunnelGeneratorNode, -) +from ee.hogai.funnels.nodes import FunnelGeneratorNode from ee.hogai.graph import AssistantGraph +from ee.hogai.retention.nodes import RetentionGeneratorNode from ee.hogai.schema_generator.nodes import SchemaGeneratorNode -from ee.hogai.trends.nodes import ( - TrendsGeneratorNode, -) +from ee.hogai.trends.nodes import TrendsGeneratorNode from ee.hogai.utils.asgi import SyncIterableToAsync from ee.hogai.utils.state import ( GraphMessageUpdateTuple, @@ -57,6 +54,7 @@ VISUALIZATION_NODES: dict[AssistantNodeName, type[SchemaGeneratorNode]] = { AssistantNodeName.TRENDS_GENERATOR: TrendsGeneratorNode, AssistantNodeName.FUNNEL_GENERATOR: FunnelGeneratorNode, + AssistantNodeName.RETENTION_GENERATOR: RetentionGeneratorNode, } @@ -166,6 +164,8 @@ def _node_to_reasoning_message( | AssistantNodeName.TRENDS_PLANNER_TOOLS | AssistantNodeName.FUNNEL_PLANNER | AssistantNodeName.FUNNEL_PLANNER_TOOLS + | AssistantNodeName.RETENTION_PLANNER + | AssistantNodeName.RETENTION_PLANNER_TOOLS ): substeps: list[str] = [] if input: @@ -191,6 +191,8 @@ def _node_to_reasoning_message( return ReasoningMessage(content="Creating trends query") case AssistantNodeName.FUNNEL_GENERATOR: return ReasoningMessage(content="Creating funnel query") + case AssistantNodeName.RETENTION_GENERATOR: + return ReasoningMessage(content="Creating retention query") case _: return None diff --git a/ee/hogai/eval/tests/test_eval_retention_generator.py b/ee/hogai/eval/tests/test_eval_retention_generator.py new file mode 100644 index 0000000000000..409a2d58838da --- /dev/null +++ b/ee/hogai/eval/tests/test_eval_retention_generator.py @@ -0,0 +1,76 @@ +from collections.abc import Callable +from typing import cast + +import pytest +from langgraph.graph.state import CompiledStateGraph + +from ee.hogai.assistant import AssistantGraph +from ee.hogai.utils.types import AssistantNodeName, AssistantState +from posthog.schema import ( + AssistantRetentionQuery, + HumanMessage, + RetentionEntity, + VisualizationMessage, +) + + +@pytest.fixture +def call_node(team, runnable_config) -> Callable[[str, str], AssistantRetentionQuery]: + graph: CompiledStateGraph = ( + AssistantGraph(team) + .add_edge(AssistantNodeName.START, AssistantNodeName.RETENTION_GENERATOR) + .add_retention_generator(AssistantNodeName.END) + .compile() + ) + + def callable(query: str, plan: str) -> AssistantRetentionQuery: + state = graph.invoke( + AssistantState(messages=[HumanMessage(content=query)], plan=plan), + runnable_config, + ) + message = cast(VisualizationMessage, AssistantState.model_validate(state).messages[-1]) + answer = message.answer + assert isinstance(answer, AssistantRetentionQuery), "Expected AssistantRetentionQuery" + return answer + + return callable + + +def test_node_replaces_equals_with_contains(call_node): + query = "Show file upload retention after signup for users with name John" + plan = """Target event: + - signed_up + + Returning event: + - file_uploaded + + Filters: + - property filter 1: + - person + - name + - equals + - John + """ + actual_output = call_node(query, plan).model_dump_json(exclude_none=True) + assert "exact" not in actual_output + assert "icontains" in actual_output + assert "John" not in actual_output + assert "john" in actual_output + + +def test_basic_retention_structure(call_node): + query = "Show retention for users who signed up" + plan = """Target Event: + - signed_up + + Returning Event: + - file_uploaded + """ + actual_output = call_node(query, plan) + assert actual_output.retentionFilter is not None + assert actual_output.retentionFilter.targetEntity == RetentionEntity( + id="signed_up", type="events", name="signed_up", order=0 + ) + assert actual_output.retentionFilter.returningEntity == RetentionEntity( + id="file_uploaded", type="events", name="file_uploaded", order=0 + ) diff --git a/ee/hogai/eval/tests/test_eval_retention_planner.py b/ee/hogai/eval/tests/test_eval_retention_planner.py new file mode 100644 index 0000000000000..b050fbea41f7a --- /dev/null +++ b/ee/hogai/eval/tests/test_eval_retention_planner.py @@ -0,0 +1,118 @@ +from collections.abc import Callable + +import pytest +from deepeval import assert_test +from deepeval.metrics import GEval +from deepeval.test_case import LLMTestCase, LLMTestCaseParams +from langchain_core.runnables.config import RunnableConfig +from langgraph.graph.state import CompiledStateGraph + +from ee.hogai.assistant import AssistantGraph +from ee.hogai.utils.types import AssistantNodeName, AssistantState +from posthog.schema import HumanMessage + + +@pytest.fixture(scope="module") +def metric(): + return GEval( + name="Retention Plan Correctness", + criteria="You will be given expected and actual generated plans to provide a taxonomy to answer a user's question with a retention insight. Compare the plans to determine whether the taxonomy of the actual plan matches the expected plan. Do not apply general knowledge about retention insights.", + evaluation_steps=[ + "A plan must define both a target event (cohort-defining event) and a returning event (retention-measuring event), but it is not required to define any filters. It can't have breakdowns.", + "Compare target event, returning event, properties, and property values of 'expected output' and 'actual output'. Do not penalize if the actual output does not include a timeframe.", + "Check if the combination of target events, returning events, properties, and property values in 'actual output' can answer the user's question according to the 'expected output'.", + "If 'expected output' contains a breakdown, check if 'actual output' contains a similar breakdown, and heavily penalize if the breakdown is not present or different.", + # We don't want to see in the output unnecessary property filters. The assistant tries to use them all the time. + "Heavily penalize if the 'actual output' contains any excessive output not present in the 'expected output'. For example, the `is set` operator in filters should not be used unless the user explicitly asks for it.", + ], + evaluation_params=[ + LLMTestCaseParams.INPUT, + LLMTestCaseParams.EXPECTED_OUTPUT, + LLMTestCaseParams.ACTUAL_OUTPUT, + ], + threshold=0.7, + ) + + +@pytest.fixture +def call_node(team, runnable_config: RunnableConfig) -> Callable[[str], str]: + graph: CompiledStateGraph = ( + AssistantGraph(team) + .add_edge(AssistantNodeName.START, AssistantNodeName.RETENTION_PLANNER) + .add_retention_planner(AssistantNodeName.END) + .compile() + ) + + def callable(query: str) -> str: + raw_state = graph.invoke( + AssistantState(messages=[HumanMessage(content=query)]), + runnable_config, + ) + state = AssistantState.model_validate(raw_state) + return state.plan or "NO PLAN WAS GENERATED" + + return callable + + +def test_basic_retention(metric, call_node): + query = "What's the file upload retention of new users?" + test_case = LLMTestCase( + input=query, + expected_output=""" + Target event: + - signed_up + + Returning event: + - uploaded_file + """, + actual_output=call_node(query), + ) + assert_test(test_case, [metric]) + + +def test_basic_filtering(metric, call_node): + query = "Show retention of Chrome users uploading files" + test_case = LLMTestCase( + input=query, + expected_output=""" + Target event: + - uploaded_file + + Returning event: + - uploaded_file + + Filters: + - property filter 1: + - entity: event + - property name: $browser + - property type: String + - operator: equals + - property value: Chrome + """, + actual_output=call_node(query), + ) + assert_test(test_case, [metric]) + + +def test_needle_in_a_haystack(metric, call_node): + query = "Show retention for users who have paid a bill and are on the personal/pro plan" + test_case = LLMTestCase( + input=query, + expected_output=""" + Target event: + - paid_bill + + Returning event: + - downloaded_file + + Filters: + - property filter 1: + - entity: account + - property name: plan + - property type: String + - operator: equals + - property value: personal/pro + """, + actual_output=call_node(query), + ) + assert_test(test_case, [metric]) diff --git a/ee/hogai/graph.py b/ee/hogai/graph.py index bf961d6bb9aa8..0ba0693fa9b25 100644 --- a/ee/hogai/graph.py +++ b/ee/hogai/graph.py @@ -11,6 +11,12 @@ FunnelPlannerNode, FunnelPlannerToolsNode, ) +from ee.hogai.retention.nodes import ( + RetentionGeneratorNode, + RetentionGeneratorToolsNode, + RetentionPlannerNode, + RetentionPlannerToolsNode, +) from ee.hogai.router.nodes import RouterNode from ee.hogai.summarizer.nodes import SummarizerNode from ee.hogai.trends.nodes import ( @@ -60,6 +66,7 @@ def add_router( path_map = path_map or { "trends": AssistantNodeName.TRENDS_PLANNER, "funnel": AssistantNodeName.FUNNEL_PLANNER, + "retention": AssistantNodeName.RETENTION_PLANNER, } router_node = RouterNode(self._team) builder.add_node(AssistantNodeName.ROUTER, router_node.run) @@ -164,6 +171,53 @@ def add_funnel_generator(self, next_node: AssistantNodeName = AssistantNodeName. return self + def add_retention_planner(self, next_node: AssistantNodeName = AssistantNodeName.RETENTION_GENERATOR): + builder = self._graph + + retention_planner = RetentionPlannerNode(self._team) + builder.add_node(AssistantNodeName.RETENTION_PLANNER, retention_planner.run) + builder.add_conditional_edges( + AssistantNodeName.RETENTION_PLANNER, + retention_planner.router, + path_map={ + "tools": AssistantNodeName.RETENTION_PLANNER_TOOLS, + }, + ) + + retention_planner_tools = RetentionPlannerToolsNode(self._team) + builder.add_node(AssistantNodeName.RETENTION_PLANNER_TOOLS, retention_planner_tools.run) + builder.add_conditional_edges( + AssistantNodeName.RETENTION_PLANNER_TOOLS, + retention_planner_tools.router, + path_map={ + "continue": AssistantNodeName.RETENTION_PLANNER, + "plan_found": next_node, + }, + ) + + return self + + def add_retention_generator(self, next_node: AssistantNodeName = AssistantNodeName.SUMMARIZER): + builder = self._graph + + retention_generator = RetentionGeneratorNode(self._team) + builder.add_node(AssistantNodeName.RETENTION_GENERATOR, retention_generator.run) + + retention_generator_tools = RetentionGeneratorToolsNode(self._team) + builder.add_node(AssistantNodeName.RETENTION_GENERATOR_TOOLS, retention_generator_tools.run) + + builder.add_edge(AssistantNodeName.RETENTION_GENERATOR_TOOLS, AssistantNodeName.RETENTION_GENERATOR) + builder.add_conditional_edges( + AssistantNodeName.RETENTION_GENERATOR, + retention_generator.router, + path_map={ + "tools": AssistantNodeName.RETENTION_GENERATOR_TOOLS, + "next": next_node, + }, + ) + + return self + def add_summarizer(self, next_node: AssistantNodeName = AssistantNodeName.END): builder = self._graph summarizer_node = SummarizerNode(self._team) @@ -179,6 +233,8 @@ def compile_full_graph(self): .add_trends_generator() .add_funnel_planner() .add_funnel_generator() + .add_retention_planner() + .add_retention_generator() .add_summarizer() .compile() ) diff --git a/ee/hogai/retention/__init__.py b/ee/hogai/retention/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/ee/hogai/retention/nodes.py b/ee/hogai/retention/nodes.py new file mode 100644 index 0000000000000..4a02854834614 --- /dev/null +++ b/ee/hogai/retention/nodes.py @@ -0,0 +1,50 @@ +from langchain_core.prompts import ChatPromptTemplate +from langchain_core.runnables import RunnableConfig + +from ee.hogai.retention.prompts import RETENTION_SYSTEM_PROMPT, REACT_SYSTEM_PROMPT +from ee.hogai.retention.toolkit import RETENTION_SCHEMA, RetentionTaxonomyAgentToolkit +from ee.hogai.schema_generator.nodes import SchemaGeneratorNode, SchemaGeneratorToolsNode +from ee.hogai.schema_generator.utils import SchemaGeneratorOutput +from ee.hogai.taxonomy_agent.nodes import TaxonomyAgentPlannerNode, TaxonomyAgentPlannerToolsNode +from ee.hogai.utils.types import AssistantState, PartialAssistantState +from posthog.schema import AssistantRetentionQuery + + +class RetentionPlannerNode(TaxonomyAgentPlannerNode): + def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: + toolkit = RetentionTaxonomyAgentToolkit(self._team) + prompt = ChatPromptTemplate.from_messages( + [ + ("system", REACT_SYSTEM_PROMPT), + ], + template_format="mustache", + ) + return super()._run_with_prompt_and_toolkit(state, prompt, toolkit, config=config) + + +class RetentionPlannerToolsNode(TaxonomyAgentPlannerToolsNode): + def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: + toolkit = RetentionTaxonomyAgentToolkit(self._team) + return super()._run_with_toolkit(state, toolkit, config=config) + + +RetentionSchemaGeneratorOutput = SchemaGeneratorOutput[AssistantRetentionQuery] + + +class RetentionGeneratorNode(SchemaGeneratorNode[AssistantRetentionQuery]): + INSIGHT_NAME = "Retention" + OUTPUT_MODEL = RetentionSchemaGeneratorOutput + OUTPUT_SCHEMA = RETENTION_SCHEMA + + def run(self, state: AssistantState, config: RunnableConfig) -> PartialAssistantState: + prompt = ChatPromptTemplate.from_messages( + [ + ("system", RETENTION_SYSTEM_PROMPT), + ], + template_format="mustache", + ) + return super()._run_with_prompt(state, prompt, config=config) + + +class RetentionGeneratorToolsNode(SchemaGeneratorToolsNode): + pass diff --git a/ee/hogai/retention/prompts.py b/ee/hogai/retention/prompts.py new file mode 100644 index 0000000000000..1f9db532ed1a9 --- /dev/null +++ b/ee/hogai/retention/prompts.py @@ -0,0 +1,91 @@ +REACT_SYSTEM_PROMPT = """ + +You are an expert product analyst agent specializing in data visualization and retention analysis. Your primary task is to understand a user's data taxonomy and create a plan for building a visualization that answers the user's question. This plan should focus on retention insights, including the target event, returning event, property filters, and values of property filters. + +{{#product_description}} +The product being analyzed is described as follows: + +{{.}} + +{{/product_description}} + +{{react_format}} + + +{{react_human_in_the_loop}} + +Below you will find information on how to correctly discover the taxonomy of the user's data. + + +Retention is a type of insight that shows you how many users return during subsequent periods. + +They're useful for answering questions like: +- Are new sign ups coming back to use your product after trying it? +- Have recent changes improved retention? + + + +You'll be given a list of events in addition to the user's question. Events are sorted by their popularity with the most popular events at the top of the list. Prioritize popular events. You must always specify events to use. Events always have an associated user's profile. Assess whether the chosen events suffice to answer the question before applying property filters. Retention insights do not require filters by default. + +Plans of retention insights must always have two events: +- The activation event – an event that determines if the user is a part of a cohort. +- The retention event – an event that determines whether a user has been retained. + +For activation and retention events, use the `$pageview` event by default or the equivalent for mobile apps `$screen`. Avoid infrequent or inconsistent events like `signed in` unless asked explicitly, as they skew the data. + + +{{react_property_filters}} + + +- Ensure that any properties included are directly relevant to the context and objectives of the user's question. Avoid unnecessary or unrelated details. +- Avoid overcomplicating the response with excessive property filters. Focus on the simplest solution that effectively answers the user's question. + +--- + +{{react_format_reminder}} +""" + +RETENTION_SYSTEM_PROMPT = """ +Act as an expert product manager. Your task is to generate a JSON schema of retention insights. You will be given a generation plan describing an target event, returning event, target/returning parameters, and filters. Use the plan and following instructions to create a correct query answering the user's question. + +Below is the additional context. + +Follow this instruction to create a query: +* Build the insight according to the plan. Properties can be of multiple types: String, Numeric, Bool, and DateTime. A property can be an array of those types and only has a single type. +* When evaluating filter operators, replace the `equals` or `doesn't equal` operators with `contains` or `doesn't contain` if the query value is likely a personal name, company name, or any other name-sensitive term where letter casing matters. For instance, if the value is ‘John Doe' or ‘Acme Corp', replace `equals` with `contains` and change the value to lowercase from `John Doe` to `john doe` or `Acme Corp` to `acme corp`. +* Determine the activation type that will answer the user's question in the best way. Use the provided defaults. +* Determine the retention period and number of periods to look back. +* Determine if the user wants to filter out internal and test users. If the user didn't specify, filter out internal and test users by default. +* Determine if you need to apply a sampling factor. Only specify those if the user has explicitly asked. +* Use your judgment if there are any other parameters that the user might want to adjust that aren't listed here. + +The user might want to receive insights about groups. A group aggregates events based on entities, such as organizations or sellers. The user might provide a list of group names and their numeric indexes. Instead of a group's name, always use its numeric index. + +Retention can be aggregated by: +- Unique users (default, do not specify anything to use it). Use this option unless the user states otherwise. +- Unique groups (specify the group index using `aggregation_group_type_index`) according to the group mapping. + +## Schema Examples + +### Question: How do new users of insights retain? + +Plan: +``` +Target event: +insight created + +Returning event: +insight saved +``` + +Output: +``` +{"kind":"RetentionQuery","retentionFilter":{"period":"Week","totalIntervals":9,"targetEntity":{"id":"insight created","name":"insight created","type":"events","order":0},"returningEntity":{"id":"insight created","name":"insight created","type":"events","order":0},"retentionType":"retention_first_time","retentionReference":"total","cumulative":false},"filterTestAccounts":true} +``` + +Obey these rules: +- Filter internal users by default if the user doesn't specify. +- You can't create new events or property definitions. Stick to the plan. + +Remember, your efforts will be rewarded by the company's founders. Do not hallucinate. +""" diff --git a/ee/hogai/retention/test/test_nodes.py b/ee/hogai/retention/test/test_nodes.py new file mode 100644 index 0000000000000..5036dff215e26 --- /dev/null +++ b/ee/hogai/retention/test/test_nodes.py @@ -0,0 +1,50 @@ +from unittest.mock import patch + +from django.test import override_settings +from langchain_core.runnables import RunnableLambda + +from ee.hogai.retention.nodes import RetentionGeneratorNode, RetentionSchemaGeneratorOutput +from ee.hogai.utils.types import AssistantState, PartialAssistantState +from posthog.schema import ( + AssistantRetentionQuery, + HumanMessage, + AssistantRetentionFilter, + VisualizationMessage, +) +from posthog.test.base import APIBaseTest, ClickhouseTestMixin + + +@override_settings(IN_UNIT_TESTING=True) +class TestRetentionGeneratorNode(ClickhouseTestMixin, APIBaseTest): + maxDiff = None + + def setUp(self): + super().setUp() + self.schema = AssistantRetentionQuery( + retentionFilter=AssistantRetentionFilter( + targetEntity={"id": "targetEntity", "type": "events", "name": "targetEntity"}, + returningEntity={"id": "returningEntity", "type": "events", "name": "returningEntity"}, + ) + ) + + def test_node_runs(self): + node = RetentionGeneratorNode(self.team) + with patch.object(RetentionGeneratorNode, "_model") as generator_model_mock: + generator_model_mock.return_value = RunnableLambda( + lambda _: RetentionSchemaGeneratorOutput(query=self.schema).model_dump() + ) + new_state = node.run( + AssistantState( + messages=[HumanMessage(content="Text")], + plan="Plan", + ), + {}, + ) + self.assertEqual( + new_state, + PartialAssistantState( + messages=[VisualizationMessage(answer=self.schema, plan="Plan", id=new_state.messages[0].id)], + intermediate_steps=[], + plan="", + ), + ) diff --git a/ee/hogai/retention/toolkit.py b/ee/hogai/retention/toolkit.py new file mode 100644 index 0000000000000..966d29c7f93cc --- /dev/null +++ b/ee/hogai/retention/toolkit.py @@ -0,0 +1,57 @@ +from ee.hogai.taxonomy_agent.toolkit import TaxonomyAgentToolkit, ToolkitTool +from ee.hogai.utils.helpers import dereference_schema +from posthog.schema import AssistantRetentionQuery + + +class RetentionTaxonomyAgentToolkit(TaxonomyAgentToolkit): + def _get_tools(self) -> list[ToolkitTool]: + return [ + *self._default_tools, + { + "name": "final_answer", + "signature": "(final_response: str)", + "description": """ +Use this tool to provide the final answer to the user's question. + +Answer in the following format: +``` +Activation event: +chosen event + +Retention event: +chosen event (can be the same as activation event, or different) + +(if filters are used) +Filters: + - property filter 1: + - entity + - property name + - property type + - operator + - property value + - property filter 2... Repeat for each property filter. +``` + +Args: + final_response: List all events and properties that you want to use to answer the question.""", + }, + ] + + +def generate_retention_schema() -> dict: + schema = AssistantRetentionQuery.model_json_schema() + return { + "name": "output_insight_schema", + "description": "Outputs the JSON schema of a product analytics insight", + "parameters": { + "type": "object", + "properties": { + "query": dereference_schema(schema), + }, + "additionalProperties": False, + "required": ["query"], + }, + } + + +RETENTION_SCHEMA = generate_retention_schema() diff --git a/ee/hogai/router/nodes.py b/ee/hogai/router/nodes.py index f6aeacdebbe6b..fac5029f146ff 100644 --- a/ee/hogai/router/nodes.py +++ b/ee/hogai/router/nodes.py @@ -16,11 +16,13 @@ from ee.hogai.utils.types import AssistantState, PartialAssistantState from posthog.schema import HumanMessage, RouterMessage -RouteName = Literal["trends", "funnel"] +RouteName = Literal["trends", "funnel", "retention"] class RouterOutput(BaseModel): - visualization_type: Literal["trends", "funnel"] = Field(..., description=ROUTER_INSIGHT_DESCRIPTION_PROMPT) + visualization_type: Literal["trends", "funnel", "retention"] = Field( + ..., description=ROUTER_INSIGHT_DESCRIPTION_PROMPT + ) class RouterNode(AssistantNode): diff --git a/ee/hogai/router/prompts.py b/ee/hogai/router/prompts.py index 2a98246f4e8c4..d72c357061c4e 100644 --- a/ee/hogai/router/prompts.py +++ b/ee/hogai/router/prompts.py @@ -11,6 +11,9 @@ Q: What is the ratio of $identify divided by page views? A: The insight type is "trends". The request asks for a custom formula, which the trends visualization supports. + +Q: How many users returned to the product after signing up? +A: The insight type is "retention". The request asks for a retention analysis. """ ROUTER_INSIGHT_DESCRIPTION_PROMPT = f""" @@ -36,6 +39,15 @@ - Drop off steps. - Steps with the highest friction and time to convert. - If product changes are improving their funnel over time. + +## `retention` + +A retention insight visualizes how many users return to the product after performing some action. They're useful for understanding user engagement and retention. + +Examples of use cases include: +- How many users come back and perform an action after their first visit. +- How many users come back to perform action X after performing action Y. +- How often users return to use a specific feature. """ ROUTER_USER_PROMPT = """ diff --git a/ee/hogai/schema_generator/nodes.py b/ee/hogai/schema_generator/nodes.py index 98a5d6ede2eae..a32ebc76262db 100644 --- a/ee/hogai/schema_generator/nodes.py +++ b/ee/hogai/schema_generator/nodes.py @@ -55,7 +55,7 @@ class SchemaGeneratorNode(AssistantNode, Generic[Q]): @property def _model(self): - return ChatOpenAI(model="gpt-4o", temperature=0.2, streaming=True).with_structured_output( + return ChatOpenAI(model="gpt-4o", temperature=0, streaming=True).with_structured_output( self.OUTPUT_SCHEMA, method="function_calling", include_raw=False, diff --git a/ee/hogai/summarizer/prompts.py b/ee/hogai/summarizer/prompts.py index 9a85ee039ed5c..fbb8a2dd39dbf 100644 --- a/ee/hogai/summarizer/prompts.py +++ b/ee/hogai/summarizer/prompts.py @@ -6,7 +6,7 @@ Acknowledge when more information would be needed. When query results are provided, note that the user can already see the chart. Use Silicon Valley lingo. Be informal but get to the point immediately, without fluff - e.g. don't start with "alright, …". -NEVER use title case, even in headings. Our style is sentence case EVERYWHERE. +NEVER use "Title Case", even in headings. Our style is "Sentence case" EVERYWHERE. You can use Markdown for emphasis. Bullets can improve clarity of action points. The product being analyzed is described as follows: diff --git a/ee/hogai/taxonomy_agent/nodes.py b/ee/hogai/taxonomy_agent/nodes.py index 92fe74ae55bcb..b034b1a730a3d 100644 --- a/ee/hogai/taxonomy_agent/nodes.py +++ b/ee/hogai/taxonomy_agent/nodes.py @@ -131,7 +131,7 @@ def router(self, state: AssistantState): @property def _model(self) -> ChatOpenAI: - return ChatOpenAI(model="gpt-4o", temperature=0.2, streaming=True) + return ChatOpenAI(model="gpt-4o", temperature=0, streaming=True) def _get_react_format_prompt(self, toolkit: TaxonomyAgentToolkit) -> str: return cast( @@ -226,7 +226,10 @@ def _construct_messages(self, state: AssistantState) -> list[BaseMessage]: conversation.append(LangchainHumanMessage(content=message.content)) elif isinstance(message, VisualizationMessage): conversation.append(LangchainAssistantMessage(content=message.plan or "")) - elif isinstance(message, AssistantMessage): + elif isinstance(message, AssistantMessage) and ( + # Filter out summarizer messages (which always follow viz), but leave clarification questions in + idx < 1 or not isinstance(filtered_messages[idx - 1], VisualizationMessage) + ): conversation.append(LangchainAssistantMessage(content=message.content)) return conversation @@ -298,6 +301,6 @@ def _run_with_toolkit( ) def router(self, state: AssistantState): - if state.plan is not None: + if state.plan: return "plan_found" return "continue" diff --git a/ee/hogai/taxonomy_agent/test/test_nodes.py b/ee/hogai/taxonomy_agent/test/test_nodes.py index cb25331664331..dfb5561881be6 100644 --- a/ee/hogai/taxonomy_agent/test/test_nodes.py +++ b/ee/hogai/taxonomy_agent/test/test_nodes.py @@ -142,25 +142,21 @@ def test_agent_reconstructs_typical_conversation(self): start_id="10", ) ) - self.assertEqual(len(history), 9) + self.assertEqual(len(history), 7) self.assertEqual(history[0].type, "human") self.assertIn("Question 1", history[0].content) self.assertEqual(history[1].type, "ai") self.assertEqual(history[1].content, "Plan 1") - self.assertEqual(history[2].type, "ai") - self.assertEqual(history[2].content, "Summary 1") - self.assertEqual(history[3].type, "human") - self.assertIn("Question 2", history[3].content) - self.assertEqual(history[4].type, "ai") - self.assertEqual(history[4].content, "Loop 1") - self.assertEqual(history[5].type, "human") - self.assertEqual(history[5].content, "Loop Answer 1") - self.assertEqual(history[6].content, "Plan 2") - self.assertEqual(history[6].type, "ai") - self.assertEqual(history[7].type, "ai") - self.assertEqual(history[7].content, "Summary 2") - self.assertEqual(history[8].type, "human") - self.assertIn("Question 3", history[8].content) + self.assertEqual(history[2].type, "human") + self.assertIn("Question 2", history[2].content) + self.assertEqual(history[3].type, "ai") + self.assertEqual(history[3].content, "Loop 1") + self.assertEqual(history[4].type, "human") + self.assertEqual(history[4].content, "Loop Answer 1") + self.assertEqual(history[5].type, "ai") + self.assertEqual(history[5].content, "Plan 2") + self.assertEqual(history[6].type, "human") + self.assertIn("Question 3", history[6].content) def test_agent_reconstructs_conversation_without_messages_after_parent(self): node = self._get_node() @@ -295,3 +291,11 @@ def test_node_handles_action_input_validation_error(self): action, observation = state_update.intermediate_steps[0] self.assertIsNotNone(observation) self.assertIn("", observation) + + def test_router(self): + node = self._get_node() + self.assertEqual(node.router(AssistantState(messages=[HumanMessage(content="Question")])), "continue") + self.assertEqual(node.router(AssistantState(messages=[HumanMessage(content="Question")], plan="")), "continue") + self.assertEqual( + node.router(AssistantState(messages=[HumanMessage(content="Question")], plan="plan")), "plan_found" + ) diff --git a/ee/hogai/test/test_assistant.py b/ee/hogai/test/test_assistant.py index d9a95660a4846..a5760f1ca7e58 100644 --- a/ee/hogai/test/test_assistant.py +++ b/ee/hogai/test/test_assistant.py @@ -77,7 +77,7 @@ def assertConversationEqual(self, output: list[tuple[str, Any]], expected_output @patch( "ee.hogai.trends.nodes.TrendsPlannerNode.run", - return_value={"intermediate_steps": [(AgentAction(tool="final_answer", tool_input="", log=""), None)]}, + return_value={"intermediate_steps": [(AgentAction(tool="final_answer", tool_input="Plan", log=""), None)]}, ) @patch( "ee.hogai.summarizer.nodes.SummarizerNode.run", return_value={"messages": [AssistantMessage(content="Foobar")]} @@ -148,7 +148,7 @@ def test_reasoning_messages_added(self, _mock_summarizer_run, _mock_funnel_plann None, ), (AgentAction(tool="handle_incorrect_response", tool_input="", log=""), None), - (AgentAction(tool="final_answer", tool_input="", log=""), None), + (AgentAction(tool="final_answer", tool_input="Plan", log=""), None), ] }, ) diff --git a/ee/hogai/utils/types.py b/ee/hogai/utils/types.py index 917edb3d4987e..b3ac57623fa56 100644 --- a/ee/hogai/utils/types.py +++ b/ee/hogai/utils/types.py @@ -50,4 +50,8 @@ class AssistantNodeName(StrEnum): FUNNEL_PLANNER_TOOLS = "funnel_planner_tools" FUNNEL_GENERATOR = "funnel_generator" FUNNEL_GENERATOR_TOOLS = "funnel_generator_tools" + RETENTION_PLANNER = "retention_planner" + RETENTION_PLANNER_TOOLS = "retention_planner_tools" + RETENTION_GENERATOR = "retention_generator" + RETENTION_GENERATOR_TOOLS = "retention_generator_tools" SUMMARIZER = "summarizer" diff --git a/frontend/__snapshots__/components-cards-insight-details--retention--dark.png b/frontend/__snapshots__/components-cards-insight-details--retention--dark.png index c942722f9306a..682895bbd12c1 100644 Binary files a/frontend/__snapshots__/components-cards-insight-details--retention--dark.png and b/frontend/__snapshots__/components-cards-insight-details--retention--dark.png differ diff --git a/frontend/__snapshots__/components-cards-insight-details--retention--light.png b/frontend/__snapshots__/components-cards-insight-details--retention--light.png index 9c0e6fad5a23e..177d14fef6732 100644 Binary files a/frontend/__snapshots__/components-cards-insight-details--retention--light.png and b/frontend/__snapshots__/components-cards-insight-details--retention--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--dark.png b/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--dark.png index 624848546ac3e..48731081d7d0b 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--dark.png and b/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--light.png b/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--light.png index 3204a87ef56f4..330fb5f078003 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--light.png and b/frontend/__snapshots__/components-errors-error-display--anonymous-error-with-stack-trace--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--chained-error-stack--dark.png b/frontend/__snapshots__/components-errors-error-display--chained-error-stack--dark.png index 87a9df0085cf6..9fc351cbabd77 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--chained-error-stack--dark.png and b/frontend/__snapshots__/components-errors-error-display--chained-error-stack--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--chained-error-stack--light.png b/frontend/__snapshots__/components-errors-error-display--chained-error-stack--light.png index 33d98017b94dd..9d370339b0ccf 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--chained-error-stack--light.png and b/frontend/__snapshots__/components-errors-error-display--chained-error-stack--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--legacy-event-properties--dark.png b/frontend/__snapshots__/components-errors-error-display--legacy-event-properties--dark.png new file mode 100644 index 0000000000000..c7d50fd42accb Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--legacy-event-properties--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--legacy-event-properties--light.png b/frontend/__snapshots__/components-errors-error-display--legacy-event-properties--light.png new file mode 100644 index 0000000000000..4d6b2e0ba2357 Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--legacy-event-properties--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--sentry-stack-trace--dark.png b/frontend/__snapshots__/components-errors-error-display--sentry-stack-trace--dark.png new file mode 100644 index 0000000000000..941cb33add551 Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--sentry-stack-trace--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--sentry-stack-trace--light.png b/frontend/__snapshots__/components-errors-error-display--sentry-stack-trace--light.png new file mode 100644 index 0000000000000..18a440b4d6a61 Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--sentry-stack-trace--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--dark.png b/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--dark.png index 457b11013108b..0df3e951f46ea 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--dark.png and b/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--light.png b/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--light.png index 3abb6c9b6327c..b9a4700958bb5 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--light.png and b/frontend/__snapshots__/components-errors-error-display--stack-trace-with-line-context--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--stacktraceless-import-module-error--dark.png b/frontend/__snapshots__/components-errors-error-display--stacktraceless-import-module-error--dark.png new file mode 100644 index 0000000000000..7b1cd735db425 Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--stacktraceless-import-module-error--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--stacktraceless-import-module-error--light.png b/frontend/__snapshots__/components-errors-error-display--stacktraceless-import-module-error--light.png new file mode 100644 index 0000000000000..110f5db83f6c9 Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--stacktraceless-import-module-error--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--stacktraceless-safari-script-error--dark.png b/frontend/__snapshots__/components-errors-error-display--stacktraceless-safari-script-error--dark.png new file mode 100644 index 0000000000000..a7305b21d51cc Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--stacktraceless-safari-script-error--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--stacktraceless-safari-script-error--light.png b/frontend/__snapshots__/components-errors-error-display--stacktraceless-safari-script-error--light.png new file mode 100644 index 0000000000000..51d99aafc66e1 Binary files /dev/null and b/frontend/__snapshots__/components-errors-error-display--stacktraceless-safari-script-error--light.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--with-cymbal-errors--dark.png b/frontend/__snapshots__/components-errors-error-display--with-cymbal-errors--dark.png index fcbc4ceb12a2a..a1442f3a3296a 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--with-cymbal-errors--dark.png and b/frontend/__snapshots__/components-errors-error-display--with-cymbal-errors--dark.png differ diff --git a/frontend/__snapshots__/components-errors-error-display--with-cymbal-errors--light.png b/frontend/__snapshots__/components-errors-error-display--with-cymbal-errors--light.png index 40ff9d12b2ba2..fe861e856a587 100644 Binary files a/frontend/__snapshots__/components-errors-error-display--with-cymbal-errors--light.png and b/frontend/__snapshots__/components-errors-error-display--with-cymbal-errors--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--error-event--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--error-event--dark.png index 2968bffa1b0af..cb9c980b9c9e1 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--error-event--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--error-event--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--error-event--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--error-event--light.png index ffb3069d1ba44..53fcf0eccbc73 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--error-event--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--error-event--light.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--dark.png b/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--dark.png index 7241c43865cef..4387c307782f5 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--dark.png and b/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--dark.png differ diff --git a/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--light.png b/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--light.png index 3573a7ff1789f..35a00d4fda7bb 100644 Binary files a/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--light.png and b/frontend/__snapshots__/components-playerinspector-itemevent--sentry-error-event--light.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png index 8293c0f34b9c1..f7c2f4fcb8dc7 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png and b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--dark.png differ diff --git a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png index cc11baf701551..0944a1be49bdb 100644 Binary files a/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png and b/frontend/__snapshots__/filters-propertyfilters--comparing-property-filters--light.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-button--loading--dark.png b/frontend/__snapshots__/lemon-ui-lemon-button--loading--dark.png index f758af103ab69..c2a6edaac8b41 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-button--loading--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-button--loading--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-button--pseudo-states--dark.png b/frontend/__snapshots__/lemon-ui-lemon-button--pseudo-states--dark.png index 0494072b9e653..a024f5129a000 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-button--pseudo-states--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-button--pseudo-states--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-button--types-and-statuses--dark.png b/frontend/__snapshots__/lemon-ui-lemon-button--types-and-statuses--dark.png index 54a16eba7b82d..95f2e9cdaf562 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-button--types-and-statuses--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-button--types-and-statuses--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-button--with-side-action--dark.png b/frontend/__snapshots__/lemon-ui-lemon-button--with-side-action--dark.png index 5e2cb5cb08079..f90293a6afbcc 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-button--with-side-action--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-button--with-side-action--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-dialog--customised--dark.png b/frontend/__snapshots__/lemon-ui-lemon-dialog--customised--dark.png index 299dfb968ba0f..303db6fb24e5a 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-dialog--customised--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-dialog--customised--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png index f5ed8a89ac925..551e1c8994d24 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png index b2493c895f8bd..dd23460496e5b 100644 Binary files a/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png and b/frontend/__snapshots__/posthog-3000-navigation--navigation-3000--light.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png index 758c48c0daa81..89067e96e4633 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png and b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png index c05ba32634144..d2aae5c85e441 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png and b/frontend/__snapshots__/posthog-3000-sidebar--dashboards--light.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png index 8c556eeb82689..cb766ea796805 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png and b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--dark.png differ diff --git a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png index 6600ad600011f..3a58333c47660 100644 Binary files a/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png and b/frontend/__snapshots__/posthog-3000-sidebar--feature-flags--light.png differ diff --git a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png index e05ec0ee257d3..0bcf261597424 100644 Binary files a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png and b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png differ diff --git a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png index 4d5f9882073b5..e6bd6be1f1f4b 100644 Binary files a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png and b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png differ diff --git a/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png b/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png index bfa0bb8c5d56d..28b9e792e056a 100644 Binary files a/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png and b/frontend/__snapshots__/replay-player-success--recent-recordings--dark.png differ diff --git a/frontend/__snapshots__/replay-player-success--recent-recordings--light.png b/frontend/__snapshots__/replay-player-success--recent-recordings--light.png index d3b511f07c042..68c21c930eb60 100644 Binary files a/frontend/__snapshots__/replay-player-success--recent-recordings--light.png and b/frontend/__snapshots__/replay-player-success--recent-recordings--light.png differ diff --git a/frontend/__snapshots__/replay-player-success--second-recording-in-list--dark.png b/frontend/__snapshots__/replay-player-success--second-recording-in-list--dark.png index deadbaa964285..2ae97386ca7bc 100644 Binary files a/frontend/__snapshots__/replay-player-success--second-recording-in-list--dark.png and b/frontend/__snapshots__/replay-player-success--second-recording-in-list--dark.png differ diff --git a/frontend/__snapshots__/replay-player-success--second-recording-in-list--light.png b/frontend/__snapshots__/replay-player-success--second-recording-in-list--light.png index f25c112bb904b..82aa898d88ed9 100644 Binary files a/frontend/__snapshots__/replay-player-success--second-recording-in-list--light.png and b/frontend/__snapshots__/replay-player-success--second-recording-in-list--light.png differ diff --git a/frontend/__snapshots__/scenes-app-data-management-actions--action--light.png b/frontend/__snapshots__/scenes-app-data-management-actions--action--light.png index 7354d1b59ebd5..efc8f44a419a8 100644 Binary files a/frontend/__snapshots__/scenes-app-data-management-actions--action--light.png and b/frontend/__snapshots__/scenes-app-data-management-actions--action--light.png differ diff --git a/frontend/__snapshots__/scenes-app-errortracking--group-page--dark.png b/frontend/__snapshots__/scenes-app-errortracking--group-page--dark.png index df722bd0f2d26..1449513daf493 100644 Binary files a/frontend/__snapshots__/scenes-app-errortracking--group-page--dark.png and b/frontend/__snapshots__/scenes-app-errortracking--group-page--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-errortracking--group-page--light.png b/frontend/__snapshots__/scenes-app-errortracking--group-page--light.png index 477239b6b7587..75a21d7031448 100644 Binary files a/frontend/__snapshots__/scenes-app-errortracking--group-page--light.png and b/frontend/__snapshots__/scenes-app-errortracking--group-page--light.png differ diff --git a/frontend/__snapshots__/scenes-app-errortracking--list-page--dark.png b/frontend/__snapshots__/scenes-app-errortracking--list-page--dark.png index 7fbea863d2331..1d90e940a909f 100644 Binary files a/frontend/__snapshots__/scenes-app-errortracking--list-page--dark.png and b/frontend/__snapshots__/scenes-app-errortracking--list-page--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-errortracking--list-page--light.png b/frontend/__snapshots__/scenes-app-errortracking--list-page--light.png index fa92d131cd17f..7cc5251414e4f 100644 Binary files a/frontend/__snapshots__/scenes-app-errortracking--list-page--light.png and b/frontend/__snapshots__/scenes-app-errortracking--list-page--light.png differ diff --git a/frontend/__snapshots__/scenes-app-insights--user-paths-edit--dark.png b/frontend/__snapshots__/scenes-app-insights--user-paths-edit--dark.png index 7b95db9d76164..9a501f6eff22f 100644 Binary files a/frontend/__snapshots__/scenes-app-insights--user-paths-edit--dark.png and b/frontend/__snapshots__/scenes-app-insights--user-paths-edit--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--dark.png b/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--dark.png index 160760e0f576d..41932a96ce02a 100644 Binary files a/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--dark.png and b/frontend/__snapshots__/scenes-app-notebooks--notebooks-list--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--new-survey-appearance-section--dark.png b/frontend/__snapshots__/scenes-app-surveys--new-survey-appearance-section--dark.png index 3a6374e4dbfb9..357abab277868 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--new-survey-appearance-section--dark.png and b/frontend/__snapshots__/scenes-app-surveys--new-survey-appearance-section--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--new-survey-appearance-section--light.png b/frontend/__snapshots__/scenes-app-surveys--new-survey-appearance-section--light.png index af17be468ba7f..6b37bd877a302 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--new-survey-appearance-section--light.png and b/frontend/__snapshots__/scenes-app-surveys--new-survey-appearance-section--light.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--new-survey-presentation-section--dark.png b/frontend/__snapshots__/scenes-app-surveys--new-survey-presentation-section--dark.png index 4d80397546bf3..6d637e32f6fbd 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--new-survey-presentation-section--dark.png and b/frontend/__snapshots__/scenes-app-surveys--new-survey-presentation-section--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--new-survey-presentation-section--light.png b/frontend/__snapshots__/scenes-app-surveys--new-survey-presentation-section--light.png index bbe76baa0005a..d72a1de43b300 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--new-survey-presentation-section--light.png and b/frontend/__snapshots__/scenes-app-surveys--new-survey-presentation-section--light.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--light.png b/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--light.png index 58aebc125c8a3..69a4ef920c539 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--light.png and b/frontend/__snapshots__/scenes-app-surveys--new-survey-targeting-section--light.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--survey-templates--dark.png b/frontend/__snapshots__/scenes-app-surveys--survey-templates--dark.png index 78547df6e518c..1696309f41655 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--survey-templates--dark.png and b/frontend/__snapshots__/scenes-app-surveys--survey-templates--dark.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--survey-templates--light.png b/frontend/__snapshots__/scenes-app-surveys--survey-templates--light.png index 810be7de30307..0f24d4f57b374 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--survey-templates--light.png and b/frontend/__snapshots__/scenes-app-surveys--survey-templates--light.png differ diff --git a/frontend/__snapshots__/scenes-other-products--products--dark.png b/frontend/__snapshots__/scenes-other-products--products--dark.png index caa1dcb27f4f8..c35537648dc46 100644 Binary files a/frontend/__snapshots__/scenes-other-products--products--dark.png and b/frontend/__snapshots__/scenes-other-products--products--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-products--products--light.png b/frontend/__snapshots__/scenes-other-products--products--light.png index 0715bb4784947..c7d70626101a0 100644 Binary files a/frontend/__snapshots__/scenes-other-products--products--light.png and b/frontend/__snapshots__/scenes-other-products--products--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png index 8de0050efb48e..11c06e4300d43 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-organization--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png b/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png index d1592c6883fe3..412b0532020e4 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-organization--light.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-user--dark.png b/frontend/__snapshots__/scenes-other-settings--settings-user--dark.png index f0f85a24787f5..c8beaa5c9514a 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-user--dark.png and b/frontend/__snapshots__/scenes-other-settings--settings-user--dark.png differ diff --git a/frontend/__snapshots__/scenes-other-settings--settings-user--light.png b/frontend/__snapshots__/scenes-other-settings--settings-user--light.png index 4cd020a3df66e..b35b1b886ed5a 100644 Binary files a/frontend/__snapshots__/scenes-other-settings--settings-user--light.png and b/frontend/__snapshots__/scenes-other-settings--settings-user--light.png differ diff --git a/frontend/src/layout/navigation-3000/Navigation.scss b/frontend/src/layout/navigation-3000/Navigation.scss index 42bb779a54d82..5a109ac4ed486 100644 --- a/frontend/src/layout/navigation-3000/Navigation.scss +++ b/frontend/src/layout/navigation-3000/Navigation.scss @@ -349,7 +349,7 @@ .Accordion { --accordion-arrow-size: 1rem; - --accordion-row-height: 1.75rem; + --accordion-row-height: 32px; --accordion-inset-expandable: 1.25rem; --accordion-header-background: var(--accent-3000); --accordion-inset: 0rem; @@ -419,6 +419,64 @@ } } +// New scoped accordion styles +.SidebarListItemAccordion { + --accordion-arrow-size: 1rem; + --accordion-inset-expandable: 1.25rem; + --accordion-header-background: var(--accent-3000); + --accordion-inset: 0rem; + + display: flex; + flex-basis: 0; + flex-direction: column; + flex-shrink: 0; + height: 100%; + + [theme='dark'] & { + --accordion-header-background: var(--bg-3000); + } + + &[role='region'] { + // This means: if accordion is expandable + --accordion-inset: var(--accordion-inset-expandable); + } + + &:not([aria-expanded='false']) { + flex-grow: 1; + + &:not(:last-child) { + border-bottom-width: 1px; + } + } +} + +.SidebarListItemAccordion__header { + z-index: 1; + display: flex; + align-items: center; + height: 100%; + padding: 0 var(--sidebar-horizontal-padding) 0 calc(var(--sidebar-horizontal-padding) + 1rem * var(--depth, 0)); + cursor: pointer; + user-select: none; + background: var(--accordion-header-background); + border-bottom-width: 1px; + + &:hover { + background: var(--border-3000); + } + + > .LemonIcon { + flex-shrink: 0; + margin-right: calc(var(--accordion-inset-expandable) - var(--accordion-arrow-size)); + font-size: var(--accordion-arrow-size); + transition: 50ms ease transform; + + .SidebarListItemAccordion[aria-expanded='true'] & { + transform: rotate(90deg); + } + } +} + .SidebarListItem { --sidebar-list-item-status-color: var(--muted); --sidebar-list-item-fold-size: 0.5rem; @@ -562,7 +620,8 @@ gap: 0.25rem; row-gap: 1px; align-items: center; - padding: 0 var(--sidebar-horizontal-padding) 0 var(--sidebar-list-item-inset); + padding: 0 var(--sidebar-horizontal-padding) 0 + calc(0.25rem + var(--sidebar-horizontal-padding) + 1rem * var(--depth, 0)); font-size: 1.125rem; // Make icons bigger color: inherit !important; // Disable link color cursor: pointer; diff --git a/frontend/src/layout/navigation-3000/components/Sidebar.tsx b/frontend/src/layout/navigation-3000/components/Sidebar.tsx index 96497e047ff25..ff36169c45f42 100644 --- a/frontend/src/layout/navigation-3000/components/Sidebar.tsx +++ b/frontend/src/layout/navigation-3000/components/Sidebar.tsx @@ -1,17 +1,15 @@ -import { IconSearch, IconX } from '@posthog/icons' +import { IconX } from '@posthog/icons' import { LemonButton, LemonInput } from '@posthog/lemon-ui' import clsx from 'clsx' import { LogicWrapper, useActions, useValues } from 'kea' import { Spinner } from 'lib/lemon-ui/Spinner' -import { capitalizeFirstLetter } from 'lib/utils' import React, { useRef, useState } from 'react' import { useDebouncedCallback } from 'use-debounce' import { navigation3000Logic } from '../navigationLogic' import { SidebarLogic, SidebarNavbarItem } from '../types' import { KeyboardShortcut } from './KeyboardShortcut' -import { NewItemButton } from './NewItemButton' -import { pluralizeCategory, SidebarAccordion } from './SidebarAccordion' +import { SidebarAccordion } from './SidebarAccordion' import { SidebarList } from './SidebarList' /** A small delay that prevents us from making a search request on each key press. */ @@ -42,12 +40,6 @@ export function Sidebar({ navbarItem, sidebarOverlay, sidebarOverlayProps }: Sid const { beginResize } = useActions(navigation3000Logic({ inputElement: inputElementRef.current })) const { contents } = useValues(navbarItem.logic) - const onlyCategoryTitle = contents.length === 1 ? capitalizeFirstLetter(pluralizeCategory(contents[0].noun)) : null - const title = - !onlyCategoryTitle || onlyCategoryTitle.toLowerCase() === navbarItem.label.toLowerCase() - ? navbarItem.label - : `${navbarItem.label} — ${onlyCategoryTitle}` - return (
-
-

{title}

- -
{navbarItem?.logic && isSearchShown && ( )} @@ -98,34 +86,6 @@ export function Sidebar({ navbarItem, sidebarOverlay, sidebarOverlayProps }: Sid ) } -function SidebarActions({ activeSidebarLogic }: { activeSidebarLogic: LogicWrapper }): JSX.Element { - const { isSearchShown } = useValues(navigation3000Logic) - const { setIsSearchShown } = useActions(navigation3000Logic) - const { contents } = useValues(activeSidebarLogic) - - return ( - <> - {contents.length === 1 && ( - // If there's only one category, show a top level "New" button - - )} - } - size="small" - noPadding - onClick={() => setIsSearchShown(!isSearchShown)} - active={isSearchShown} - tooltip={ - <> - Find - - } - tooltipPlacement="bottom" - /> - - ) -} - function SidebarSearchBar({ activeSidebarLogic, inputElementRef, @@ -134,7 +94,7 @@ function SidebarSearchBar({ inputElementRef: React.RefObject }): JSX.Element { const { searchTerm } = useValues(navigation3000Logic) - const { setIsSearchShown, setSearchTerm, focusNextItem, setLastFocusedItemIndex } = useActions(navigation3000Logic) + const { setSearchTerm, focusNextItem, setLastFocusedItemIndex } = useActions(navigation3000Logic) const { contents, debounceSearch } = useValues(activeSidebarLogic) const [localSearchTerm, setLocalSearchTerm] = useState(searchTerm) @@ -146,8 +106,9 @@ function SidebarSearchBar({ const isLoading = contents.some((item) => item.loading) return ( -
+
{ - if (e.key === 'Escape') { - setIsSearchShown(false) - e.preventDefault() - } else if (e.key === 'ArrowDown') { + if (e.key === 'ArrowDown') { focusNextItem() e.preventDefault() } @@ -176,7 +134,6 @@ function SidebarSearchBar({ setLastFocusedItemIndex(-1) }} autoFocus - suffix={} />
) diff --git a/frontend/src/layout/navigation-3000/components/SidebarList.tsx b/frontend/src/layout/navigation-3000/components/SidebarList.tsx index 65cd05d65c4d4..0ce2a598de34b 100644 --- a/frontend/src/layout/navigation-3000/components/SidebarList.tsx +++ b/frontend/src/layout/navigation-3000/components/SidebarList.tsx @@ -5,8 +5,10 @@ import { captureException } from '@sentry/react' import clsx from 'clsx' import { useActions, useAsyncActions, useValues } from 'kea' import { isDayjs } from 'lib/dayjs' +import { IconChevronRight } from 'lib/lemon-ui/icons/icons' import { LemonMenu } from 'lib/lemon-ui/LemonMenu' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' +import { capitalizeFirstLetter } from 'lib/utils' import React, { useEffect, useMemo, useRef, useState } from 'react' import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer' import { InfiniteLoader } from 'react-virtualized/dist/es/InfiniteLoader' @@ -18,23 +20,90 @@ import { ButtonListItem, ExtendedListItem, ExtraListItemContext, + ListItemAccordion, SidebarCategory, + SidebarCategoryBase, TentativeListItem, } from '../types' import { KeyboardShortcut } from './KeyboardShortcut' +import { pluralizeCategory } from './SidebarAccordion' -export function SidebarList({ category }: { category: SidebarCategory }): JSX.Element { - const { normalizedActiveListItemKey, sidebarWidth, newItemInlineCategory, savingNewItem } = - useValues(navigation3000Logic) +const isListItemAccordion = ( + category: BasicListItem | ExtendedListItem | TentativeListItem | ButtonListItem | ListItemAccordion +): category is ListItemAccordion => { + return 'items' in category +} + +const isSidebarCategory = (category: SidebarCategory | SidebarCategoryBase): category is SidebarCategory => { + return 'loading' in category +} + +export function SidebarList({ category }: { category: SidebarCategory | ListItemAccordion }): JSX.Element { + const listRef = useRef(null) + const { + isListItemVisible, + listItemAccordionCollapseMapping, + normalizedActiveListItemKey, + sidebarWidth, + newItemInlineCategory, + savingNewItem, + } = useValues(navigation3000Logic) const { cancelNewItem } = useActions(navigation3000Logic) const { saveNewItem } = useAsyncActions(navigation3000Logic) const emptyStateSkeletonCount = useMemo(() => 4 + Math.floor(Math.random() * 4), []) - const { items, remote } = category + const { items: _items } = category + + const listItems = useMemo(() => { + const allItems: (BasicListItem | ExtendedListItem | ListItemAccordion)[] = [] + + const flatten = ( + items: BasicListItem[] | ExtendedListItem[] | ListItemAccordion[], + depth: number = 1, + parentKey: string | number | string[] | null = null + ): void => { + items.forEach((item) => { + allItems.push({ + ...item, + depth: depth, + key: parentKey + ? [ + Array.isArray(parentKey) ? parentKey.join(ITEM_KEY_PART_SEPARATOR) : parentKey, + item.key, + ].join(ITEM_KEY_PART_SEPARATOR) + : item.key.toString(), + }) + if (isListItemAccordion(item)) { + flatten( + item.items, + depth + 1, + parentKey ? `${parentKey}${ITEM_KEY_PART_SEPARATOR}${item.key}` : item.key + ) + } + }) + } + + flatten(_items, 1, category.key) + + return allItems.filter((item) => + isListItemVisible(Array.isArray(item.key) ? item.key.join(ITEM_KEY_PART_SEPARATOR) : item.key.toString()) + ) + }, [_items, isListItemVisible]) + + useEffect(() => { + if (listRef.current) { + listRef.current.recomputeRowHeights() + listRef.current.forceUpdateGrid() + } + }, [listItemAccordionCollapseMapping, listItems]) + + const remote = isSidebarCategory(category) ? category.remote : undefined + const loading = isSidebarCategory(category) ? category.loading : false + const validateName = isSidebarCategory(category) ? category.validateName : undefined const addingNewItem = newItemInlineCategory === category.key - const firstItem = items.find(Boolean) + const firstItem = listItems.find(Boolean) const usingExtendedItemFormat = !!firstItem && 'summary' in firstItem const listProps = { @@ -55,13 +124,13 @@ export function SidebarList({ category }: { category: SidebarCategory }): JSX.El loading: savingNewItem, } as TentativeListItem } - validateName={category.validateName} + validateName={validateName} style={style} /> ) } - const item = items[index] + const item = listItems[index] if (!item) { return } @@ -78,27 +147,17 @@ export function SidebarList({ category }: { category: SidebarCategory }): JSX.El } else { active = normalizedItemKey === normalizedActiveListItemKey } - - return ( - - ) + return }, overscanRowCount: 20, tabIndex: null, } as ListProps - return ( // The div is for AutoSizer to work -
+
{({ height }) => - category.loading && category.items.length === 0 ? ( + 'loading' in category && category.items.length === 0 ? ( Array(emptyStateSkeletonCount) .fill(null) .map((_, index) => ) @@ -120,7 +179,12 @@ export function SidebarList({ category }: { category: SidebarCategory }): JSX.El )} ) : ( - + ) } @@ -129,7 +193,7 @@ export function SidebarList({ category }: { category: SidebarCategory }): JSX.El } interface SidebarListItemProps { - item: BasicListItem | ExtendedListItem | TentativeListItem | ButtonListItem + item: BasicListItem | ExtendedListItem | TentativeListItem | ButtonListItem | ListItemAccordion validateName?: SidebarCategory['validateName'] active?: boolean style: React.CSSProperties @@ -155,7 +219,7 @@ function SidebarListItem({ item, validateName, active, style }: SidebarListItemP const isSaving = isItemTentative(item) ? item.loading : isSavingName const menuItems = useMemo(() => { - if (isItemTentative(item)) { + if (isItemTentative(item) || isListItemAccordion(item)) { return undefined } if (item.onRename) { @@ -191,7 +255,7 @@ function SidebarListItem({ item, validateName, active, style }: SidebarListItemP } await item.onSave(name) } - : item.onRename + : !isListItemAccordion(item) && item.onRename ? async (newName: string): Promise => { if (!newName || newName === item.name) { return cancel() // No change to be saved @@ -214,7 +278,7 @@ function SidebarListItem({ item, validateName, active, style }: SidebarListItemP useEffect(() => { // Add double-click handler for renaming - if (!isItemTentative(item) && save && newName === null) { + if (!isItemTentative(item) && !isListItemAccordion(item) && save && newName === null) { const onDoubleClick = (): void => { setNewName(item.name) } @@ -229,9 +293,16 @@ function SidebarListItem({ item, validateName, active, style }: SidebarListItemP }) // Intentionally run on every render so that ref value changes are picked up let content: JSX.Element - if (isItemClickable(item)) { + if (isListItemAccordion(item)) { + content = + } else if (isItemClickable(item)) { content = ( -
  • +
  • {item.icon &&
    {item.icon}
    }
    {item.name}
  • @@ -364,7 +435,7 @@ function SidebarListItem({ item, validateName, active, style }: SidebarListItemP return (
  • ) } + +function SidebarListItemAccordion({ category }: { category: ListItemAccordion }): JSX.Element { + const { listItemAccordionCollapseMapping } = useValues(navigation3000Logic) + const { toggleListItemAccordion } = useActions(navigation3000Logic) + + const { key, items } = category + + const isEmpty = items.length === 0 + const keyString = Array.isArray(key) ? key.join(ITEM_KEY_PART_SEPARATOR) : key.toString() + const isExpanded = !(keyString in listItemAccordionCollapseMapping) || !listItemAccordionCollapseMapping[keyString] + + return ( +
  • +
    0 ? () => toggleListItemAccordion(keyString) : undefined} + > + +

    + {capitalizeFirstLetter(pluralizeCategory(category.noun))} + {isEmpty && ( + <> + {' '} + (empty) + + )} +

    +
    +
  • + ) +} diff --git a/frontend/src/layout/navigation-3000/navigationLogic.tsx b/frontend/src/layout/navigation-3000/navigationLogic.tsx index 6616814c0d8df..51a822fc3e324 100644 --- a/frontend/src/layout/navigation-3000/navigationLogic.tsx +++ b/frontend/src/layout/navigation-3000/navigationLogic.tsx @@ -39,7 +39,7 @@ import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' import { dashboardsModel } from '~/models/dashboardsModel' -import { ProductKey } from '~/types' +import { ProductKey, ReplayTabs } from '~/types' import { navigationLogic } from '../navigation/navigationLogic' import type { navigation3000LogicType } from './navigationLogicType' @@ -101,6 +101,7 @@ export const navigation3000Logic = kea([ focusNextItem: true, focusPreviousItem: true, toggleAccordion: (key: string) => ({ key }), + toggleListItemAccordion: (key: string) => ({ key }), }), reducers({ isSidebarShown: [ @@ -167,7 +168,7 @@ export const navigation3000Logic = kea([ }, ], isSearchShown: [ - false, + true, { setIsSearchShown: (_, { isSearchShown }) => isSearchShown, }, @@ -198,6 +199,18 @@ export const navigation3000Logic = kea([ }), }, ], + listItemAccordionCollapseMapping: [ + {} as Record, + { + persist: true, + }, + { + toggleListItemAccordion: (state, { key }) => ({ + ...state, + [key]: !state[key], + }), + }, + ], newItemInlineCategory: [ null as string | null, { @@ -346,6 +359,10 @@ export const navigation3000Logic = kea([ ): NavbarItem[][] => { const isUsingSidebar = featureFlags[FEATURE_FLAGS.POSTHOG_3000_NAV] const hasOnboardedFeatureFlags = currentTeam?.has_completed_onboarding_for?.[ProductKey.FEATURE_FLAGS] + + const replayLandingPageFlag = featureFlags[FEATURE_FLAGS.REPLAY_LANDING_PAGE] + const replayLandingPage: ReplayTabs = + replayLandingPageFlag === 'templates' ? ReplayTabs.Templates : ReplayTabs.Home const sectionOne: NavbarItem[] = hasOnboardedAnyProduct ? [ { @@ -360,30 +377,33 @@ export const navigation3000Logic = kea([ icon: , logic: isUsingSidebar ? dashboardsSidebarLogic : undefined, to: isUsingSidebar ? undefined : urls.dashboards(), - sideAction: { - identifier: 'pinned-dashboards-dropdown', - dropdown: { - overlay: ( - ({ - label: dashboard.name, - to: urls.dashboard(dashboard.id), - })), - footer: dashboardsLoading && ( -
    - Loading… -
    - ), - }, - ]} - /> - ), - placement: 'bottom-end', - }, - }, + sideAction: + pinnedDashboards.length > 0 + ? { + identifier: 'pinned-dashboards-dropdown', + dropdown: { + overlay: ( + ({ + label: dashboard.name, + to: urls.dashboard(dashboard.id), + })), + footer: dashboardsLoading && ( +
    + Loading… +
    + ), + }, + ]} + /> + ), + placement: 'bottom-end', + }, + } + : undefined, }, { identifier: Scene.Notebooks, @@ -462,12 +482,34 @@ export const navigation3000Logic = kea([ label: 'Web analytics', icon: , to: isUsingSidebar ? undefined : urls.webAnalytics(), + sideAction: featureFlags[FEATURE_FLAGS.CORE_WEB_VITALS] + ? { + identifier: 'web-analytics-dropdown', + dropdown: { + overlay: ( + + ), + placement: 'bottom-end', + }, + } + : undefined, }, { identifier: Scene.Replay, label: 'Session replay', icon: , - to: urls.replay(), + to: urls.replay(replayLandingPage), }, featureFlags[FEATURE_FLAGS.ERROR_TRACKING] ? { @@ -546,7 +588,7 @@ export const navigation3000Logic = kea([ tag: 'alpha' as const, } : null, - ].filter(isNotNil), + ].filter(isNotNil) as NavbarItem[], ] }, ], @@ -582,8 +624,17 @@ export const navigation3000Logic = kea([ ], sidebarContentsFlattened: [ (s) => [(state) => s.activeNavbarItem(state)?.logic?.findMounted()?.selectors.contents(state) || null], - (sidebarContents): BasicListItem[] | ExtendedListItem[] => - sidebarContents ? sidebarContents.flatMap((item) => ('items' in item ? item.items : item)) : [], + (sidebarContents): BasicListItem[] | ExtendedListItem[] => { + const flattenItems = (items: any[]): (BasicListItem | ExtendedListItem)[] => { + return items.flatMap((item) => { + if ('items' in item) { + return flattenItems(item.items) + } + return item + }) + } + return sidebarContents ? flattenItems(sidebarContents) : [] + }, ], normalizedActiveListItemKey: [ (s) => [ @@ -597,6 +648,23 @@ export const navigation3000Logic = kea([ : activeListItemKey : null, ], + isListItemVisible: [ + (s) => [s.listItemAccordionCollapseMapping], + (listItemAccordionCollapseMapping) => { + return (key: string): boolean => { + // Split the key into parts to check each parent's visibility + const parts = key.split(ITEM_KEY_PART_SEPARATOR) + // Check if any parent is collapsed + for (let i = 1; i < parts.length; i++) { + const parentKey = parts.slice(0, i).join(ITEM_KEY_PART_SEPARATOR) + if (listItemAccordionCollapseMapping[parentKey]) { + return false + } + } + return true + } + }, + ], activeNavbarItemId: [ (s) => [s.activeNavbarItemIdRaw, featureFlagLogic.selectors.featureFlags], (activeNavbarItemIdRaw, featureFlags): string | null => { diff --git a/frontend/src/layout/navigation-3000/sidebars/featureFlags.tsx b/frontend/src/layout/navigation-3000/sidebars/featureFlags.tsx index 66d480491172a..4cd34453d73a6 100644 --- a/frontend/src/layout/navigation-3000/sidebars/featureFlags.tsx +++ b/frontend/src/layout/navigation-3000/sidebars/featureFlags.tsx @@ -2,6 +2,7 @@ import Fuse from 'fuse.js' import { connect, kea, path, selectors } from 'kea' import { subscriptions } from 'kea-subscriptions' import { dayjs } from 'lib/dayjs' +import { lemonToast } from 'lib/lemon-ui/LemonToast' import { copyToClipboard } from 'lib/utils/copyToClipboard' import { deleteWithUndo } from 'lib/utils/deleteWithUndo' import { featureFlagLogic } from 'scenes/feature-flags/featureFlagLogic' @@ -152,6 +153,8 @@ export const featureFlagsSidebarLogic = kea([ callback: () => { actions.loadFeatureFlags() }, + }).catch((e: any) => { + lemonToast.error(`Failed to delete feature flag: ${e.detail}`) }) }, disabledReason: !featureFlag.can_edit diff --git a/frontend/src/layout/navigation-3000/types.ts b/frontend/src/layout/navigation-3000/types.ts index a941e7dfaad74..ca36680663afa 100644 --- a/frontend/src/layout/navigation-3000/types.ts +++ b/frontend/src/layout/navigation-3000/types.ts @@ -45,12 +45,22 @@ export type NavbarItem = NavbarItemBase | SceneNavbarItem | SidebarNavbarItem export type ListItemSaveHandler = (newName: string) => Promise -/** A category of items. This is either displayed directly for sidebars with only one category, or as an accordion. */ -export interface SidebarCategory { +export interface SidebarCategoryBase { key: string /** Category content noun. If the plural form is non-standard, provide a tuple with both forms. @example 'person' */ noun: string | [singular: string, plural: string] - items: BasicListItem[] | ExtendedListItem[] + items: BasicListItem[] | ExtendedListItem[] | ListItemAccordion[] + + /** Ref to the corresponding element. This is injected automatically when the element is rendered. */ + ref?: React.MutableRefObject +} + +export interface ListItemAccordion extends SidebarCategoryBase { + depth?: number +} + +/** A category of items. This is either displayed directly for sidebars with only one category, or as an accordion. */ +export interface SidebarCategory extends SidebarCategoryBase { loading: boolean /** * Items can be created in three ways: @@ -126,6 +136,8 @@ export interface BasicListItem { onRename?: ListItemSaveHandler /** Ref to the corresponding element. This is injected automatically when the element is rendered. */ ref?: React.MutableRefObject + /** If this item is inside an accordion, this is the depth of the accordion. */ + depth?: number } export type ExtraListItemContext = string | Dayjs diff --git a/frontend/src/layout/navigation/OrganizationSwitcher.tsx b/frontend/src/layout/navigation/OrganizationSwitcher.tsx index 8824bf4f4a2e8..667f82a305a2f 100644 --- a/frontend/src/layout/navigation/OrganizationSwitcher.tsx +++ b/frontend/src/layout/navigation/OrganizationSwitcher.tsx @@ -1,4 +1,4 @@ -import { IconPlus } from '@posthog/icons' +import { IconPlusSmall } from '@posthog/icons' import { useActions, useValues } from 'kea' import { upgradeModalLogic } from 'lib/components/UpgradeModal/upgradeModalLogic' import { LemonButton } from 'lib/lemon-ui/LemonButton' @@ -57,7 +57,7 @@ export function NewOrganizationButton(): JSX.Element { return ( } + icon={} onClick={() => guardAvailableFeature( AvailableFeature.ORGANIZATIONS_PROJECTS, diff --git a/frontend/src/lib/api.mock.ts b/frontend/src/lib/api.mock.ts index 709beee303f6b..75ee08ffba75c 100644 --- a/frontend/src/lib/api.mock.ts +++ b/frontend/src/lib/api.mock.ts @@ -136,7 +136,11 @@ export const MOCK_DEFAULT_USER: UserType = { distinct_id: MOCK_DEFAULT_BASIC_USER.uuid, first_name: MOCK_DEFAULT_BASIC_USER.first_name, email: MOCK_DEFAULT_BASIC_USER.email, - notification_settings: { plugin_disabled: false }, + notification_settings: { + plugin_disabled: false, + project_weekly_digest_disabled: {}, + all_weekly_digest_disabled: false, + }, anonymize_data: false, toolbar_mode: 'toolbar', has_password: true, diff --git a/frontend/src/lib/components/Cards/InsightCard/InsightDetails.tsx b/frontend/src/lib/components/Cards/InsightCard/InsightDetails.tsx index a9b08bf114af8..63927e0628fec 100644 --- a/frontend/src/lib/components/Cards/InsightCard/InsightDetails.tsx +++ b/frontend/src/lib/components/Cards/InsightCard/InsightDetails.tsx @@ -24,10 +24,14 @@ import { urls } from 'scenes/urls' import { cohortsModel } from '~/models/cohortsModel' import { propertyDefinitionsModel } from '~/models/propertyDefinitionsModel' import { + AnyEntityNode, FunnelsQuery, InsightQueryNode, LifecycleQuery, + Node, + NodeKind, PathsQuery, + RetentionQuery, StickinessQuery, TrendsQuery, } from '~/queries/schema' @@ -40,6 +44,7 @@ import { isInsightVizNode, isLifecycleQuery, isPathsQuery, + isRetentionQuery, isTrendsQuery, isValidBreakdown, } from '~/queries/utils' @@ -142,6 +147,29 @@ function CompactPropertyFiltersDisplay({ ) } +function EntityDisplay({ entity }: { entity: AnyEntityNode }): JSX.Element { + return ( + <> + {entity.custom_name && "{entity.custom_name}"} + {isActionsNode(entity) ? ( + + {entity.name} + + ) : isEventsNode(entity) ? ( + + + + ) : ( + {entity.kind /* TODO: Support DataWarehouseNode */} + )} + + ) +} + function SeriesDisplay({ query, seriesIndex, @@ -182,22 +210,7 @@ function SeriesDisplay({ > {isFunnelsQuery(query) ? 'Performed' : 'Showing'} - {series.custom_name && "{series.custom_name}"} - {isActionsNode(series) ? ( - - {series.name} - - ) : isEventsNode(series) ? ( - - - - ) : ( - {series.kind /* TODO: Support DataWarehouseNode */} - )} + {!isFunnelsQuery(query) && ( counted by{' '} @@ -249,6 +262,52 @@ function PathsSummary({ query }: { query: PathsQuery }): JSX.Element { ) } +function RetentionSummary({ query }: { query: RetentionQuery }): JSX.Element { + const { aggregationLabel } = useValues(mathsLogic) + + return ( + <> + {query.aggregation_group_type_index != null + ? `${capitalizeFirstLetter(aggregationLabel(query.aggregation_group_type_index).plural)} which` + : 'Users who'} + {' performed'} + + + {query.retentionFilter.retentionType === 'retention_recurring' ? 'recurringly' : 'for the first time'} + {' '} + in the preceding{' '} + + {(query.retentionFilter.totalIntervals || 11) - 1}{' '} + {query.retentionFilter.period?.toLocaleLowerCase() ?? 'day'}s + +
    + and came back to perform + + in any of the next periods + + ) +} + export function SeriesSummary({ query, heading }: { query: InsightQueryNode; heading?: JSX.Element }): JSX.Element { return (
    @@ -267,6 +326,8 @@ export function SeriesSummary({ query, heading }: { query: InsightQueryNode; hea
    {isPathsQuery(query) ? ( + ) : isRetentionQuery(query) ? ( + ) : isInsightQueryWithSeries(query) ? ( <> {query.series.map((_entity, index) => ( @@ -277,8 +338,7 @@ export function SeriesSummary({ query, heading }: { query: InsightQueryNode; hea ))} ) : ( - /* TODO: Add support for Retention to InsightDetails */ - Unavailable for this insight type. + Query summary is not available for {(query as Node).kind} yet )}
    diff --git a/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx b/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx index 5f6a526a19dd4..30c2cdef8fe95 100644 --- a/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx +++ b/frontend/src/lib/components/Cards/InsightCard/InsightMeta.tsx @@ -1,3 +1,4 @@ +import { lemonToast } from '@posthog/lemon-ui' import { useValues } from 'kea' import { CardMeta } from 'lib/components/Cards/CardMeta' import { TopHeading } from 'lib/components/Cards/InsightCard/TopHeading' @@ -258,7 +259,19 @@ export function InsightMeta({ Remove from dashboard ) : ( - void deleteWithUndo?.()} fullWidth> + { + void (async () => { + try { + await deleteWithUndo?.() + } catch (error: any) { + lemonToast.error(`Failed to delete insight meta: ${error.detail}`) + } + })() + }} + fullWidth + > Delete insight )} diff --git a/frontend/src/lib/components/CommandBar/SearchResult.tsx b/frontend/src/lib/components/CommandBar/SearchResult.tsx index 6bb04fce0693c..1aaaae1336d37 100644 --- a/frontend/src/lib/components/CommandBar/SearchResult.tsx +++ b/frontend/src/lib/components/CommandBar/SearchResult.tsx @@ -1,7 +1,6 @@ import { LemonSkeleton } from '@posthog/lemon-ui' import clsx from 'clsx' import { useActions, useValues } from 'kea' -import { TAILWIND_BREAKPOINTS } from 'lib/constants' import { useWindowSize } from 'lib/hooks/useWindowSize' import { capitalizeFirstLetter } from 'lib/utils' import { useLayoutEffect, useRef } from 'react' @@ -28,7 +27,7 @@ export const SearchResult = ({ result, resultIndex, focused }: SearchResultProps const ref = useRef(null) - const { width } = useWindowSize() + const { isWindowLessThan } = useWindowSize() useLayoutEffect(() => { if (focused) { @@ -51,7 +50,7 @@ export const SearchResult = ({ result, resultIndex, focused }: SearchResultProps focused ? 'bg-bg-3000 border-l-primary-3000' : 'bg-bg-light' )} onClick={() => { - if (width && width <= TAILWIND_BREAKPOINTS.md) { + if (isWindowLessThan('md')) { openResult(resultIndex) } else { setActiveResultIndex(resultIndex) diff --git a/frontend/src/lib/components/Errors/ErrorDisplay.scss b/frontend/src/lib/components/Errors/ErrorDisplay.scss deleted file mode 100644 index ec8c81c7ec1a3..0000000000000 --- a/frontend/src/lib/components/Errors/ErrorDisplay.scss +++ /dev/null @@ -1,11 +0,0 @@ -.ErrorDisplay__stacktrace { - .LemonCollapsePanel__header { - min-height: 1.875rem !important; - padding: 0 !important; - background-color: var(--accent-3000); - - &--disabled:hover { - background-color: var(--accent-3000) !important; - } - } -} diff --git a/frontend/src/lib/components/Errors/ErrorDisplay.stories.tsx b/frontend/src/lib/components/Errors/ErrorDisplay.stories.tsx index c93643b3af17a..8033e0631487e 100644 --- a/frontend/src/lib/components/Errors/ErrorDisplay.stories.tsx +++ b/frontend/src/lib/components/Errors/ErrorDisplay.stories.tsx @@ -82,15 +82,13 @@ function errorProperties(properties: Record): EventType['properties customer: 'the-customer', instance: 'https://app.posthog.com', }, - $exception_message: 'ResizeObserver loop limit exceeded', - $exception_type: 'Error', $exception_fingerprint: 'Error', $exception_personURL: 'https://app.posthog.com/person/the-person-id', $sentry_event_id: 'id-from-the-sentry-integration', $sentry_exception: { values: [ { - value: 'ResizeObserver loop limit exceeded', + value: "DB::Exception: There was an error on [localhost:9000]: Code: 701. DB::Exception: Requested cluster 'posthog_single_shard' not found. (CLUSTER_DOESNT_EXIST) (version 23.11.2.11 (official build)). Stack trace: 0. DB::Exception::Exception(DB::Exception::MessageMasked&&, int, bool) @ 0x000000000c4fd597 in /usr/bin/clickhouse 1. DB::DDLQueryStatusSource::generate() @ 0x00000000113205f8 in /usr/bin/clickhouse 2. DB::ISource::tryGenerate() @ 0x0000000012290275 in /usr/bin/clickhouse 3. DB::ISource::work() @ 0x000000001228fcc3 in /usr/bin/clickhouse 4. DB::ExecutionThreadContext::executeTask() @ 0x00000000122a78ba in /usr/bin/clickhouse 5. DB::PipelineExecutor::executeStepImpl(unsigned long, std::atomic*) @ 0x000000001229e5d0 in /usr/bin/clickhouse 6. DB::PipelineExecutor::execute(unsigned long, bool) @ 0x000000001229d860 in /usr/bin/clickhouse 7. void std::__function::__policy_invoker::__call_impl::ThreadFromGlobalPoolImpl(DB::PullingAsyncPipelineExecutor::pull(DB::Chunk&, unsigned long)::$_0&&)::'lambda'(), void ()>>(std::__function::__policy_storage const*) @ 0x00000000122ab1cf in /usr/bin/clickhouse 8. void* std::__thread_proxy[abi:v15000]>, void ThreadPoolImpl::scheduleImpl(std::function, Priority, std::optional, bool)::'lambda0'()>>(void*) @ 0x000000000c5e45d3 in /usr/bin/clickhouse 9. ? @ 0x00007429a8071609 in ? 10. ? @ 0x00007429a7f96133 in ?", type: 'Error', mechanism: { type: 'onerror', @@ -135,42 +133,37 @@ function errorProperties(properties: Record): EventType['properties $lib_version__major: 1, $lib_version__minor: 63, $lib_version__patch: 3, + $exception_list: [ + { + value: 'ResizeObserver loop limit exceeded', + type: 'Error', + }, + ], ...properties, } } -export function ResizeObserverLoopLimitExceeded(): JSX.Element { - return ( - - ) -} - -export function SafariScriptError(): JSX.Element { +export function StacktracelessSafariScriptError(): JSX.Element { return ( ) } -export function ImportingModule(): JSX.Element { +export function StacktracelessImportModuleError(): JSX.Element { return ( ) @@ -207,8 +200,6 @@ export function ChainedErrorStack(): JSX.Element { return ( ) } -export function WithCymbalErrors(): JSX.Element { +export function SentryStackTrace(): JSX.Element { + return +} + +export function LegacyEventProperties(): JSX.Element { return ( ) diff --git a/frontend/src/lib/components/Errors/ErrorDisplay.tsx b/frontend/src/lib/components/Errors/ErrorDisplay.tsx index cc6899794e94f..98e5292da2346 100644 --- a/frontend/src/lib/components/Errors/ErrorDisplay.tsx +++ b/frontend/src/lib/components/Errors/ErrorDisplay.tsx @@ -1,257 +1,36 @@ -import './ErrorDisplay.scss' - -import { LemonBanner, LemonCollapse, Tooltip } from '@posthog/lemon-ui' +import { LemonBanner } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { TitledSnack } from 'lib/components/TitledSnack' import { LemonDivider } from 'lib/lemon-ui/LemonDivider' import { LemonSwitch } from 'lib/lemon-ui/LemonSwitch' -import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag' import { Link } from 'lib/lemon-ui/Link' -import { useEffect, useState } from 'react' +import { getExceptionAttributes, hasAnyInAppFrames, hasStacktrace } from 'scenes/error-tracking/utils' import { EventType } from '~/types' -import { CodeLine, getLanguage, Language } from '../CodeSnippet/CodeSnippet' import { stackFrameLogic } from './stackFrameLogic' -import { - ErrorTrackingException, - ErrorTrackingStackFrame, - ErrorTrackingStackFrameContext, - ErrorTrackingStackFrameContextLine, -} from './types' - -function StackTrace({ - frames, - showAllFrames, -}: { - frames: ErrorTrackingStackFrame[] - showAllFrames: boolean -}): JSX.Element | null { - const { stackFrameRecords } = useValues(stackFrameLogic) - const displayFrames = showAllFrames ? frames : frames.filter((f) => f.in_app) - - const panels = displayFrames.map( - ({ raw_id, source, line, column, resolved_name, lang, resolved, resolve_failure }, index) => { - const record = stackFrameRecords[raw_id] - return { - key: index, - header: ( -
    -
    - {source} - {resolved_name ? ( -
    - in - {resolved_name} -
    - ) : null} - {line ? ( -
    - @ - - {line} - {column && `:${column}`} - -
    - ) : null} -
    - {!resolved && ( -
    - - Unresolved - -
    - )} -
    - ), - content: - record && record.context ? ( - - ) : null, - className: 'p-0', - } - } - ) - - return -} - -function FrameContext({ - context, - language, -}: { - context: ErrorTrackingStackFrameContext - language: Language -}): JSX.Element { - const { before, line, after } = context - return ( - <> - - - - - ) -} - -function FrameContextLine({ - lines, - language, - highlight, -}: { - lines: ErrorTrackingStackFrameContextLine[] - language: Language - highlight?: boolean -}): JSX.Element { - return ( -
    - {lines - .sort((l) => l.number) - .map(({ number, line }) => ( -
    -
    {number}
    - -
    - ))} -
    - ) -} -function ChainedStackTraces({ exceptionList }: { exceptionList: ErrorTrackingException[] }): JSX.Element { - const hasAnyInApp = exceptionList.some(({ stacktrace }) => stacktrace?.frames?.some(({ in_app }) => in_app)) - const [showAllFrames, setShowAllFrames] = useState(!hasAnyInApp) - const { loadFromRawIds } = useActions(stackFrameLogic) - - useEffect(() => { - const frames: ErrorTrackingStackFrame[] = exceptionList.flatMap((e) => { - const trace = e.stacktrace - if (trace?.type === 'resolved') { - return trace.frames - } - return [] - }) - loadFromRawIds(frames.map(({ raw_id }) => raw_id)) - }, [exceptionList, loadFromRawIds]) - - return ( - <> -
    -

    Stack Trace

    - {hasAnyInApp ? ( - { - setShowAllFrames(!showAllFrames) - }} - /> - ) : null} -
    - {exceptionList.map(({ stacktrace, value }, index) => { - if (stacktrace && stacktrace.type === 'resolved') { - const { frames } = stacktrace - if (!showAllFrames && !frames?.some((frame) => frame.in_app)) { - // if we're not showing all frames and there are no in_app frames, skip this exception - return null - } - - return ( -
    -

    {value}

    - -
    - ) - } - })} - - ) -} - -export function getExceptionPropertiesFrom(eventProperties: Record): Record { - const { - $lib, - $lib_version, - $browser, - $browser_version, - $os, - $os_version, - $active_feature_flags, - $sentry_url, - $sentry_exception, - $level, - } = eventProperties - - let $exception_type = eventProperties.$exception_type - let $exception_message = eventProperties.$exception_message - let $exception_synthetic = eventProperties.$exception_synthetic - let $exception_list = eventProperties.$exception_list - - // exception autocapture sets $exception_list for all exceptions. - // If it's not present, then this is probably a sentry exception. Get this list from the sentry_exception - if (!$exception_list?.length && $sentry_exception) { - if (Array.isArray($sentry_exception.values)) { - $exception_list = $sentry_exception.values - } - } - - if (!$exception_type) { - $exception_type = $exception_list?.[0]?.type - } - if (!$exception_message) { - $exception_message = $exception_list?.[0]?.value - } - if ($exception_synthetic == undefined) { - $exception_synthetic = $exception_list?.[0]?.mechanism?.synthetic - } - - return { - $exception_type, - $exception_message, - $exception_synthetic, - $lib, - $lib_version, - $browser, - $browser_version, - $os, - $os_version, - $active_feature_flags, - $sentry_url, - $exception_list, - $level, - } -} +import { ChainedStackTraces } from './StackTraces' +import { ErrorTrackingException } from './types' export function ErrorDisplay({ eventProperties }: { eventProperties: EventType['properties'] }): JSX.Element { - const { - $exception_type, - $exception_message, - $exception_synthetic, - $lib, - $lib_version, - $browser, - $browser_version, - $os, - $os_version, - $sentry_url, - $exception_list, - $level, - } = getExceptionPropertiesFrom(eventProperties) + const { type, value, library, browser, os, sentryUrl, exceptionList, level, ingestionErrors, unhandled } = + getExceptionAttributes(eventProperties) - const exceptionList: ErrorTrackingException[] | undefined = $exception_list - const exceptionWithStack = exceptionList?.length && exceptionList.some((e) => !!e.stacktrace) - const ingestionErrors: string[] | undefined = eventProperties['$cymbal_errors'] + const exceptionWithStack = hasStacktrace(exceptionList) return ( -
    -

    {$exception_message}

    +
    +

    {type || level}

    + {!exceptionWithStack &&
    {value}
    }
    - {$exception_type || $level} Sentry @@ -261,10 +40,10 @@ export function ErrorDisplay({ eventProperties }: { eventProperties: EventType[' ) } /> - - - - + + + +
    {ingestionErrors || exceptionWithStack ? : null} @@ -279,7 +58,29 @@ export function ErrorDisplay({ eventProperties }: { eventProperties: EventType[' )} - {exceptionWithStack ? : null} + {exceptionWithStack && }
    ) } + +const StackTrace = ({ exceptionList }: { exceptionList: ErrorTrackingException[] }): JSX.Element => { + const { showAllFrames } = useValues(stackFrameLogic) + const { setShowAllFrames } = useActions(stackFrameLogic) + const hasAnyInApp = hasAnyInAppFrames(exceptionList) + + return ( + <> +
    +

    Stack Trace

    + {hasAnyInApp ? ( + setShowAllFrames(!showAllFrames)} + /> + ) : null} +
    + + + ) +} diff --git a/frontend/src/lib/components/Errors/StackTraces.scss b/frontend/src/lib/components/Errors/StackTraces.scss new file mode 100644 index 0000000000000..c71349387cd11 --- /dev/null +++ b/frontend/src/lib/components/Errors/StackTraces.scss @@ -0,0 +1,25 @@ +.StackTrace { + .LemonCollapse { + &.LemonCollapse--embedded { + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + } + + .LemonCollapsePanel__header { + min-height: 1.875rem !important; + padding: 0 !important; + background-color: var(--accent-3000); + + &--disabled:hover { + background-color: var(--accent-3000) !important; + } + } + } + + &--embedded { + .StackTrace__type, + .StackTrace__value { + margin: 0 8px; + } + } +} diff --git a/frontend/src/lib/components/Errors/StackTraces.tsx b/frontend/src/lib/components/Errors/StackTraces.tsx new file mode 100644 index 0000000000000..588f6dae7039c --- /dev/null +++ b/frontend/src/lib/components/Errors/StackTraces.tsx @@ -0,0 +1,164 @@ +import './StackTraces.scss' + +import { LemonCollapse, Tooltip } from '@posthog/lemon-ui' +import clsx from 'clsx' +import { useActions, useValues } from 'kea' +import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag' +import { useEffect } from 'react' + +import { CodeLine, getLanguage, Language } from '../CodeSnippet/CodeSnippet' +import { stackFrameLogic } from './stackFrameLogic' +import { + ErrorTrackingException, + ErrorTrackingStackFrame, + ErrorTrackingStackFrameContext, + ErrorTrackingStackFrameContextLine, +} from './types' + +export function ChainedStackTraces({ + exceptionList, + showAllFrames, + embedded = false, +}: { + exceptionList: ErrorTrackingException[] + showAllFrames: boolean + embedded?: boolean +}): JSX.Element { + const { loadFromRawIds } = useActions(stackFrameLogic) + + useEffect(() => { + const frames: ErrorTrackingStackFrame[] = exceptionList.flatMap((e) => { + const trace = e.stacktrace + if (trace?.type === 'resolved') { + return trace.frames + } + return [] + }) + loadFromRawIds(frames.map(({ raw_id }) => raw_id)) + }, [exceptionList, loadFromRawIds]) + + return ( + <> + {exceptionList.map(({ stacktrace, value, type }, index) => { + if (stacktrace && stacktrace.type === 'resolved') { + const { frames } = stacktrace + if (!showAllFrames && !frames?.some((frame) => frame.in_app)) { + // if we're not showing all frames and there are no in_app frames, skip this exception + return null + } + + return ( +
    +
    +

    {type}

    +
    {value}
    +
    + +
    + ) + } + })} + + ) +} + +function Trace({ + frames, + showAllFrames, + embedded, +}: { + frames: ErrorTrackingStackFrame[] + showAllFrames: boolean + embedded: boolean +}): JSX.Element | null { + const { stackFrameRecords } = useValues(stackFrameLogic) + const displayFrames = showAllFrames ? frames : frames.filter((f) => f.in_app) + + const panels = displayFrames.map( + ({ raw_id, source, line, column, resolved_name, lang, resolved, resolve_failure }, index) => { + const record = stackFrameRecords[raw_id] + return { + key: index, + header: ( +
    +
    + {source} + {resolved_name ? ( +
    + in + {resolved_name} +
    + ) : null} + {line ? ( +
    + @ + + {line} + {column && `:${column}`} + +
    + ) : null} +
    + {!resolved && ( +
    + + Unresolved + +
    + )} +
    + ), + content: + record && record.context ? ( + + ) : null, + className: 'p-0', + } + } + ) + + return +} + +function FrameContext({ + context, + language, +}: { + context: ErrorTrackingStackFrameContext + language: Language +}): JSX.Element { + const { before, line, after } = context + return ( + <> + + + + + ) +} + +function FrameContextLine({ + lines, + language, + highlight, +}: { + lines: ErrorTrackingStackFrameContextLine[] + language: Language + highlight?: boolean +}): JSX.Element { + return ( +
    + {lines + .sort((l) => l.number) + .map(({ number, line }) => ( +
    +
    {number}
    + +
    + ))} +
    + ) +} diff --git a/frontend/src/lib/components/Errors/error-display.test.ts b/frontend/src/lib/components/Errors/error-display.test.ts index 2e9024e80b7b2..5c4d945ff7346 100644 --- a/frontend/src/lib/components/Errors/error-display.test.ts +++ b/frontend/src/lib/components/Errors/error-display.test.ts @@ -1,4 +1,4 @@ -import { getExceptionPropertiesFrom } from 'lib/components/Errors/ErrorDisplay' +import { getExceptionAttributes } from 'scenes/error-tracking/utils' describe('Error Display', () => { it('can read sentry stack trace when $exception_list is not present', () => { @@ -47,13 +47,11 @@ describe('Error Display', () => { $exception_personURL: 'https://app.posthog.com/person/f6kW3HXaha6dAvHZiOmgrcAXK09682P6nNPxvfjqM9c', $exception_type: 'Error', } - const result = getExceptionPropertiesFrom(eventProperties) + const result = getExceptionAttributes(eventProperties) expect(result).toEqual({ - $active_feature_flags: ['feature1,feature2'], - $browser: 'Chrome', - $browser_version: '92.0.4515', - $exception_message: 'There was an error creating the support ticket with zendesk.', - $exception_list: [ + browser: 'Chrome 92.0.4515', + value: 'There was an error creating the support ticket with zendesk.', + exceptionList: [ { mechanism: { handled: true, @@ -74,14 +72,14 @@ describe('Error Display', () => { value: 'There was an error creating the support ticket with zendesk.', }, ], - $exception_synthetic: undefined, - $exception_type: 'Error', - $lib: 'posthog-js', - $lib_version: '1.0.0', - $level: undefined, - $os: 'Windows', - $os_version: '10', - $sentry_url: + synthetic: undefined, + unhandled: false, + type: 'Error', + library: 'posthog-js 1.0.0', + level: undefined, + os: 'Windows 10', + ingestionErrors: undefined, + sentryUrl: 'https://sentry.io/organizations/posthog/issues/?project=1899813&query=40e442d79c22473391aeeeba54c82163', }) }) @@ -110,20 +108,19 @@ describe('Error Display', () => { $level: 'info', $exception_message: 'the message sent into sentry captureMessage', } - const result = getExceptionPropertiesFrom(eventProperties) + const result = getExceptionAttributes(eventProperties) expect(result).toEqual({ - $active_feature_flags: ['feature1,feature2'], - $browser: 'Chrome', - $browser_version: '92.0.4515', - $exception_message: 'the message sent into sentry captureMessage', - $exception_synthetic: undefined, - $exception_type: undefined, - $lib: 'posthog-js', - $lib_version: '1.0.0', - $level: 'info', - $os: 'Windows', - $os_version: '10', - $sentry_url: + browser: 'Chrome 92.0.4515', + value: 'the message sent into sentry captureMessage', + exceptionList: [], + ingestionErrors: undefined, + unhandled: true, + synthetic: undefined, + type: undefined, + library: 'posthog-js 1.0.0', + level: 'info', + os: 'Windows 10', + sentryUrl: 'https://sentry.io/organizations/posthog/issues/?project=1899813&query=40e442d79c22473391aeeeba54c82163', }) }) @@ -162,21 +159,17 @@ describe('Error Display', () => { ], $exception_personURL: 'https://app.posthog.com/person/f6kW3HXaha6dAvHZiOmgrcAXK09682P6nNPxvfjqM9c', } - const result = getExceptionPropertiesFrom(eventProperties) + const result = getExceptionAttributes(eventProperties) expect(result).toEqual({ - $active_feature_flags: ['feature1,feature2'], - $browser: 'Chrome', - $browser_version: '92.0.4515', - $exception_message: 'There was an error creating the support ticket with zendesk2.', - $exception_synthetic: false, - $exception_type: 'Error', - $lib: 'posthog-js', - $lib_version: '1.0.0', - $level: undefined, - $os: 'Windows', - $os_version: '10', - $sentry_url: undefined, - $exception_list: [ + browser: 'Chrome 92.0.4515', + value: 'There was an error creating the support ticket with zendesk2.', + synthetic: false, + type: 'Error', + library: 'posthog-js 1.0.0', + level: undefined, + os: 'Windows 10', + sentryUrl: undefined, + exceptionList: [ { mechanism: { handled: true, @@ -198,6 +191,8 @@ describe('Error Display', () => { value: 'There was an error creating the support ticket with zendesk2.', }, ], + ingestionErrors: undefined, + unhandled: false, }) }) }) diff --git a/frontend/src/lib/components/Errors/stackFrameLogic.ts b/frontend/src/lib/components/Errors/stackFrameLogic.ts index 41516ccee257f..ae4668410b747 100644 --- a/frontend/src/lib/components/Errors/stackFrameLogic.ts +++ b/frontend/src/lib/components/Errors/stackFrameLogic.ts @@ -1,4 +1,4 @@ -import { actions, kea, path } from 'kea' +import { actions, kea, path, reducers } from 'kea' import { loaders } from 'kea-loaders' import api from 'lib/api' @@ -20,8 +20,19 @@ export const stackFrameLogic = kea([ actions({ loadFromRawIds: (rawIds: ErrorTrackingStackFrame['raw_id'][]) => ({ rawIds }), loadForSymbolSet: (symbolSetId: ErrorTrackingSymbolSet['id']) => ({ symbolSetId }), + setShowAllFrames: (showAllFrames: boolean) => ({ showAllFrames }), }), + reducers(() => ({ + showAllFrames: [ + false, + { persist: true }, + { + setShowAllFrames: (_, { showAllFrames }) => showAllFrames, + }, + ], + })), + loaders(({ values }) => ({ stackFrameRecords: [ {} as KeyedStackFrameRecords, diff --git a/frontend/src/lib/components/Errors/types.ts b/frontend/src/lib/components/Errors/types.ts index 3442e81884eb8..fe2ca33e222c4 100644 --- a/frontend/src/lib/components/Errors/types.ts +++ b/frontend/src/lib/components/Errors/types.ts @@ -3,6 +3,11 @@ export interface ErrorTrackingException { module: string type: string value: string + mechanism?: { + synthetic?: boolean + handled?: boolean + type: 'generic' + } } interface ErrorTrackingRawStackTrace { diff --git a/frontend/src/lib/components/PanelLayout/PanelLayout.tsx b/frontend/src/lib/components/PanelLayout/PanelLayout.tsx new file mode 100644 index 0000000000000..8cf0c74531f86 --- /dev/null +++ b/frontend/src/lib/components/PanelLayout/PanelLayout.tsx @@ -0,0 +1,135 @@ +import { IconMinus } from '@posthog/icons' +import { LemonButton, LemonButtonProps, Tooltip } from '@posthog/lemon-ui' +import clsx from 'clsx' +import { LemonMenu, LemonMenuItem, LemonMenuProps } from 'lib/lemon-ui/LemonMenu/LemonMenu' +import { useState } from 'react' + +type PanelContainerProps = { + children: React.ReactNode + primary: boolean + className?: string + column?: boolean + title: string + header?: JSX.Element | null +} + +interface SettingsMenuProps extends Omit { + label?: string + items: LemonMenuItem[] + icon?: JSX.Element + isAvailable?: boolean + whenUnavailable?: LemonMenuItem + highlightWhenActive?: boolean + closeOnClickInside?: boolean +} + +type SettingsButtonProps = Omit & { + tooltip?: string + icon?: JSX.Element | null + label?: JSX.Element | string +} + +type SettingsToggleProps = SettingsButtonProps & { + active: boolean +} + +function PanelLayout({ className, ...props }: Omit): JSX.Element { + return +} + +function Container({ children, primary, className, column }: Omit): JSX.Element { + return ( +
    + {children} +
    + ) +} + +function Panel({ children, primary, className, title, header }: Omit): JSX.Element { + const [open, setOpen] = useState(true) + + return ( +
    +
    + {title} +
    + {header} + setOpen(!open)} icon={} /> +
    +
    + {open ? children : null} +
    + ) +} + +export function SettingsMenu({ + label, + items, + icon, + isAvailable = true, + closeOnClickInside = true, + highlightWhenActive = true, + whenUnavailable, + ...props +}: SettingsMenuProps): JSX.Element { + const active = items.some((cf) => !!cf.active) + return ( + + + {label} + + + ) +} + +export function SettingsToggle({ tooltip, icon, label, active, ...props }: SettingsToggleProps): JSX.Element { + const button = ( + + {label} + + ) + + // otherwise the tooltip shows instead of the disabled reason + return props.disabledReason ? button : {button} +} + +export function SettingsButton(props: SettingsButtonProps): JSX.Element { + return +} + +PanelLayout.Panel = Panel +PanelLayout.Container = Container +PanelLayout.SettingsMenu = SettingsMenu +PanelLayout.SettingsToggle = SettingsToggle +PanelLayout.SettingsButton = SettingsButton + +export default PanelLayout diff --git a/frontend/src/lib/components/PropertyFilters/components/FilterRow.tsx b/frontend/src/lib/components/PropertyFilters/components/FilterRow.tsx index ee99d2aa6e6b1..741556a28fc92 100644 --- a/frontend/src/lib/components/PropertyFilters/components/FilterRow.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/FilterRow.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx' import { isValidPropertyFilter } from 'lib/components/PropertyFilters/utils' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { Popover } from 'lib/lemon-ui/Popover/Popover' -import React, { useEffect, useState } from 'react' +import React, { useState } from 'react' import { AnyPropertyFilter, PathCleaningFilter } from '~/types' @@ -45,28 +45,26 @@ export const FilterRow = React.memo(function FilterRow({ errorMessage, disabledReason, }: FilterRowProps) { - const [open, setOpen] = useState(false) - - useEffect(() => { - setOpen(openOnInsert) - }, []) + const [open, setOpen] = useState(() => openOnInsert) const { key } = item + const isValid = isValidPropertyFilter(item) const handleVisibleChange = (visible: boolean): void => { - if (!visible && isValidPropertyFilter(item) && !item.key) { + if (!visible && isValid && !item.key) { onRemove(index) } + setOpen(visible) } return ( <>
    {disablePopover ? ( @@ -89,7 +87,7 @@ export const FilterRow = React.memo(function FilterRow({ onClickOutside={() => handleVisibleChange(false)} overlay={filterComponent(() => setOpen(false))} > - {isValidPropertyFilter(item) ? ( + {isValid ? ( setOpen(!open)} onClose={() => onRemove(index)} @@ -99,7 +97,7 @@ export const FilterRow = React.memo(function FilterRow({ ) : !disabledReason ? ( setOpen(!open)} - className="new-prop-filter" + className="new-prop-filter grow" data-attr={'new-prop-filter-' + pageKey} type="secondary" size="small" diff --git a/frontend/src/lib/components/PropertyFilters/components/OperatorValueSelect.tsx b/frontend/src/lib/components/PropertyFilters/components/OperatorValueSelect.tsx index 1c5c897b291e7..8097e9e693393 100644 --- a/frontend/src/lib/components/PropertyFilters/components/OperatorValueSelect.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/OperatorValueSelect.tsx @@ -20,7 +20,7 @@ export interface OperatorValueSelectProps { type?: PropertyFilterType propertyKey?: string operator?: PropertyOperator | null - value?: string | number | Array | null + value?: string | number | bigint | Array | null placeholder?: string endpoint?: string onChange: (operator: PropertyOperator, value: PropertyFilterValue) => void diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyFilterButton.scss b/frontend/src/lib/components/PropertyFilters/components/PropertyFilterButton.scss index 7e7f1c598f2c6..9bd77b5b7b1c1 100644 --- a/frontend/src/lib/components/PropertyFilters/components/PropertyFilterButton.scss +++ b/frontend/src/lib/components/PropertyFilters/components/PropertyFilterButton.scss @@ -43,6 +43,7 @@ .PropertyFilterButton-content { flex: 1; overflow: hidden; + text-align: left; text-overflow: ellipsis; } diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyFilterButton.tsx b/frontend/src/lib/components/PropertyFilters/components/PropertyFilterButton.tsx index a6201d8e2ceb2..80708b3586395 100644 --- a/frontend/src/lib/components/PropertyFilters/components/PropertyFilterButton.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/PropertyFilterButton.tsx @@ -39,7 +39,7 @@ export const PropertyFilterButton = React.forwardRef ['string', 'number'].includes(typeof candidateDateTimeValue) const narrowToString = ( - candidateDateTimeValue: string | number | (string | number)[] | null | undefined + candidateDateTimeValue: string | number | bigint | (string | number | bigint)[] | null | undefined ): candidateDateTimeValue is string | null | undefined => candidateDateTimeValue == undefined || typeof candidateDateTimeValue === 'string' @@ -19,7 +19,7 @@ interface PropertyFilterDatePickerProps { autoFocus: boolean operator: PropertyOperator setValue: (newValue: PropertyValueProps['value']) => void - value: string | number | (string | number)[] | null | undefined + value: string | number | bigint | (string | number | bigint)[] | null | undefined } const dateAndTimeFormat = 'YYYY-MM-DD HH:mm:ss' diff --git a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx index 67dc2fed89a23..5344247507333 100644 --- a/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/PropertyValue.tsx @@ -21,7 +21,7 @@ export interface PropertyValueProps { endpoint?: string // Endpoint to fetch options from placeholder?: string onSet: CallableFunction - value?: string | number | Array | null + value?: string | number | bigint | Array | null operator: PropertyOperator autoFocus?: boolean eventNames?: string[] diff --git a/frontend/src/lib/components/Sharing/SharingModal.tsx b/frontend/src/lib/components/Sharing/SharingModal.tsx index 7ef76f6fc54d6..00801a89ebd3d 100644 --- a/frontend/src/lib/components/Sharing/SharingModal.tsx +++ b/frontend/src/lib/components/Sharing/SharingModal.tsx @@ -65,7 +65,7 @@ export function SharingModalContent({ iframeProperties, shareLink, } = useValues(sharingLogic(logicProps)) - const { setIsEnabled, togglePreview } = useActions(sharingLogic(logicProps)) + const { setIsEnabled, togglePreview, setEmbedConfigValue } = useActions(sharingLogic(logicProps)) const { guardAvailableFeature } = useValues(upgradeModalLogic) const [iframeLoaded, setIframeLoaded] = useState(false) @@ -159,7 +159,7 @@ export function SharingModalContent({ )} - {({ value, onChange }) => ( + {({ value }) => ( } onChange={() => - guardAvailableFeature(AvailableFeature.WHITE_LABELLING, () => - onChange(!value) - ) + guardAvailableFeature(AvailableFeature.WHITE_LABELLING, () => { + // setEmbedConfigValue is used to update the form state and call the reportDashboardWhitelabelToggled event + setEmbedConfigValue('whitelabel', !value) + }) } checked={!value} /> diff --git a/frontend/src/lib/components/Sharing/sharingLogic.ts b/frontend/src/lib/components/Sharing/sharingLogic.ts index 701fe9e5c9704..8d79928ad2036 100644 --- a/frontend/src/lib/components/Sharing/sharingLogic.ts +++ b/frontend/src/lib/components/Sharing/sharingLogic.ts @@ -86,6 +86,11 @@ export const sharingLogic = kea([ dashboardsModel.actions.loadDashboards() } }, + setEmbedConfigValue: ({ name, value }) => { + if (name === 'whitelabel' && props.dashboardId) { + eventUsageLogic.actions.reportDashboardWhitelabelToggled(value) + } + }, })), forms({ diff --git a/frontend/src/lib/components/TaxonomicFilter/InfiniteSelectResults.tsx b/frontend/src/lib/components/TaxonomicFilter/InfiniteSelectResults.tsx index e2d060c36c76b..49fb919432fc1 100644 --- a/frontend/src/lib/components/TaxonomicFilter/InfiniteSelectResults.tsx +++ b/frontend/src/lib/components/TaxonomicFilter/InfiniteSelectResults.tsx @@ -5,6 +5,7 @@ import { InfiniteList } from 'lib/components/TaxonomicFilter/InfiniteList' import { infiniteListLogic } from 'lib/components/TaxonomicFilter/infiniteListLogic' import { TaxonomicFilterGroupType, TaxonomicFilterLogicProps } from 'lib/components/TaxonomicFilter/types' +import { TaxonomicFilterEmptyState, taxonomicFilterGroupTypesWithEmptyStates } from './TaxonomicFilterEmptyState' import { taxonomicFilterLogic } from './taxonomicFilterLogic' export interface InfiniteSelectResultsProps { @@ -31,7 +32,7 @@ function CategoryPill({ const group = taxonomicGroups.find((g) => g.type === groupType) // :TRICKY: use `totalListCount` (results + extra) to toggle interactivity, while showing `totalResultCount` - const canInteract = totalListCount > 0 + const canInteract = totalListCount > 0 || taxonomicFilterGroupTypesWithEmptyStates.includes(groupType) return ( ) + const showEmptyState = totalListCount === 0 && taxonomicFilterGroupTypesWithEmptyStates.includes(openTab) + return ( <> {hasMultipleGroups && ( @@ -107,6 +115,7 @@ export function InfiniteSelectResults({
    )} + {taxonomicGroupTypes.map((groupType) => { return (
    @@ -114,7 +123,8 @@ export function InfiniteSelectResults({ logic={infiniteListLogic} props={{ ...taxonomicFilterLogicProps, listGroupType: groupType }} > - {listComponent} + {showEmptyState && } + {!showEmptyState && listComponent}
    ) diff --git a/frontend/src/lib/components/TaxonomicFilter/TaxonomicFilterEmptyState.tsx b/frontend/src/lib/components/TaxonomicFilter/TaxonomicFilterEmptyState.tsx new file mode 100644 index 0000000000000..c13f378c25d84 --- /dev/null +++ b/frontend/src/lib/components/TaxonomicFilter/TaxonomicFilterEmptyState.tsx @@ -0,0 +1,96 @@ +import { IconOpenSidebar, IconPlus } from '@posthog/icons' +import { LemonButton } from '@posthog/lemon-ui' +import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' +import type React from 'react' +import { urls } from 'scenes/urls' + +import { PipelineStage } from '~/types' + +import { BuilderHog3 } from '../hedgehogs' + +type EmptyStateProps = { + title: string + description: string + action: { + to: string + text: string + } + docsUrl?: string + hog: React.ComponentType<{ className?: string }> + groupType: TaxonomicFilterGroupType +} + +const EmptyState = ({ title, description, action, docsUrl, hog: Hog, groupType }: EmptyStateProps): JSX.Element => { + return ( +
    +
    + +
    +
    +

    {title}

    +

    {description}

    +
    + } + to={action.to} + data-attr={`taxonomic-filter-empty-state-${groupType}-new-button`} + > + {action.text} + + } + to={`${docsUrl}?utm_medium=in-product&utm_campaign=taxonomic-filter-empty-state-docs-link`} + data-attr="product-introduction-docs-link" + targetBlank + > + Learn more + +
    +
    +
    + ) +} + +type Props = { + groupType: TaxonomicFilterGroupType +} + +const DataWarehouseEmptyState = (): JSX.Element => { + return ( + + ) +} + +const DefaultEmptyState = (): JSX.Element | null => { + return null +} + +const EMPTY_STATES: Partial JSX.Element>> = { + [TaxonomicFilterGroupType.DataWarehouse]: DataWarehouseEmptyState, + [TaxonomicFilterGroupType.DataWarehouseProperties]: DataWarehouseEmptyState, + [TaxonomicFilterGroupType.DataWarehousePersonProperties]: DataWarehouseEmptyState, +} as const + +export const taxonomicFilterGroupTypesWithEmptyStates = Object.keys(EMPTY_STATES) as TaxonomicFilterGroupType[] + +export const TaxonomicFilterEmptyState = (props: Props): JSX.Element => { + const EmptyState = EMPTY_STATES[props.groupType] + + if (EmptyState) { + return + } + + return +} diff --git a/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx b/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx index 4f78288aa2c75..3c6cc9ea3b79f 100644 --- a/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx +++ b/frontend/src/lib/components/TaxonomicFilter/taxonomicFilterLogic.tsx @@ -18,7 +18,6 @@ import { getEventDefinitionIcon, getPropertyDefinitionIcon } from 'scenes/data-m import { dataWarehouseJoinsLogic } from 'scenes/data-warehouse/external/dataWarehouseJoinsLogic' import { dataWarehouseSceneLogic } from 'scenes/data-warehouse/settings/dataWarehouseSceneLogic' import { experimentsLogic } from 'scenes/experiments/experimentsLogic' -import { featureFlagsLogic } from 'scenes/feature-flags/featureFlagsLogic' import { groupDisplayId } from 'scenes/persons/GroupActorDisplay' import { projectLogic } from 'scenes/projectLogic' import { ReplayTaxonomicFilters } from 'scenes/session-recordings/filters/ReplayTaxonomicFilters' @@ -442,8 +441,7 @@ export const taxonomicFilterLogic = kea([ name: 'Feature Flags', searchPlaceholder: 'feature flags', type: TaxonomicFilterGroupType.FeatureFlags, - logic: featureFlagsLogic, - value: 'featureFlags', + endpoint: combineUrl(`api/projects/${teamId}/feature_flags/`).url, getName: (featureFlag: FeatureFlagType) => featureFlag.key || featureFlag.name, getValue: (featureFlag: FeatureFlagType) => featureFlag.id || '', getPopoverHeader: () => `Feature Flags`, diff --git a/frontend/src/lib/components/UniversalFilters/UniversalFilterButton.tsx b/frontend/src/lib/components/UniversalFilters/UniversalFilterButton.tsx index 5b8c367f8291b..ede916afef3c6 100644 --- a/frontend/src/lib/components/UniversalFilters/UniversalFilterButton.tsx +++ b/frontend/src/lib/components/UniversalFilters/UniversalFilterButton.tsx @@ -1,6 +1,6 @@ import './UniversalFilterButton.scss' -import { IconFilter, IconX } from '@posthog/icons' +import { IconFilter, IconLogomark, IconX } from '@posthog/icons' import { LemonButton, PopoverReferenceContext } from '@posthog/lemon-ui' import clsx from 'clsx' import { useValues } from 'kea' @@ -78,15 +78,19 @@ const PropertyLabel = ({ filter }: { filter: AnyPropertyFilter }): JSX.Element = const { cohortsById } = useValues(cohortsModel) const { formatPropertyValueForDisplay } = useValues(propertyDefinitionsModel) - const label = formatPropertyLabel( + let label = formatPropertyLabel( filter, cohortsById, (s) => formatPropertyValueForDisplay(filter.key, s)?.toString() || '?' ) + const isEventFeature = label.startsWith('$feature/') + if (isEventFeature) { + label = label.replace('$feature/', 'Feature: ') + } return ( <> - + {isEventFeature ? : } {typeof label === 'string' ? midEllipsis(label, 32) : label} diff --git a/frontend/src/lib/constants.tsx b/frontend/src/lib/constants.tsx index 7dcec3058115a..27b5a89db92dd 100644 --- a/frontend/src/lib/constants.tsx +++ b/frontend/src/lib/constants.tsx @@ -192,7 +192,6 @@ export const FEATURE_FLAGS = { PERSONLESS_EVENTS_NOT_SUPPORTED: 'personless-events-not-supported', // owner: @raquelmsmith ALERTS: 'alerts', // owner: @anirudhpillai #team-product-analytics ERROR_TRACKING: 'error-tracking', // owner: #team-error-tracking - ERROR_TRACKING_GROUP_ACTIONS: 'error-tracking-group-actions', // owner: #team-error-tracking SETTINGS_BOUNCE_RATE_PAGE_VIEW_MODE: 'settings-bounce-rate-page-view-mode', // owner: @robbie-c ONBOARDING_DASHBOARD_TEMPLATES: 'onboarding-dashboard-templates', // owner: @raquelmsmith MULTIPLE_BREAKDOWNS: 'multiple-breakdowns', // owner: @skoob13 #team-product-analytics @@ -238,6 +237,10 @@ export const FEATURE_FLAGS = { CDP_ACTIVITY_LOG_NOTIFICATIONS: 'cdp-activity-log-notifications', // owner: #team-cdp COOKIELESS_SERVER_HASH_MODE_SETTING: 'cookieless-server-hash-mode-setting', // owner: @robbie-c #team-web-analytics INSIGHT_COLORS: 'insight-colors', // owner @thmsobrmlr #team-product-analytics + WEB_ANALYTICS_FOR_MOBILE: 'web-analytics-for-mobile', // owner @robbie-c #team-web-analytics + REPLAY_FLAGS_FILTERS: 'replay-flags-filters', // owner @pauldambra #team-replay + REPLAY_LANDING_PAGE: 'replay-landing-page', // owner #team-replay + CORE_WEB_VITALS: 'core-web-vitals', // owner @rafaeelaudibert #team-web-analytics } as const export type FeatureFlagKey = (typeof FEATURE_FLAGS)[keyof typeof FEATURE_FLAGS] diff --git a/frontend/src/lib/hooks/useWindowSize.js b/frontend/src/lib/hooks/useWindowSize.js deleted file mode 100644 index d9e137bc1b684..0000000000000 --- a/frontend/src/lib/hooks/useWindowSize.js +++ /dev/null @@ -1,33 +0,0 @@ -import { useEffect, useState } from 'react' - -export function useWindowSize() { - const isClient = typeof window === 'object' - - function getSize() { - return { - width: isClient ? window.innerWidth : undefined, - height: isClient ? window.innerHeight : undefined, - } - } - - const [windowSize, setWindowSize] = useState(getSize) - - useEffect( - () => { - if (!isClient) { - return false - } - - function handleResize() { - setWindowSize(getSize()) - } - - window.addEventListener('resize', handleResize) - return () => window.removeEventListener('resize', handleResize) - }, - - [] // Empty array ensures that effect is only run on mount and unmount - ) - - return windowSize -} diff --git a/frontend/src/lib/hooks/useWindowSize.ts b/frontend/src/lib/hooks/useWindowSize.ts new file mode 100644 index 0000000000000..0d8526af10d28 --- /dev/null +++ b/frontend/src/lib/hooks/useWindowSize.ts @@ -0,0 +1,55 @@ +import { TAILWIND_BREAKPOINTS } from 'lib/constants' +import { useCallback, useEffect, useState } from 'react' + +type WindowSize = { + width: number | undefined + height: number | undefined +} + +type Breakpoint = keyof typeof TAILWIND_BREAKPOINTS + +type UseWindowSize = { + windowSize: WindowSize + isWindowLessThan: (breakpoint: Breakpoint) => boolean +} + +export function useWindowSize(): UseWindowSize { + const isClient = typeof window === 'object' + + const getSize = useCallback(() => { + return { + width: isClient ? window.innerWidth : undefined, + height: isClient ? window.innerHeight : undefined, + } + }, [isClient]) + + const [windowSize, setWindowSize] = useState(getSize) + + const isWindowLessThan = useCallback( + (breakpoint: keyof typeof TAILWIND_BREAKPOINTS) => + !!windowSize?.width && windowSize.width < TAILWIND_BREAKPOINTS[breakpoint], + [windowSize] + ) + + useEffect( + () => { + if (!isClient) { + return + } + + function handleResize(): void { + const size = getSize() + setWindowSize(size) + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, + + // Empty array ensures that effect is only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ) + + return { windowSize, isWindowLessThan } +} diff --git a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss index 6cebd0b3a7c8a..848538718507b 100644 --- a/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss +++ b/frontend/src/lib/lemon-ui/LemonButton/LemonButton.scss @@ -220,6 +220,16 @@ --lemon-button-icon-opacity: 1; } + &.LemonButton--primary.LemonButton--status-danger { + --lemon-button-bg-color: var(--red-100); + --lemon-button-color: var(--text-3000); + + [theme='dark'] & { + --lemon-button-bg-color: #312101; + --lemon-button-color: var(--red-200); + } + } + &.LemonButton--secondary.LemonButton--status-alt { --lemon-button-color: var(--muted); diff --git a/frontend/src/lib/lemon-ui/LemonFileInput/LemonFileInput.tsx b/frontend/src/lib/lemon-ui/LemonFileInput/LemonFileInput.tsx index 04a13056a7b45..beee2ba1fcf86 100644 --- a/frontend/src/lib/lemon-ui/LemonFileInput/LemonFileInput.tsx +++ b/frontend/src/lib/lemon-ui/LemonFileInput/LemonFileInput.tsx @@ -13,6 +13,8 @@ export interface LemonFileInputProps extends Pick -