Merge branch 'master' of https://github.com/PostHog/posthog into detect-stale-flags
2
.vscode/launch.json
vendored
@ -97,7 +97,7 @@
|
||||
"console": "integratedTerminal",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"SKIP_ASYNC_MIGRATIONS_SETUP": "0",
|
||||
"SKIP_ASYNC_MIGRATIONS_SETUP": "1",
|
||||
"DEBUG": "1",
|
||||
"BILLING_SERVICE_URL": "https://billing.dev.posthog.dev",
|
||||
"SKIP_SERVICE_VERSION_REQUIREMENTS": "1"
|
||||
|
3
bin/hoge
@ -3,10 +3,13 @@ set -e
|
||||
|
||||
if [[ "$@" == *".hog"* ]]; then
|
||||
exec python3 -m posthog.hogql.cli --compile "$@"
|
||||
elif [[ "$@" == *".js"* ]]; then
|
||||
exec python3 -m posthog.hogql.cli --compile "$@"
|
||||
else
|
||||
echo "$0 - the Hog compilër! 🦔+🕶️= Hoge"
|
||||
echo ""
|
||||
echo "Usage: bin/hoge <file.hog> [output.hoge] compile .hog into .hoge"
|
||||
echo " bin/hoge <file.hog> <output.js> compile .hog into .js"
|
||||
echo " bin/hog <file.hog> run .hog source code"
|
||||
echo " bin/hog <file.hoge> run compiled .hoge bytecode"
|
||||
exit 1
|
||||
|
@ -88,9 +88,7 @@ describe('Experiments', () => {
|
||||
// Wait for the goal modal to open and click the confirmation button
|
||||
cy.get('.LemonModal__layout').should('be.visible')
|
||||
cy.contains('Change experiment goal').should('be.visible')
|
||||
cy.get('.LemonModal__footer').contains('button', 'Save').should('have.attr', 'aria-disabled', 'true')
|
||||
cy.get('.LemonModal__content').contains('button', 'Add funnel step').click()
|
||||
cy.get('.LemonModal__footer').contains('button', 'Save').should('not.have.attr', 'aria-disabled', 'true')
|
||||
cy.get('.LemonModal__footer').contains('button', 'Save').click()
|
||||
}
|
||||
|
||||
|
@ -308,7 +308,8 @@ describe('Feature Flags', () => {
|
||||
cy.get('.operator-value-option').contains('> after').should('not.exist')
|
||||
})
|
||||
|
||||
it('Allow setting multivariant rollout percentage to zero', () => {
|
||||
it('Allows setting multivariant rollout percentage to zero', () => {
|
||||
cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags')
|
||||
// Start creating a multivariant flag
|
||||
cy.get('[data-attr=new-feature-flag]').click()
|
||||
cy.get('[data-attr=feature-flag-served-value-segmented-button]')
|
||||
@ -328,6 +329,15 @@ describe('Feature Flags', () => {
|
||||
cy.get('[data-attr=feature-flag-variant-rollout-percentage-input]').click().type(`4.5`).should('have.value', 4)
|
||||
})
|
||||
|
||||
it('Sets URL properly when switching between tabs', () => {
|
||||
cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags')
|
||||
cy.get('[data-attr=feature-flags-tab-navigation]').contains('History').click()
|
||||
cy.url().should('include', `tab=history`)
|
||||
|
||||
cy.get('[data-attr=feature-flags-tab-navigation]').contains('Overview').click()
|
||||
cy.url().should('include', `tab=overview`)
|
||||
})
|
||||
|
||||
it('Renders flags in FlagSelector', () => {
|
||||
// Create flag name
|
||||
cy.get('[data-attr=top-bar-name]').should('contain', 'Feature flags')
|
||||
|
@ -269,6 +269,7 @@ describe('Surveys', () => {
|
||||
|
||||
// Set responses limit
|
||||
cy.get('.LemonCollapsePanel').contains('Completion conditions').click()
|
||||
cy.get('[data-attr=survey-collection-until-limit]').first().click()
|
||||
cy.get('[data-attr=survey-responses-limit-input]').focus().type('228').click()
|
||||
|
||||
// Save the survey
|
||||
@ -276,7 +277,7 @@ describe('Surveys', () => {
|
||||
cy.get('button[data-attr="launch-survey"]').should('have.text', 'Launch')
|
||||
|
||||
cy.reload()
|
||||
cy.contains('The survey will be stopped once 228 responses are received.').should('be.visible')
|
||||
cy.contains('The survey will be stopped once 100228 responses are received.').should('be.visible')
|
||||
})
|
||||
|
||||
it('creates a new survey with branching logic', () => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import dataclasses
|
||||
|
||||
from posthog.client import sync_execute
|
||||
from posthog.hogql.bytecode import create_bytecode
|
||||
from posthog.hogql.compiler.bytecode import create_bytecode
|
||||
from posthog.hogql.hogql import HogQLContext
|
||||
from posthog.hogql.property import action_to_expr
|
||||
from posthog.models.action import Action
|
||||
|
@ -31,7 +31,6 @@ from posthog.clickhouse.query_tagging import tag_queries
|
||||
from posthog.constants import INSIGHT_TRENDS
|
||||
from posthog.models.experiment import Experiment, ExperimentHoldout, ExperimentSavedMetric
|
||||
from posthog.models.filters.filter import Filter
|
||||
from posthog.schema import ExperimentFunnelsQuery, ExperimentTrendsQuery
|
||||
from posthog.utils import generate_cache_key, get_safe_cache
|
||||
|
||||
EXPERIMENT_RESULTS_CACHE_DEFAULT_TTL = 60 * 60 # 1 hour
|
||||
@ -194,6 +193,7 @@ class ExperimentSerializer(serializers.ModelSerializer):
|
||||
"updated_at",
|
||||
"type",
|
||||
"metrics",
|
||||
"metrics_secondary",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
@ -235,36 +235,7 @@ class ExperimentSerializer(serializers.ModelSerializer):
|
||||
return value
|
||||
|
||||
def validate_metrics(self, value):
|
||||
# TODO: This isn't correct most probably, we wouldn't have experiment_id inside ExperimentTrendsQuery
|
||||
# on creation. Not sure how this is supposed to work yet.
|
||||
if not value:
|
||||
return value
|
||||
|
||||
if not isinstance(value, list):
|
||||
raise ValidationError("Metrics must be a list")
|
||||
|
||||
if len(value) > 10:
|
||||
raise ValidationError("Experiments can have a maximum of 10 metrics")
|
||||
|
||||
for metric in value:
|
||||
if not isinstance(metric, dict):
|
||||
raise ValidationError("Metrics must be objects")
|
||||
if not metric.get("query"):
|
||||
raise ValidationError("Metric query is required")
|
||||
|
||||
if metric.get("type") not in ["primary", "secondary"]:
|
||||
raise ValidationError("Metric type must be 'primary' or 'secondary'")
|
||||
|
||||
metric_query = metric["query"]
|
||||
|
||||
if metric_query.get("kind") not in ["ExperimentTrendsQuery", "ExperimentFunnelsQuery"]:
|
||||
raise ValidationError("Metric query kind must be 'ExperimentTrendsQuery' or 'ExperimentFunnelsQuery'")
|
||||
|
||||
# pydantic models are used to validate the query
|
||||
if metric_query["kind"] == "ExperimentTrendsQuery":
|
||||
ExperimentTrendsQuery(**metric_query)
|
||||
else:
|
||||
ExperimentFunnelsQuery(**metric_query)
|
||||
# TODO 2024-11-15: commented code will be addressed when persistent metrics are implemented.
|
||||
|
||||
return value
|
||||
|
||||
@ -285,8 +256,8 @@ class ExperimentSerializer(serializers.ModelSerializer):
|
||||
def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment:
|
||||
is_draft = "start_date" not in validated_data or validated_data["start_date"] is None
|
||||
|
||||
if not validated_data.get("filters") and not is_draft:
|
||||
raise ValidationError("Filters are required when creating a launched experiment")
|
||||
# if not validated_data.get("filters") and not is_draft:
|
||||
# raise ValidationError("Filters are required when creating a launched experiment")
|
||||
|
||||
saved_metrics_data = validated_data.pop("saved_metrics_ids", [])
|
||||
|
||||
@ -301,11 +272,6 @@ class ExperimentSerializer(serializers.ModelSerializer):
|
||||
|
||||
feature_flag_key = validated_data.pop("get_feature_flag_key")
|
||||
|
||||
properties = validated_data["filters"].get("properties", [])
|
||||
|
||||
if properties:
|
||||
raise ValidationError("Experiments do not support global filter properties")
|
||||
|
||||
holdout_groups = None
|
||||
if validated_data.get("holdout"):
|
||||
holdout_groups = validated_data["holdout"].filters
|
||||
@ -315,8 +281,8 @@ class ExperimentSerializer(serializers.ModelSerializer):
|
||||
{"key": "test", "name": "Test Variant", "rollout_percentage": 50},
|
||||
]
|
||||
|
||||
filters = {
|
||||
"groups": [{"properties": properties, "rollout_percentage": 100}],
|
||||
feature_flag_filters = {
|
||||
"groups": [{"properties": [], "rollout_percentage": 100}],
|
||||
"multivariate": {"variants": variants or default_variants},
|
||||
"aggregation_group_type_index": aggregation_group_type_index,
|
||||
"holdout_groups": holdout_groups,
|
||||
@ -326,8 +292,9 @@ class ExperimentSerializer(serializers.ModelSerializer):
|
||||
data={
|
||||
"key": feature_flag_key,
|
||||
"name": f'Feature Flag for Experiment {validated_data["name"]}',
|
||||
"filters": filters,
|
||||
"filters": feature_flag_filters,
|
||||
"active": not is_draft,
|
||||
"creation_context": "experiments",
|
||||
},
|
||||
context=self.context,
|
||||
)
|
||||
@ -369,13 +336,13 @@ class ExperimentSerializer(serializers.ModelSerializer):
|
||||
return experiment
|
||||
|
||||
def update(self, instance: Experiment, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment:
|
||||
if (
|
||||
not instance.filters.get("events")
|
||||
and not instance.filters.get("actions")
|
||||
and validated_data.get("start_date")
|
||||
and not validated_data.get("filters")
|
||||
):
|
||||
raise ValidationError("Filters are required when launching an experiment")
|
||||
# if (
|
||||
# not instance.filters.get("events")
|
||||
# and not instance.filters.get("actions")
|
||||
# and validated_data.get("start_date")
|
||||
# and not validated_data.get("filters")
|
||||
# ):
|
||||
# raise ValidationError("Filters are required when launching an experiment")
|
||||
|
||||
update_saved_metrics = "saved_metrics_ids" in validated_data
|
||||
saved_metrics_data = validated_data.pop("saved_metrics_ids", []) or []
|
||||
@ -408,6 +375,8 @@ class ExperimentSerializer(serializers.ModelSerializer):
|
||||
"archived",
|
||||
"secondary_metrics",
|
||||
"holdout",
|
||||
"metrics",
|
||||
"metrics_secondary",
|
||||
}
|
||||
given_keys = set(validated_data.keys())
|
||||
extra_keys = given_keys - expected_keys
|
||||
|
@ -1235,42 +1235,6 @@ class TestExperimentCRUD(APILicensedTest):
|
||||
|
||||
self.assertIsNotNone(Experiment.objects.get(pk=id))
|
||||
|
||||
def test_cant_add_global_properties_to_new_experiment(self):
|
||||
ff_key = "a-b-tests"
|
||||
response = self.client.post(
|
||||
f"/api/projects/{self.team.id}/experiments/",
|
||||
{
|
||||
"name": "Test Experiment",
|
||||
"description": "",
|
||||
"start_date": None,
|
||||
"end_date": None,
|
||||
"feature_flag_key": ff_key,
|
||||
"parameters": None,
|
||||
"filters": {
|
||||
"events": [
|
||||
{"order": 0, "id": "$pageview"},
|
||||
{"order": 1, "id": "$pageleave"},
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"key": "industry",
|
||||
"type": "group",
|
||||
"value": ["technology"],
|
||||
"operator": "exact",
|
||||
"group_type_index": 1,
|
||||
}
|
||||
],
|
||||
"aggregation_group_type_index": 1,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(
|
||||
response.json()["detail"],
|
||||
"Experiments do not support global filter properties",
|
||||
)
|
||||
|
||||
def test_creating_updating_experiment_with_group_aggregation(self):
|
||||
ff_key = "a-b-tests"
|
||||
response = self.client.post(
|
||||
@ -1789,79 +1753,6 @@ class TestExperimentCRUD(APILicensedTest):
|
||||
self.assertEqual(response.json()["name"], "Test Experiment")
|
||||
self.assertEqual(response.json()["feature_flag_key"], ff_key)
|
||||
|
||||
def test_create_launched_experiment_without_filters(self) -> None:
|
||||
ff_key = "a-b-tests"
|
||||
response = self.client.post(
|
||||
f"/api/projects/{self.team.id}/experiments/",
|
||||
{
|
||||
"name": "Test Experiment",
|
||||
"description": "",
|
||||
"start_date": "2021-12-01T10:23",
|
||||
"end_date": None,
|
||||
"feature_flag_key": ff_key,
|
||||
"parameters": None,
|
||||
"filters": {},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.json()["detail"], "Filters are required when creating a launched experiment")
|
||||
|
||||
def test_launch_draft_experiment_without_filters(self) -> None:
|
||||
ff_key = "a-b-tests"
|
||||
response = self.client.post(
|
||||
f"/api/projects/{self.team.id}/experiments/",
|
||||
{
|
||||
"name": "Test Experiment",
|
||||
"description": "",
|
||||
"start_date": None,
|
||||
"end_date": None,
|
||||
"feature_flag_key": ff_key,
|
||||
"parameters": None,
|
||||
"filters": {},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||
draft_exp = response.json()
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/projects/{self.team.id}/experiments/{draft_exp['id']}",
|
||||
{
|
||||
"name": "Test Experiment",
|
||||
"description": "",
|
||||
"start_date": "2021-12-01T10:23",
|
||||
"end_date": None,
|
||||
"feature_flag_key": ff_key,
|
||||
"parameters": None,
|
||||
"filters": {},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||
self.assertEqual(response.json()["detail"], "Filters are required when launching an experiment")
|
||||
|
||||
response = self.client.patch(
|
||||
f"/api/projects/{self.team.id}/experiments/{draft_exp['id']}",
|
||||
{
|
||||
"name": "Test Experiment",
|
||||
"description": "",
|
||||
"start_date": "2021-12-01T10:23",
|
||||
"end_date": None,
|
||||
"feature_flag_key": ff_key,
|
||||
"parameters": None,
|
||||
"filters": {
|
||||
"events": [
|
||||
{"order": 0, "id": "$pageview"},
|
||||
{"order": 1, "id": "$pageleave"},
|
||||
],
|
||||
"properties": [],
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
|
||||
class TestExperimentAuxiliaryEndpoints(ClickhouseTestMixin, APILicensedTest):
|
||||
def _generate_experiment(self, start_date="2024-01-01T10:23", extra_parameters=None):
|
||||
|
@ -5,6 +5,7 @@ from langchain_core.messages import AIMessageChunk
|
||||
from langfuse.callback import CallbackHandler
|
||||
from langgraph.graph.state import StateGraph
|
||||
from pydantic import BaseModel
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
from ee import settings
|
||||
from ee.hogai.funnels.nodes import (
|
||||
@ -15,6 +16,7 @@ from ee.hogai.funnels.nodes import (
|
||||
)
|
||||
from ee.hogai.router.nodes import RouterNode
|
||||
from ee.hogai.schema_generator.nodes import SchemaGeneratorNode
|
||||
from ee.hogai.summarizer.nodes import SummarizerNode
|
||||
from ee.hogai.trends.nodes import (
|
||||
TrendsGeneratorNode,
|
||||
TrendsGeneratorToolsNode,
|
||||
@ -26,6 +28,8 @@ from posthog.models.team.team import Team
|
||||
from posthog.schema import (
|
||||
AssistantGenerationStatusEvent,
|
||||
AssistantGenerationStatusType,
|
||||
AssistantMessage,
|
||||
FailureMessage,
|
||||
VisualizationMessage,
|
||||
)
|
||||
|
||||
@ -123,7 +127,7 @@ class Assistant:
|
||||
generate_trends_node.router,
|
||||
path_map={
|
||||
"tools": AssistantNodeName.TRENDS_GENERATOR_TOOLS,
|
||||
"next": AssistantNodeName.END,
|
||||
"next": AssistantNodeName.SUMMARIZER,
|
||||
},
|
||||
)
|
||||
|
||||
@ -160,10 +164,14 @@ class Assistant:
|
||||
generate_trends_node.router,
|
||||
path_map={
|
||||
"tools": AssistantNodeName.FUNNEL_GENERATOR_TOOLS,
|
||||
"next": AssistantNodeName.END,
|
||||
"next": AssistantNodeName.SUMMARIZER,
|
||||
},
|
||||
)
|
||||
|
||||
summarizer_node = SummarizerNode(self._team)
|
||||
builder.add_node(AssistantNodeName.SUMMARIZER, summarizer_node.run)
|
||||
builder.add_edge(AssistantNodeName.SUMMARIZER, AssistantNodeName.END)
|
||||
|
||||
return builder.compile()
|
||||
|
||||
def stream(self, conversation: Conversation) -> Generator[BaseModel, None, None]:
|
||||
@ -185,33 +193,47 @@ class Assistant:
|
||||
# Send a chunk to establish the connection avoiding the worker's timeout.
|
||||
yield AssistantGenerationStatusEvent(type=AssistantGenerationStatusType.ACK)
|
||||
|
||||
for update in generator:
|
||||
if is_state_update(update):
|
||||
_, new_state = update
|
||||
state = new_state
|
||||
try:
|
||||
for update in generator:
|
||||
if is_state_update(update):
|
||||
_, new_state = update
|
||||
state = new_state
|
||||
|
||||
elif is_value_update(update):
|
||||
_, state_update = update
|
||||
elif is_value_update(update):
|
||||
_, state_update = update
|
||||
|
||||
if AssistantNodeName.ROUTER in state_update and "messages" in state_update[AssistantNodeName.ROUTER]:
|
||||
yield state_update[AssistantNodeName.ROUTER]["messages"][0]
|
||||
elif intersected_nodes := state_update.keys() & VISUALIZATION_NODES.keys():
|
||||
# Reset chunks when schema validation fails.
|
||||
chunks = AIMessageChunk(content="")
|
||||
if (
|
||||
AssistantNodeName.ROUTER in state_update
|
||||
and "messages" in state_update[AssistantNodeName.ROUTER]
|
||||
):
|
||||
yield state_update[AssistantNodeName.ROUTER]["messages"][0]
|
||||
elif intersected_nodes := state_update.keys() & VISUALIZATION_NODES.keys():
|
||||
# Reset chunks when schema validation fails.
|
||||
chunks = AIMessageChunk(content="")
|
||||
|
||||
node_name = intersected_nodes.pop()
|
||||
if "messages" in state_update[node_name]:
|
||||
yield state_update[node_name]["messages"][0]
|
||||
elif state_update[node_name].get("intermediate_steps", []):
|
||||
yield AssistantGenerationStatusEvent(type=AssistantGenerationStatusType.GENERATION_ERROR)
|
||||
|
||||
elif is_message_update(update):
|
||||
langchain_message, langgraph_state = update[1]
|
||||
for node_name, viz_node in VISUALIZATION_NODES.items():
|
||||
if langgraph_state["langgraph_node"] == node_name and isinstance(langchain_message, AIMessageChunk):
|
||||
chunks += langchain_message # type: ignore
|
||||
parsed_message = viz_node.parse_output(chunks.tool_calls[0]["args"])
|
||||
if parsed_message:
|
||||
yield VisualizationMessage(
|
||||
reasoning_steps=parsed_message.reasoning_steps, answer=parsed_message.answer
|
||||
node_name = intersected_nodes.pop()
|
||||
if "messages" in state_update[node_name]:
|
||||
yield state_update[node_name]["messages"][0]
|
||||
elif state_update[node_name].get("intermediate_steps", []):
|
||||
yield AssistantGenerationStatusEvent(type=AssistantGenerationStatusType.GENERATION_ERROR)
|
||||
elif AssistantNodeName.SUMMARIZER in state_update:
|
||||
chunks = AIMessageChunk(content="")
|
||||
yield state_update[AssistantNodeName.SUMMARIZER]["messages"][0]
|
||||
elif is_message_update(update):
|
||||
langchain_message, langgraph_state = update[1]
|
||||
if isinstance(langchain_message, AIMessageChunk):
|
||||
if langgraph_state["langgraph_node"] in VISUALIZATION_NODES.keys():
|
||||
chunks += langchain_message # type: ignore
|
||||
parsed_message = VISUALIZATION_NODES[langgraph_state["langgraph_node"]].parse_output(
|
||||
chunks.tool_calls[0]["args"]
|
||||
)
|
||||
if parsed_message:
|
||||
yield VisualizationMessage(
|
||||
reasoning_steps=parsed_message.reasoning_steps, answer=parsed_message.answer
|
||||
)
|
||||
elif langgraph_state["langgraph_node"] == AssistantNodeName.SUMMARIZER:
|
||||
chunks += langchain_message # type: ignore
|
||||
yield AssistantMessage(content=chunks.content)
|
||||
except Exception as e:
|
||||
capture_exception(e)
|
||||
yield FailureMessage() # This is an unhandled error, so we just stop further generation at this point
|
||||
|
@ -33,7 +33,9 @@ class TestFunnelsGeneratorNode(ClickhouseTestMixin, APIBaseTest):
|
||||
self.assertEqual(
|
||||
new_state,
|
||||
{
|
||||
"messages": [VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"])],
|
||||
"messages": [
|
||||
VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"], done=True)
|
||||
],
|
||||
"intermediate_steps": None,
|
||||
},
|
||||
)
|
||||
|
@ -101,6 +101,7 @@ class SchemaGeneratorNode(AssistantNode, Generic[Q]):
|
||||
plan=generated_plan,
|
||||
reasoning_steps=message.reasoning_steps,
|
||||
answer=message.answer,
|
||||
done=True,
|
||||
)
|
||||
],
|
||||
"intermediate_steps": None,
|
||||
|
@ -54,7 +54,9 @@ class TestSchemaGeneratorNode(ClickhouseTestMixin, APIBaseTest):
|
||||
self.assertEqual(
|
||||
new_state,
|
||||
{
|
||||
"messages": [VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"])],
|
||||
"messages": [
|
||||
VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"], done=True)
|
||||
],
|
||||
"intermediate_steps": None,
|
||||
},
|
||||
)
|
||||
|
0
ee/hogai/summarizer/__init__.py
Normal file
95
ee/hogai/summarizer/nodes.py
Normal file
@ -0,0 +1,95 @@
|
||||
import json
|
||||
from time import sleep
|
||||
from django.conf import settings
|
||||
from langchain_core.prompts import ChatPromptTemplate
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langchain_openai import ChatOpenAI
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from rest_framework.exceptions import APIException
|
||||
from sentry_sdk import capture_exception
|
||||
|
||||
from ee.hogai.summarizer.prompts import SUMMARIZER_SYSTEM_PROMPT, SUMMARIZER_INSTRUCTION_PROMPT
|
||||
from ee.hogai.utils import AssistantNode, AssistantNodeName, AssistantState
|
||||
from posthog.api.services.query import process_query_dict
|
||||
from posthog.clickhouse.client.execute_async import get_query_status
|
||||
from posthog.errors import ExposedCHQueryError
|
||||
from posthog.hogql.errors import ExposedHogQLError
|
||||
from posthog.hogql_queries.query_runner import ExecutionMode
|
||||
from posthog.schema import AssistantMessage, FailureMessage, HumanMessage, VisualizationMessage
|
||||
|
||||
|
||||
class SummarizerNode(AssistantNode):
|
||||
name = AssistantNodeName.SUMMARIZER
|
||||
|
||||
def run(self, state: AssistantState, config: RunnableConfig):
|
||||
viz_message = state["messages"][-1]
|
||||
if not isinstance(viz_message, VisualizationMessage):
|
||||
raise ValueError("Can only run summarization with a visualization message as the last one in the state")
|
||||
if viz_message.answer is None:
|
||||
raise ValueError("Did not found query in the visualization message")
|
||||
|
||||
try:
|
||||
results_response = process_query_dict( # type: ignore
|
||||
self._team, # TODO: Add user
|
||||
viz_message.answer.model_dump(mode="json"), # We need mode="json" so that
|
||||
# Celery doesn't run in tests, so there we use force_blocking instead
|
||||
# This does mean that the waiting logic is not tested
|
||||
execution_mode=ExecutionMode.RECENT_CACHE_CALCULATE_ASYNC_IF_STALE
|
||||
if not settings.TEST
|
||||
else ExecutionMode.CALCULATE_BLOCKING_ALWAYS,
|
||||
).model_dump(mode="json")
|
||||
if results_response.get("query_status") and not results_response["query_status"]["complete"]:
|
||||
query_id = results_response["query_status"]["id"]
|
||||
for i in range(0, 999):
|
||||
sleep(i / 2) # We start at 0.5s and every iteration we wait 0.5s more
|
||||
query_status = get_query_status(team_id=self._team.pk, query_id=query_id)
|
||||
if query_status.error:
|
||||
if query_status.error_message:
|
||||
raise APIException(query_status.error_message)
|
||||
else:
|
||||
raise ValueError("Query failed")
|
||||
if query_status.complete:
|
||||
results_response = query_status.results
|
||||
break
|
||||
except (APIException, ExposedHogQLError, ExposedCHQueryError) as err:
|
||||
err_message = str(err)
|
||||
if isinstance(err, APIException):
|
||||
if isinstance(err.detail, dict):
|
||||
err_message = ", ".join(f"{key}: {value}" for key, value in err.detail.items())
|
||||
elif isinstance(err.detail, list):
|
||||
err_message = ", ".join(map(str, err.detail))
|
||||
return {"messages": [FailureMessage(content=f"There was an error running this query: {err_message}")]}
|
||||
except Exception as err:
|
||||
capture_exception(err)
|
||||
return {"messages": [FailureMessage(content="There was an unknown error running this query.")]}
|
||||
|
||||
summarization_prompt = ChatPromptTemplate(self._construct_messages(state), template_format="mustache")
|
||||
|
||||
chain = summarization_prompt | self._model
|
||||
|
||||
message = chain.invoke(
|
||||
{
|
||||
"query_kind": viz_message.answer.kind,
|
||||
"product_description": self._team.project.product_description,
|
||||
"results": json.dumps(results_response["results"], cls=DjangoJSONEncoder),
|
||||
},
|
||||
config,
|
||||
)
|
||||
|
||||
return {"messages": [AssistantMessage(content=str(message.content), done=True)]}
|
||||
|
||||
@property
|
||||
def _model(self):
|
||||
return ChatOpenAI(model="gpt-4o", temperature=0.5, streaming=True) # Slightly higher temp than earlier steps
|
||||
|
||||
def _construct_messages(self, state: AssistantState) -> list[tuple[str, str]]:
|
||||
conversation: list[tuple[str, str]] = [("system", SUMMARIZER_SYSTEM_PROMPT)]
|
||||
|
||||
for message in state.get("messages", []):
|
||||
if isinstance(message, HumanMessage):
|
||||
conversation.append(("human", message.content))
|
||||
elif isinstance(message, AssistantMessage):
|
||||
conversation.append(("assistant", message.content))
|
||||
|
||||
conversation.append(("human", SUMMARIZER_INSTRUCTION_PROMPT))
|
||||
return conversation
|
17
ee/hogai/summarizer/prompts.py
Normal file
@ -0,0 +1,17 @@
|
||||
SUMMARIZER_SYSTEM_PROMPT = """
|
||||
Act as an expert product manager. Your task is to summarize query results in a a concise way.
|
||||
Offer actionable feedback if possible. Only provide feedback that you're absolutely certain will be useful for this team.
|
||||
|
||||
The product being analyzed is described as follows:
|
||||
{{product_description}}"""
|
||||
|
||||
SUMMARIZER_INSTRUCTION_PROMPT = """
|
||||
Here are the {{query_kind}} results for this question:
|
||||
```json
|
||||
{{results}}
|
||||
```
|
||||
|
||||
Answer my earlier question using the results above. Point out interesting trends or anomalies.
|
||||
Take into account what you know about my product. If possible, offer actionable feedback, but avoid generic advice.
|
||||
Limit yourself to a few sentences. The answer needs to be high-impact and relevant for me as a Silicon Valley engineer.
|
||||
"""
|
0
ee/hogai/summarizer/test/__init__.py
Normal file
196
ee/hogai/summarizer/test/test_nodes.py
Normal file
@ -0,0 +1,196 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import override_settings
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
from langchain_core.messages import (
|
||||
HumanMessage as LangchainHumanMessage,
|
||||
)
|
||||
from ee.hogai.summarizer.nodes import SummarizerNode
|
||||
from ee.hogai.summarizer.prompts import SUMMARIZER_INSTRUCTION_PROMPT, SUMMARIZER_SYSTEM_PROMPT
|
||||
from posthog.schema import (
|
||||
AssistantMessage,
|
||||
AssistantTrendsEventsNode,
|
||||
AssistantTrendsQuery,
|
||||
FailureMessage,
|
||||
HumanMessage,
|
||||
VisualizationMessage,
|
||||
)
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from posthog.test.base import APIBaseTest, ClickhouseTestMixin
|
||||
from posthog.api.services.query import process_query_dict
|
||||
|
||||
|
||||
@override_settings(IN_UNIT_TESTING=True)
|
||||
class TestSummarizerNode(ClickhouseTestMixin, APIBaseTest):
|
||||
maxDiff = None
|
||||
|
||||
@patch("ee.hogai.summarizer.nodes.process_query_dict", side_effect=process_query_dict)
|
||||
def test_node_runs(self, mock_process_query_dict):
|
||||
node = SummarizerNode(self.team)
|
||||
with patch.object(SummarizerNode, "_model") as generator_model_mock:
|
||||
generator_model_mock.return_value = RunnableLambda(
|
||||
lambda _: LangchainHumanMessage(content="The results indicate foobar.")
|
||||
)
|
||||
new_state = node.run(
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="Text"),
|
||||
VisualizationMessage(
|
||||
answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]),
|
||||
plan="Plan",
|
||||
reasoning_steps=["step"],
|
||||
done=True,
|
||||
),
|
||||
],
|
||||
"plan": "Plan",
|
||||
},
|
||||
{},
|
||||
)
|
||||
mock_process_query_dict.assert_called_once() # Query processing started
|
||||
self.assertEqual(
|
||||
new_state,
|
||||
{
|
||||
"messages": [
|
||||
AssistantMessage(content="The results indicate foobar.", done=True),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@patch(
|
||||
"ee.hogai.summarizer.nodes.process_query_dict",
|
||||
side_effect=ValueError("You have not glibbled the glorp before running this."),
|
||||
)
|
||||
def test_node_handles_internal_error(self, mock_process_query_dict):
|
||||
node = SummarizerNode(self.team)
|
||||
with patch.object(SummarizerNode, "_model") as generator_model_mock:
|
||||
generator_model_mock.return_value = RunnableLambda(
|
||||
lambda _: LangchainHumanMessage(content="The results indicate foobar.")
|
||||
)
|
||||
new_state = node.run(
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="Text"),
|
||||
VisualizationMessage(
|
||||
answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]),
|
||||
plan="Plan",
|
||||
reasoning_steps=["step"],
|
||||
done=True,
|
||||
),
|
||||
],
|
||||
"plan": "Plan",
|
||||
},
|
||||
{},
|
||||
)
|
||||
mock_process_query_dict.assert_called_once() # Query processing started
|
||||
self.assertEqual(
|
||||
new_state,
|
||||
{
|
||||
"messages": [
|
||||
FailureMessage(content="There was an unknown error running this query."),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@patch(
|
||||
"ee.hogai.summarizer.nodes.process_query_dict",
|
||||
side_effect=ValidationError(
|
||||
"This query exceeds the capabilities of our picolator. Try de-brolling its flim-flam."
|
||||
),
|
||||
)
|
||||
def test_node_handles_exposed_error(self, mock_process_query_dict):
|
||||
node = SummarizerNode(self.team)
|
||||
with patch.object(SummarizerNode, "_model") as generator_model_mock:
|
||||
generator_model_mock.return_value = RunnableLambda(
|
||||
lambda _: LangchainHumanMessage(content="The results indicate foobar.")
|
||||
)
|
||||
new_state = node.run(
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="Text"),
|
||||
VisualizationMessage(
|
||||
answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]),
|
||||
plan="Plan",
|
||||
reasoning_steps=["step"],
|
||||
done=True,
|
||||
),
|
||||
],
|
||||
"plan": "Plan",
|
||||
},
|
||||
{},
|
||||
)
|
||||
mock_process_query_dict.assert_called_once() # Query processing started
|
||||
self.assertEqual(
|
||||
new_state,
|
||||
{
|
||||
"messages": [
|
||||
FailureMessage(
|
||||
content=(
|
||||
"There was an error running this query: This query exceeds the capabilities of our picolator. "
|
||||
"Try de-brolling its flim-flam."
|
||||
)
|
||||
),
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
def test_node_requires_a_viz_message_in_state(self):
|
||||
node = SummarizerNode(self.team)
|
||||
|
||||
with self.assertRaisesMessage(
|
||||
ValueError, "Can only run summarization with a visualization message as the last one in the state"
|
||||
):
|
||||
node.run(
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="Text"),
|
||||
],
|
||||
"plan": "Plan",
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
def test_node_requires_viz_message_in_state_to_have_query(self):
|
||||
node = SummarizerNode(self.team)
|
||||
|
||||
with self.assertRaisesMessage(ValueError, "Did not found query in the visualization message"):
|
||||
node.run(
|
||||
{
|
||||
"messages": [
|
||||
VisualizationMessage(
|
||||
answer=None,
|
||||
plan="Plan",
|
||||
reasoning_steps=["step"],
|
||||
done=True,
|
||||
),
|
||||
],
|
||||
"plan": "Plan",
|
||||
},
|
||||
{},
|
||||
)
|
||||
|
||||
def test_agent_reconstructs_conversation(self):
|
||||
self.project.product_description = "Dating app for lonely hedgehogs."
|
||||
self.project.save()
|
||||
node = SummarizerNode(self.team)
|
||||
|
||||
history = node._construct_messages(
|
||||
{
|
||||
"messages": [
|
||||
HumanMessage(content="What's the trends in signups?"),
|
||||
VisualizationMessage(
|
||||
answer=AssistantTrendsQuery(series=[AssistantTrendsEventsNode()]),
|
||||
plan="Plan",
|
||||
reasoning_steps=["step"],
|
||||
done=True,
|
||||
),
|
||||
]
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
history,
|
||||
[
|
||||
("system", SUMMARIZER_SYSTEM_PROMPT),
|
||||
("human", "What's the trends in signups?"),
|
||||
("human", SUMMARIZER_INSTRUCTION_PROMPT),
|
||||
],
|
||||
)
|
@ -75,10 +75,8 @@ class TaxonomyAgentPlannerNode(AssistantNode):
|
||||
AgentAction,
|
||||
agent.invoke(
|
||||
{
|
||||
"react_format": REACT_FORMAT_PROMPT,
|
||||
"react_format": self._get_react_format_prompt(toolkit),
|
||||
"react_format_reminder": REACT_FORMAT_REMINDER_PROMPT,
|
||||
"tools": toolkit.render_text_description(),
|
||||
"tool_names": ", ".join([t["name"] for t in toolkit.tools]),
|
||||
"product_description": self._team.project.product_description,
|
||||
"groups": self._team_group_types,
|
||||
"events": self._events_prompt,
|
||||
@ -121,6 +119,17 @@ class TaxonomyAgentPlannerNode(AssistantNode):
|
||||
def _model(self) -> ChatOpenAI:
|
||||
return ChatOpenAI(model="gpt-4o", temperature=0.2, streaming=True)
|
||||
|
||||
def _get_react_format_prompt(self, toolkit: TaxonomyAgentToolkit) -> str:
|
||||
return cast(
|
||||
str,
|
||||
ChatPromptTemplate.from_template(REACT_FORMAT_PROMPT, template_format="mustache")
|
||||
.format_messages(
|
||||
tools=toolkit.render_text_description(),
|
||||
tool_names=", ".join([t["name"] for t in toolkit.tools]),
|
||||
)[0]
|
||||
.content,
|
||||
)
|
||||
|
||||
@cached_property
|
||||
def _events_prompt(self) -> str:
|
||||
response = TeamTaxonomyQueryRunner(TeamTaxonomyQuery(), self._team).run(
|
||||
|
@ -22,7 +22,7 @@ from posthog.schema import (
|
||||
from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_event, _create_person
|
||||
|
||||
|
||||
class TestToolkit(TaxonomyAgentToolkit):
|
||||
class DummyToolkit(TaxonomyAgentToolkit):
|
||||
def _get_tools(self) -> list[ToolkitTool]:
|
||||
return self._default_tools
|
||||
|
||||
@ -36,8 +36,8 @@ class TestTaxonomyAgentPlannerNode(ClickhouseTestMixin, APIBaseTest):
|
||||
def _get_node(self):
|
||||
class Node(TaxonomyAgentPlannerNode):
|
||||
def run(self, state: AssistantState, config: RunnableConfig) -> AssistantState:
|
||||
prompt = ChatPromptTemplate.from_messages([("user", "test")])
|
||||
toolkit = TestToolkit(self._team)
|
||||
prompt: ChatPromptTemplate = ChatPromptTemplate.from_messages([("user", "test")])
|
||||
toolkit = DummyToolkit(self._team)
|
||||
return super()._run_with_prompt_and_toolkit(state, prompt, toolkit, config=config)
|
||||
|
||||
return Node(self.team)
|
||||
@ -180,13 +180,21 @@ class TestTaxonomyAgentPlannerNode(ClickhouseTestMixin, APIBaseTest):
|
||||
node._events_prompt,
|
||||
)
|
||||
|
||||
def test_format_prompt(self):
|
||||
node = self._get_node()
|
||||
self.assertNotIn("Human:", node._get_react_format_prompt(DummyToolkit(self.team)))
|
||||
self.assertIn("retrieve_event_properties,", node._get_react_format_prompt(DummyToolkit(self.team)))
|
||||
self.assertIn(
|
||||
"retrieve_event_properties(event_name: str)", node._get_react_format_prompt(DummyToolkit(self.team))
|
||||
)
|
||||
|
||||
|
||||
@override_settings(IN_UNIT_TESTING=True)
|
||||
class TestTaxonomyAgentPlannerToolsNode(ClickhouseTestMixin, APIBaseTest):
|
||||
def _get_node(self):
|
||||
class Node(TaxonomyAgentPlannerToolsNode):
|
||||
def run(self, state: AssistantState, config: RunnableConfig) -> AssistantState:
|
||||
toolkit = TestToolkit(self._team)
|
||||
toolkit = DummyToolkit(self._team)
|
||||
return super()._run_with_toolkit(state, toolkit, config=config)
|
||||
|
||||
return Node(self.team)
|
||||
|
@ -14,6 +14,8 @@ from posthog.test.base import APIBaseTest, ClickhouseTestMixin
|
||||
|
||||
@override_settings(IN_UNIT_TESTING=True)
|
||||
class TestTrendsGeneratorNode(ClickhouseTestMixin, APIBaseTest):
|
||||
maxDiff = None
|
||||
|
||||
def setUp(self):
|
||||
self.schema = AssistantTrendsQuery(series=[])
|
||||
|
||||
@ -33,7 +35,9 @@ class TestTrendsGeneratorNode(ClickhouseTestMixin, APIBaseTest):
|
||||
self.assertEqual(
|
||||
new_state,
|
||||
{
|
||||
"messages": [VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"])],
|
||||
"messages": [
|
||||
VisualizationMessage(answer=self.schema, plan="Plan", reasoning_steps=["step"], done=True)
|
||||
],
|
||||
"intermediate_steps": None,
|
||||
},
|
||||
)
|
||||
|
@ -50,6 +50,7 @@ class AssistantNodeName(StrEnum):
|
||||
FUNNEL_PLANNER_TOOLS = "funnel_planner_tools"
|
||||
FUNNEL_GENERATOR = "funnel_generator"
|
||||
FUNNEL_GENERATOR_TOOLS = "funnel_generator_tools"
|
||||
SUMMARIZER = "summarizer"
|
||||
|
||||
|
||||
class AssistantNode(ABC):
|
||||
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 31 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 28 KiB |
After Width: | Height: | Size: 20 KiB |
After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 102 KiB |
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 101 KiB |
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 159 KiB |
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 55 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 71 KiB |
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 69 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/services/brevo.png
Normal file
After Width: | Height: | Size: 24 KiB |
@ -719,6 +719,10 @@ class ApiRequest {
|
||||
return this.errorTracking().addPathComponent('upload_source_maps')
|
||||
}
|
||||
|
||||
public errorTrackingStackFrames(ids: string[]): ApiRequest {
|
||||
return this.errorTracking().addPathComponent('stack_frames').withQueryString({ ids })
|
||||
}
|
||||
|
||||
// # Warehouse
|
||||
public dataWarehouseTables(teamId?: TeamType['id']): ApiRequest {
|
||||
return this.projectsDetail(teamId).addPathComponent('warehouse_tables')
|
||||
@ -1857,6 +1861,10 @@ const api = {
|
||||
async uploadSourceMaps(data: FormData): Promise<{ content: string }> {
|
||||
return await new ApiRequest().errorTrackingUploadSourceMaps().create({ data })
|
||||
},
|
||||
|
||||
async fetchStackFrames(ids: string[]): Promise<{ content: string }> {
|
||||
return await new ApiRequest().errorTrackingStackFrames(ids).get()
|
||||
},
|
||||
},
|
||||
|
||||
recordings: {
|
||||
|
6
frontend/src/lib/components/Errors/ErrorDisplay.scss
Normal file
@ -0,0 +1,6 @@
|
||||
.ErrorDisplay__stacktrace {
|
||||
.LemonCollapsePanel__header {
|
||||
min-height: 2.375rem !important;
|
||||
padding: 0.25rem !important;
|
||||
}
|
||||
}
|
@ -1,71 +1,67 @@
|
||||
import './ErrorDisplay.scss'
|
||||
|
||||
import { IconFlag } from '@posthog/icons'
|
||||
import { LemonCollapse } from '@posthog/lemon-ui'
|
||||
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 posthog from 'posthog-js'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { EventType } from '~/types'
|
||||
|
||||
interface StackFrame {
|
||||
filename: string
|
||||
lineno: number
|
||||
colno: number
|
||||
function: string
|
||||
context_line?: string
|
||||
in_app?: boolean
|
||||
import { StackFrame } from './stackFrameLogic'
|
||||
|
||||
interface RawStackTrace {
|
||||
type: 'raw'
|
||||
frames: StackFrame[]
|
||||
}
|
||||
interface ResolvedStackTrace {
|
||||
type: 'resolved'
|
||||
frames: StackFrame[]
|
||||
}
|
||||
|
||||
interface ExceptionTrace {
|
||||
stacktrace: {
|
||||
frames: StackFrame[]
|
||||
}
|
||||
interface Exception {
|
||||
stacktrace: ResolvedStackTrace | RawStackTrace
|
||||
module: string
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
function parseToFrames(rawTrace: string): StackFrame[] {
|
||||
return JSON.parse(rawTrace)
|
||||
function StackTrace({ frames, showAllFrames }: { frames: StackFrame[]; showAllFrames: boolean }): JSX.Element | null {
|
||||
const displayFrames = showAllFrames ? frames : frames.filter((f) => f.in_app)
|
||||
|
||||
const panels = displayFrames.map(({ filename, lineno, colno, function: functionName }, index) => {
|
||||
return {
|
||||
key: index,
|
||||
header: (
|
||||
<div className="flex flex-wrap space-x-0.5">
|
||||
<span>{filename}</span>
|
||||
{functionName ? (
|
||||
<div className="flex space-x-0.5">
|
||||
<span className="text-muted">in</span>
|
||||
<span>{functionName}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{lineno && colno ? (
|
||||
<div className="flex space-x-0.5">
|
||||
<span className="text-muted">at line</span>
|
||||
<span>
|
||||
{lineno}:{colno}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
content: null,
|
||||
}
|
||||
})
|
||||
|
||||
return <LemonCollapse defaultActiveKeys={[]} multiple panels={panels} size="xsmall" />
|
||||
}
|
||||
|
||||
function StackTrace({ rawTrace, showAllFrames }: { rawTrace: string; showAllFrames: boolean }): JSX.Element | null {
|
||||
try {
|
||||
const frames = parseToFrames(rawTrace)
|
||||
return (
|
||||
<>
|
||||
{frames.length ? (
|
||||
frames.map((frame, index) => {
|
||||
const { filename, lineno, colno, function: functionName, context_line, in_app } = frame
|
||||
|
||||
return showAllFrames || in_app ? (
|
||||
<TitledSnack
|
||||
key={index}
|
||||
title={functionName}
|
||||
value={
|
||||
<>
|
||||
{filename}:{lineno}:{colno}
|
||||
{context_line ? `:${context_line}` : ''}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
})
|
||||
) : (
|
||||
<LemonTag>Empty stack trace</LemonTag>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
} catch (e: any) {
|
||||
//very meta
|
||||
posthog.capture('Cannot parse stack trace in Exception event', { tag: 'error-display-stack-trace', e })
|
||||
return <LemonTag type="caution">Error parsing stack trace</LemonTag>
|
||||
}
|
||||
}
|
||||
|
||||
function ChainedStackTraces({ exceptionList }: { exceptionList: ExceptionTrace[] }): JSX.Element {
|
||||
function ChainedStackTraces({ exceptionList }: { exceptionList: Exception[] }): JSX.Element {
|
||||
const [showAllFrames, setShowAllFrames] = useState(false)
|
||||
|
||||
return (
|
||||
@ -89,9 +85,9 @@ function ChainedStackTraces({ exceptionList }: { exceptionList: ExceptionTrace[]
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={index} className="flex flex-col gap-1 mt-6">
|
||||
<div key={index} className="ErrorDisplay__stacktrace flex flex-col gap-1 mt-6">
|
||||
<h3 className="mb-0">{value}</h3>
|
||||
<StackTrace rawTrace={JSON.stringify(frames || [])} showAllFrames={showAllFrames} />
|
||||
<StackTrace frames={frames || []} showAllFrames={showAllFrames} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
30
frontend/src/lib/components/Errors/stackFrameLogic.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { kea, path } from 'kea'
|
||||
import { loaders } from 'kea-loaders'
|
||||
import api from 'lib/api'
|
||||
|
||||
import type { stackFrameLogicType } from './stackFrameLogicType'
|
||||
|
||||
export interface StackFrame {
|
||||
filename: string
|
||||
lineno: number
|
||||
colno: number
|
||||
function: string
|
||||
in_app?: boolean
|
||||
}
|
||||
|
||||
export const stackFrameLogic = kea<stackFrameLogicType>([
|
||||
path(['components', 'Errors', 'stackFrameLogic']),
|
||||
loaders(({ values }) => ({
|
||||
stackFrames: [
|
||||
{} as Record<string, StackFrame>,
|
||||
{
|
||||
loadFrames: async ({ frameIds }: { frameIds: string[] }) => {
|
||||
const loadedFrameIds = Object.keys(values.stackFrames)
|
||||
const ids = frameIds.filter((id) => loadedFrameIds.includes(id))
|
||||
await api.errorTracking.fetchStackFrames(ids)
|
||||
return {}
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
])
|
@ -169,6 +169,7 @@ export const FEATURE_FLAGS = {
|
||||
SURVEYS_EVENTS: 'surveys-events', // owner: #team-feature-success
|
||||
SURVEYS_ACTIONS: 'surveys-actions', // owner: #team-feature-success
|
||||
SURVEYS_RECURRING: 'surveys-recurring', // owner: #team-feature-success
|
||||
SURVEYS_ADAPTIVE_COLLECTION: 'surveys-recurring', // owner: #team-feature-success
|
||||
YEAR_IN_HOG: 'year-in-hog', // owner: #team-replay
|
||||
SESSION_REPLAY_EXPORT_MOBILE_DATA: 'session-replay-export-mobile-data', // owner: #team-replay
|
||||
DISCUSSIONS: 'discussions', // owner: #team-replay
|
||||
|
@ -1,5 +1,5 @@
|
||||
import api from 'lib/api'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { MediaUploadResponse } from '~/types'
|
||||
|
||||
@ -47,14 +47,17 @@ export function useUploadFiles({
|
||||
} {
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [filesToUpload, setFilesToUpload] = useState<File[]>([])
|
||||
const uploadInProgressRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const uploadFiles = async (): Promise<void> => {
|
||||
if (filesToUpload.length === 0) {
|
||||
if (filesToUpload.length === 0 || uploadInProgressRef.current) {
|
||||
setUploading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
uploadInProgressRef.current = true
|
||||
setUploading(true)
|
||||
const file: File = filesToUpload[0]
|
||||
const media = await uploadFile(file)
|
||||
@ -63,6 +66,7 @@ export function useUploadFiles({
|
||||
const errorDetail = (error as any).detail || 'unknown error'
|
||||
onError(errorDetail)
|
||||
} finally {
|
||||
uploadInProgressRef.current = false
|
||||
setUploading(false)
|
||||
setFilesToUpload([])
|
||||
}
|
||||
|
@ -32,6 +32,11 @@
|
||||
&.LemonButton:active {
|
||||
transform: inherit;
|
||||
}
|
||||
|
||||
&--disabled:hover {
|
||||
cursor: default;
|
||||
background-color: var(--bg-light) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.LemonCollapsePanel__body {
|
||||
|
@ -116,18 +116,28 @@ function LemonCollapsePanel({
|
||||
|
||||
return (
|
||||
<div className="LemonCollapsePanel" aria-expanded={isExpanded}>
|
||||
<LemonButton
|
||||
onClick={() => {
|
||||
onHeaderClick && onHeaderClick()
|
||||
onChange(!isExpanded)
|
||||
}}
|
||||
icon={isExpanded ? <IconCollapse /> : <IconExpand />}
|
||||
className="LemonCollapsePanel__header"
|
||||
{...(dataAttr ? { 'data-attr': dataAttr } : {})}
|
||||
size={size}
|
||||
>
|
||||
{header}
|
||||
</LemonButton>
|
||||
{content ? (
|
||||
<LemonButton
|
||||
onClick={() => {
|
||||
onHeaderClick && onHeaderClick()
|
||||
onChange(!isExpanded)
|
||||
}}
|
||||
icon={isExpanded ? <IconCollapse /> : <IconExpand />}
|
||||
className="LemonCollapsePanel__header"
|
||||
{...(dataAttr ? { 'data-attr': dataAttr } : {})}
|
||||
size={size}
|
||||
>
|
||||
{header}
|
||||
</LemonButton>
|
||||
) : (
|
||||
<LemonButton
|
||||
className="LemonCollapsePanel__header LemonCollapsePanel__header--disabled"
|
||||
{...(dataAttr ? { 'data-attr': dataAttr } : {})}
|
||||
size={size}
|
||||
>
|
||||
{header}
|
||||
</LemonButton>
|
||||
)}
|
||||
<Transition in={isExpanded} timeout={200} mountOnEnter unmountOnExit>
|
||||
{(status) => (
|
||||
<div
|
||||
|
@ -52,9 +52,18 @@ export const OBJECTS = {
|
||||
'IconGearFilled',
|
||||
'IconStack',
|
||||
'IconSparkles',
|
||||
'IconPlug',
|
||||
'IconPuzzle',
|
||||
],
|
||||
People: ['IconPeople', 'IconPeopleFilled', 'IconPerson', 'IconProfile', 'IconUser', 'IconGroups'],
|
||||
People: [
|
||||
'IconPeople',
|
||||
'IconPeopleFilled',
|
||||
'IconPerson',
|
||||
'IconProfile',
|
||||
'IconUser',
|
||||
'IconGroups',
|
||||
'IconShieldPeople',
|
||||
],
|
||||
'Business & Finance': ['IconStore', 'IconCart', 'IconReceipt', 'IconPiggyBank', 'IconHandMoney'],
|
||||
Time: ['IconHourglass', 'IconCalendar', 'IconClock'],
|
||||
Nature: ['IconDay', 'IconNight', 'IconGlobe', 'IconCloud', 'IconBug'],
|
||||
@ -183,6 +192,7 @@ export const TEAMS_AND_COMPANIES = {
|
||||
'IconPageChart',
|
||||
'IconSampling',
|
||||
'IconLive',
|
||||
'IconRefresh',
|
||||
'IconBadge',
|
||||
],
|
||||
Replay: [
|
||||
|
@ -290,7 +290,7 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element {
|
||||
hasErrors
|
||||
? error ?? 'Query has errors'
|
||||
: !isValidView
|
||||
? 'All fields must have an alias'
|
||||
? 'Some fields may need an alias'
|
||||
: ''
|
||||
}
|
||||
data-attr="hogql-query-editor-update-view"
|
||||
@ -307,7 +307,7 @@ export function HogQLQueryEditor(props: HogQLQueryEditorProps): JSX.Element {
|
||||
hasErrors
|
||||
? error ?? 'Query has errors'
|
||||
: !isValidView
|
||||
? 'All fields must have an alias'
|
||||
? 'Some fields may need an alias'
|
||||
: ''
|
||||
}
|
||||
data-attr="hogql-query-editor-save-as-view"
|
||||
|
@ -1089,6 +1089,10 @@
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"done": {
|
||||
"description": "We only need this \"done\" value to tell when the particular message is finished during its streaming. It won't be necessary when we optimize streaming to NOT send the entire message every time a character is added.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"const": "ai",
|
||||
"type": "string"
|
||||
@ -6089,11 +6093,14 @@
|
||||
"$ref": "#/definitions/HogQLQueryModifiers",
|
||||
"description": "Modifiers used when performing the query"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"response": {
|
||||
"$ref": "#/definitions/ExperimentFunnelsQueryResponse"
|
||||
}
|
||||
},
|
||||
"required": ["experiment_id", "funnels_query", "kind"],
|
||||
"required": ["funnels_query", "kind"],
|
||||
"type": "object"
|
||||
},
|
||||
"ExperimentFunnelsQueryResponse": {
|
||||
@ -6184,11 +6191,14 @@
|
||||
"$ref": "#/definitions/HogQLQueryModifiers",
|
||||
"description": "Modifiers used when performing the query"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"response": {
|
||||
"$ref": "#/definitions/ExperimentTrendsQueryResponse"
|
||||
}
|
||||
},
|
||||
"required": ["count_query", "experiment_id", "kind"],
|
||||
"required": ["count_query", "kind"],
|
||||
"type": "object"
|
||||
},
|
||||
"ExperimentTrendsQueryResponse": {
|
||||
@ -6296,12 +6306,16 @@
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"done": {
|
||||
"const": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"const": "ai/failure",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type"],
|
||||
"required": ["type", "done"],
|
||||
"type": "object"
|
||||
},
|
||||
"FeaturePropertyFilter": {
|
||||
@ -7618,12 +7632,17 @@
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"done": {
|
||||
"const": true,
|
||||
"description": "Human messages are only appended when done.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"const": "human",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "content"],
|
||||
"required": ["type", "content", "done"],
|
||||
"type": "object"
|
||||
},
|
||||
"InsightActorsQuery": {
|
||||
@ -11687,12 +11706,17 @@
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"done": {
|
||||
"const": true,
|
||||
"description": "Router messages are not streamed, so they can only be done.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"const": "ai/router",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["type", "content"],
|
||||
"required": ["type", "content", "done"],
|
||||
"type": "object"
|
||||
},
|
||||
"SamplingRate": {
|
||||
@ -12837,6 +12861,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"done": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"plan": {
|
||||
"type": "string"
|
||||
},
|
||||
|
@ -2020,17 +2020,19 @@ export type CachedExperimentFunnelsQueryResponse = CachedQueryResponse<Experimen
|
||||
|
||||
export interface ExperimentFunnelsQuery extends DataNode<ExperimentFunnelsQueryResponse> {
|
||||
kind: NodeKind.ExperimentFunnelsQuery
|
||||
name?: string
|
||||
experiment_id?: integer
|
||||
funnels_query: FunnelsQuery
|
||||
experiment_id: integer
|
||||
}
|
||||
|
||||
export interface ExperimentTrendsQuery extends DataNode<ExperimentTrendsQueryResponse> {
|
||||
kind: NodeKind.ExperimentTrendsQuery
|
||||
name?: string
|
||||
experiment_id?: integer
|
||||
count_query: TrendsQuery
|
||||
// Defaults to $feature_flag_called if not specified
|
||||
// https://github.com/PostHog/posthog/blob/master/posthog/hogql_queries/experiments/experiment_trends_query_runner.py
|
||||
exposure_query?: TrendsQuery
|
||||
experiment_id: integer
|
||||
}
|
||||
|
||||
/**
|
||||
@ -2478,11 +2480,18 @@ export enum AssistantMessageType {
|
||||
export interface HumanMessage {
|
||||
type: AssistantMessageType.Human
|
||||
content: string
|
||||
/** Human messages are only appended when done. */
|
||||
done: true
|
||||
}
|
||||
|
||||
export interface AssistantMessage {
|
||||
type: AssistantMessageType.Assistant
|
||||
content: string
|
||||
/**
|
||||
* We only need this "done" value to tell when the particular message is finished during its streaming.
|
||||
* It won't be necessary when we optimize streaming to NOT send the entire message every time a character is added.
|
||||
*/
|
||||
done?: boolean
|
||||
}
|
||||
|
||||
export interface VisualizationMessage {
|
||||
@ -2490,16 +2499,20 @@ export interface VisualizationMessage {
|
||||
plan?: string
|
||||
reasoning_steps?: string[] | null
|
||||
answer?: AssistantTrendsQuery | AssistantFunnelsQuery
|
||||
done?: boolean
|
||||
}
|
||||
|
||||
export interface FailureMessage {
|
||||
type: AssistantMessageType.Failure
|
||||
content?: string
|
||||
done: true
|
||||
}
|
||||
|
||||
export interface RouterMessage {
|
||||
type: AssistantMessageType.Router
|
||||
content: string
|
||||
/** Router messages are not streamed, so they can only be done. */
|
||||
done: true
|
||||
}
|
||||
|
||||
export type RootAssistantMessage =
|
||||
|
@ -67,7 +67,7 @@ export const signupLogic = kea<signupLogicType>([
|
||||
password: !values.preflight?.demo
|
||||
? !password
|
||||
? 'Please enter your password to continue'
|
||||
: values.validatedPassword.feedback
|
||||
: values.validatedPassword.feedback || undefined
|
||||
: undefined,
|
||||
}),
|
||||
submit: async () => {
|
||||
|
@ -21,6 +21,7 @@ import { BillingCTAHero } from './BillingCTAHero'
|
||||
import { billingLogic } from './billingLogic'
|
||||
import { BillingProduct } from './BillingProduct'
|
||||
import { CreditCTAHero } from './CreditCTAHero'
|
||||
import { PaymentEntryModal } from './PaymentEntryModal'
|
||||
import { UnsubscribeCard } from './UnsubscribeCard'
|
||||
|
||||
export const scene: SceneExport = {
|
||||
@ -82,6 +83,8 @@ export function Billing(): JSX.Element {
|
||||
const platformAndSupportProduct = products?.find((product) => product.type === 'platform_and_support')
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<PaymentEntryModal />
|
||||
|
||||
{showLicenseDirectInput && (
|
||||
<>
|
||||
<Form logic={billingLogic} formKey="activateLicense" enableFormOnSubmit className="space-y-4">
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { LemonButton, LemonModal, Spinner } from '@posthog/lemon-ui'
|
||||
import { Elements, PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { useEffect } from 'react'
|
||||
import { WavingHog } from 'lib/components/hedgehogs'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import { paymentEntryLogic } from './paymentEntryLogic'
|
||||
|
||||
const stripePromise = loadStripe(window.STRIPE_PUBLIC_KEY!)
|
||||
const stripeJs = async (): Promise<typeof import('@stripe/stripe-js')> => await import('@stripe/stripe-js')
|
||||
|
||||
export const PaymentForm = (): JSX.Element => {
|
||||
const { error, isLoading } = useValues(paymentEntryLogic)
|
||||
@ -34,13 +35,17 @@ export const PaymentForm = (): JSX.Element => {
|
||||
setLoading(false)
|
||||
setError(result.error.message)
|
||||
} else {
|
||||
pollAuthorizationStatus()
|
||||
pollAuthorizationStatus(result.paymentIntent.id)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PaymentElement />
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Your card will not be charged but we place a $0.50 hold on it to verify your card that will be released
|
||||
in 7 days.
|
||||
</p>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<div className="flex justify-end space-x-2 mt-2">
|
||||
<LemonButton disabled={isLoading} type="secondary" onClick={hidePaymentEntryModal}>
|
||||
@ -58,21 +63,38 @@ interface PaymentEntryModalProps {
|
||||
redirectPath?: string | null
|
||||
}
|
||||
|
||||
export const PaymentEntryModal = ({ redirectPath = null }: PaymentEntryModalProps): JSX.Element | null => {
|
||||
export const PaymentEntryModal = ({
|
||||
redirectPath = urls.organizationBilling(),
|
||||
}: PaymentEntryModalProps): JSX.Element => {
|
||||
const { clientSecret, paymentEntryModalOpen } = useValues(paymentEntryLogic)
|
||||
const { hidePaymentEntryModal, initiateAuthorization } = useActions(paymentEntryLogic)
|
||||
const [stripePromise, setStripePromise] = useState<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
initiateAuthorization(redirectPath)
|
||||
}, [redirectPath])
|
||||
// Only load Stripe.js when the modal is opened
|
||||
if (paymentEntryModalOpen && !stripePromise) {
|
||||
const loadStripeJs = async (): Promise<void> => {
|
||||
const { loadStripe } = await stripeJs()
|
||||
const publicKey = window.STRIPE_PUBLIC_KEY!
|
||||
setStripePromise(await loadStripe(publicKey))
|
||||
}
|
||||
void loadStripeJs()
|
||||
}
|
||||
}, [paymentEntryModalOpen, stripePromise])
|
||||
|
||||
useEffect(() => {
|
||||
if (paymentEntryModalOpen) {
|
||||
initiateAuthorization(redirectPath)
|
||||
}
|
||||
}, [paymentEntryModalOpen, initiateAuthorization, redirectPath])
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
onClose={hidePaymentEntryModal}
|
||||
width="max(44vw)"
|
||||
isOpen={paymentEntryModalOpen}
|
||||
title="Add your payment details"
|
||||
description="Your card will not be charged."
|
||||
title="Add your payment details to subscribe"
|
||||
description=""
|
||||
>
|
||||
<div>
|
||||
{clientSecret ? (
|
||||
@ -80,9 +102,13 @@ export const PaymentEntryModal = ({ redirectPath = null }: PaymentEntryModalProp
|
||||
<PaymentForm />
|
||||
</Elements>
|
||||
) : (
|
||||
<div className="min-h-40 flex justify-center items-center">
|
||||
<div className="text-4xl">
|
||||
<Spinner />
|
||||
<div className="min-h-80 flex flex-col justify-center items-center">
|
||||
<p className="text-muted text-md mt-4">We're contacting the Hedgehogs for approval.</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="text-4xl">
|
||||
<Spinner />
|
||||
</div>
|
||||
<WavingHog className="w-18 h-18" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
@ -12,7 +12,7 @@ export const paymentEntryLogic = kea<paymentEntryLogicType>({
|
||||
setLoading: (loading) => ({ loading }),
|
||||
setError: (error) => ({ error }),
|
||||
initiateAuthorization: (redirectPath: string | null) => ({ redirectPath }),
|
||||
pollAuthorizationStatus: true,
|
||||
pollAuthorizationStatus: (paymentIntentId?: string) => ({ paymentIntentId }),
|
||||
setAuthorizationStatus: (status: string | null) => ({ status }),
|
||||
showPaymentEntryModal: true,
|
||||
hidePaymentEntryModal: true,
|
||||
@ -73,7 +73,7 @@ export const paymentEntryLogic = kea<paymentEntryLogicType>({
|
||||
}
|
||||
},
|
||||
|
||||
pollAuthorizationStatus: async () => {
|
||||
pollAuthorizationStatus: async ({ paymentIntentId }) => {
|
||||
const pollInterval = 2000 // Poll every 2 seconds
|
||||
const maxAttempts = 30 // Max 1 minute of polling (30 * 2 seconds)
|
||||
let attempts = 0
|
||||
@ -81,9 +81,9 @@ export const paymentEntryLogic = kea<paymentEntryLogicType>({
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const paymentIntentId = urlParams.get('payment_intent')
|
||||
const searchPaymentIntentId = urlParams.get('payment_intent')
|
||||
const response = await api.create('api/billing/activate/authorize/status', {
|
||||
payment_intent_id: paymentIntentId,
|
||||
payment_intent_id: paymentIntentId || searchPaymentIntentId,
|
||||
})
|
||||
const status = response.status
|
||||
|
||||
|
@ -60,7 +60,7 @@ export function QueryWindow(): JSX.Element {
|
||||
onQueryInputChange={runQuery}
|
||||
onSave={saveAsView}
|
||||
saveDisabledReason={
|
||||
hasErrors ? error ?? 'Query has errors' : !isValidView ? 'All fields must have an alias' : ''
|
||||
hasErrors ? error ?? 'Query has errors' : !isValidView ? 'Some fields may need an alias' : ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
@ -117,6 +117,7 @@ const MOCK_FUNNEL_EXPERIMENT: Experiment = {
|
||||
filter_test_accounts: true,
|
||||
},
|
||||
metrics: [],
|
||||
metrics_secondary: [],
|
||||
archived: false,
|
||||
created_by: {
|
||||
id: 1,
|
||||
@ -174,6 +175,7 @@ const MOCK_TREND_EXPERIMENT: Experiment = {
|
||||
},
|
||||
},
|
||||
metrics: [],
|
||||
metrics_secondary: [],
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{
|
||||
@ -281,6 +283,7 @@ const MOCK_WEB_EXPERIMENT_MANY_VARIANTS: Experiment = {
|
||||
},
|
||||
},
|
||||
metrics: [],
|
||||
metrics_secondary: [],
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{
|
||||
@ -403,6 +406,7 @@ const MOCK_TREND_EXPERIMENT_MANY_VARIANTS: Experiment = {
|
||||
},
|
||||
},
|
||||
metrics: [],
|
||||
metrics_secondary: [],
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{
|
||||
|
@ -17,14 +17,8 @@ import { experimentLogic } from './experimentLogic'
|
||||
|
||||
const ExperimentFormFields = (): JSX.Element => {
|
||||
const { experiment, featureFlags, groupTypes, aggregationLabel, dynamicFeatureFlagKey } = useValues(experimentLogic)
|
||||
const {
|
||||
addExperimentGroup,
|
||||
removeExperimentGroup,
|
||||
setExperiment,
|
||||
setNewExperimentInsight,
|
||||
createExperiment,
|
||||
setExperimentType,
|
||||
} = useActions(experimentLogic)
|
||||
const { addExperimentGroup, removeExperimentGroup, setExperiment, createExperiment, setExperimentType } =
|
||||
useActions(experimentLogic)
|
||||
const { webExperimentsAvailable } = useValues(experimentsLogic)
|
||||
|
||||
return (
|
||||
@ -130,7 +124,6 @@ const ExperimentFormFields = (): JSX.Element => {
|
||||
aggregation_group_type_index: groupTypeIndex ?? undefined,
|
||||
},
|
||||
})
|
||||
setNewExperimentInsight()
|
||||
}}
|
||||
options={[
|
||||
{ value: -1, label: 'Persons' },
|
||||
|
@ -1,17 +1,17 @@
|
||||
import { IconInfo } from '@posthog/icons'
|
||||
import { Tooltip } from '@posthog/lemon-ui'
|
||||
import { useValues } from 'kea'
|
||||
import { FEATURE_FLAGS } from 'lib/constants'
|
||||
import { featureFlagLogic } from 'lib/logic/featureFlagLogic'
|
||||
import { InsightEmptyState } from 'scenes/insights/EmptyStates'
|
||||
|
||||
import { InsightViz } from '~/queries/nodes/InsightViz/InsightViz'
|
||||
import { queryFromFilters } from '~/queries/nodes/InsightViz/utils'
|
||||
import { InsightQueryNode, InsightVizNode, NodeKind } from '~/queries/schema'
|
||||
import { CachedExperimentTrendsQueryResponse, InsightQueryNode, InsightVizNode, NodeKind } from '~/queries/schema'
|
||||
import {
|
||||
_TrendsExperimentResults,
|
||||
BaseMathType,
|
||||
ChartDisplayType,
|
||||
Experiment,
|
||||
ExperimentResults,
|
||||
InsightType,
|
||||
PropertyFilterType,
|
||||
PropertyOperator,
|
||||
@ -20,68 +20,113 @@ import {
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { transformResultFilters } from '../utils'
|
||||
|
||||
const getCumulativeExposuresQuery = (
|
||||
experiment: Experiment,
|
||||
experimentResults: ExperimentResults['result']
|
||||
): InsightVizNode<InsightQueryNode> => {
|
||||
const experimentInsightType = experiment.filters?.insight || InsightType.TRENDS
|
||||
export function CumulativeExposuresChart(): JSX.Element {
|
||||
const { experiment, experimentResults, getMetricType } = useValues(experimentLogic)
|
||||
const { featureFlags } = useValues(featureFlagLogic)
|
||||
|
||||
const metricIdx = 0
|
||||
const metricType = getMetricType(metricIdx)
|
||||
|
||||
const variants = experiment.parameters?.feature_flag_variants?.map((variant) => variant.key) || []
|
||||
if (experiment.holdout) {
|
||||
variants.push(`holdout-${experiment.holdout.id}`)
|
||||
}
|
||||
|
||||
// Trends Experiment
|
||||
if (experimentInsightType === InsightType.TRENDS && experiment.parameters?.custom_exposure_filter) {
|
||||
const trendResults = experimentResults as _TrendsExperimentResults
|
||||
const queryFilters = {
|
||||
...trendResults.exposure_filters,
|
||||
display: ChartDisplayType.ActionsLineGraphCumulative,
|
||||
} as _TrendsExperimentResults['exposure_filters']
|
||||
return queryFromFilters(transformResultFilters(queryFilters))
|
||||
}
|
||||
return {
|
||||
kind: NodeKind.InsightVizNode,
|
||||
source: {
|
||||
kind: NodeKind.TrendsQuery,
|
||||
dateRange: {
|
||||
date_from: experiment.start_date,
|
||||
date_to: experiment.end_date,
|
||||
},
|
||||
interval: 'day',
|
||||
trendsFilter: {
|
||||
display: ChartDisplayType.ActionsLineGraphCumulative,
|
||||
showLegend: false,
|
||||
smoothingIntervals: 1,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
kind: NodeKind.EventsNode,
|
||||
event:
|
||||
experimentInsightType === InsightType.TRENDS
|
||||
? '$feature_flag_called'
|
||||
: experiment.filters?.events?.[0]?.name,
|
||||
math: BaseMathType.UniqueUsers,
|
||||
properties: [
|
||||
let query
|
||||
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
if (metricType === InsightType.TRENDS) {
|
||||
query = {
|
||||
kind: NodeKind.InsightVizNode,
|
||||
source: (experimentResults as CachedExperimentTrendsQueryResponse).exposure_query,
|
||||
}
|
||||
} else {
|
||||
query = {
|
||||
kind: NodeKind.InsightVizNode,
|
||||
source: {
|
||||
kind: NodeKind.TrendsQuery,
|
||||
dateRange: {
|
||||
date_from: experiment.start_date,
|
||||
date_to: experiment.end_date,
|
||||
},
|
||||
interval: 'day',
|
||||
trendsFilter: {
|
||||
display: ChartDisplayType.ActionsLineGraphCumulative,
|
||||
showLegend: false,
|
||||
smoothingIntervals: 1,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
key: `$feature/${experiment.feature_flag_key}`,
|
||||
value: variants,
|
||||
operator: PropertyOperator.Exact,
|
||||
type: PropertyFilterType.Event,
|
||||
kind: NodeKind.EventsNode,
|
||||
event: experiment.filters?.events?.[0]?.name,
|
||||
math: BaseMathType.UniqueUsers,
|
||||
properties: [
|
||||
{
|
||||
key: `$feature/${experiment.feature_flag_key}`,
|
||||
value: variants,
|
||||
operator: PropertyOperator.Exact,
|
||||
type: PropertyFilterType.Event,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
breakdownFilter: {
|
||||
breakdown: `$feature/${experiment.feature_flag_key}`,
|
||||
breakdown_type: 'event',
|
||||
},
|
||||
},
|
||||
],
|
||||
breakdownFilter: {
|
||||
breakdown: `$feature/${experiment.feature_flag_key}`,
|
||||
breakdown_type: 'event',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (metricType === InsightType.TRENDS && experiment.parameters?.custom_exposure_filter) {
|
||||
const trendResults = experimentResults as _TrendsExperimentResults
|
||||
const queryFilters = {
|
||||
...trendResults.exposure_filters,
|
||||
display: ChartDisplayType.ActionsLineGraphCumulative,
|
||||
} as _TrendsExperimentResults['exposure_filters']
|
||||
query = queryFromFilters(transformResultFilters(queryFilters))
|
||||
} else {
|
||||
query = {
|
||||
kind: NodeKind.InsightVizNode,
|
||||
source: {
|
||||
kind: NodeKind.TrendsQuery,
|
||||
dateRange: {
|
||||
date_from: experiment.start_date,
|
||||
date_to: experiment.end_date,
|
||||
},
|
||||
interval: 'day',
|
||||
trendsFilter: {
|
||||
display: ChartDisplayType.ActionsLineGraphCumulative,
|
||||
showLegend: false,
|
||||
smoothingIntervals: 1,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
kind: NodeKind.EventsNode,
|
||||
event:
|
||||
metricType === InsightType.TRENDS
|
||||
? '$feature_flag_called'
|
||||
: experiment.filters?.events?.[0]?.name,
|
||||
math: BaseMathType.UniqueUsers,
|
||||
properties: [
|
||||
{
|
||||
key: `$feature/${experiment.feature_flag_key}`,
|
||||
value: variants,
|
||||
operator: PropertyOperator.Exact,
|
||||
type: PropertyFilterType.Event,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
breakdownFilter: {
|
||||
breakdown: `$feature/${experiment.feature_flag_key}`,
|
||||
breakdown_type: 'event',
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function CumulativeExposuresChart(): JSX.Element {
|
||||
const { experiment, experimentResults } = useValues(experimentLogic)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -94,7 +139,7 @@ export function CumulativeExposuresChart(): JSX.Element {
|
||||
{experiment.start_date ? (
|
||||
<InsightViz
|
||||
query={{
|
||||
...getCumulativeExposuresQuery(experiment, experimentResults as ExperimentResults['result']),
|
||||
...(query as InsightVizNode<InsightQueryNode>),
|
||||
showTable: true,
|
||||
}}
|
||||
setQuery={() => {}}
|
||||
|
@ -19,7 +19,7 @@ export function DataCollection(): JSX.Element {
|
||||
const {
|
||||
experimentId,
|
||||
experiment,
|
||||
experimentInsightType,
|
||||
getMetricType,
|
||||
funnelResultsPersonsTotal,
|
||||
actualRunningTime,
|
||||
minimumDetectableEffect,
|
||||
@ -27,11 +27,13 @@ export function DataCollection(): JSX.Element {
|
||||
|
||||
const { openExperimentCollectionGoalModal } = useActions(experimentLogic)
|
||||
|
||||
const metricType = getMetricType(0)
|
||||
|
||||
const recommendedRunningTime = experiment?.parameters?.recommended_running_time || 1
|
||||
const recommendedSampleSize = experiment?.parameters?.recommended_sample_size || 100
|
||||
|
||||
const experimentProgressPercent =
|
||||
experimentInsightType === InsightType.FUNNELS
|
||||
metricType === InsightType.FUNNELS
|
||||
? (funnelResultsPersonsTotal / recommendedSampleSize) * 100
|
||||
: (actualRunningTime / recommendedRunningTime) * 100
|
||||
|
||||
@ -83,7 +85,7 @@ export function DataCollection(): JSX.Element {
|
||||
size="large"
|
||||
percent={experimentProgressPercent}
|
||||
/>
|
||||
{experimentInsightType === InsightType.TRENDS && (
|
||||
{metricType === InsightType.TRENDS && (
|
||||
<div className="flex justify-between mt-0">
|
||||
<span className="flex items-center text-xs">
|
||||
Completed
|
||||
@ -103,7 +105,7 @@ export function DataCollection(): JSX.Element {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{experimentInsightType === InsightType.FUNNELS && (
|
||||
{metricType === InsightType.FUNNELS && (
|
||||
<div className="flex justify-between mt-0">
|
||||
<div className="space-x-1 flex items-center text-xs">
|
||||
<span>
|
||||
@ -170,11 +172,19 @@ export function DataCollection(): JSX.Element {
|
||||
}
|
||||
|
||||
export function DataCollectionGoalModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
|
||||
const { isExperimentCollectionGoalModalOpen, goalInsightDataLoading } = useValues(experimentLogic({ experimentId }))
|
||||
const {
|
||||
isExperimentCollectionGoalModalOpen,
|
||||
getMetricType,
|
||||
trendMetricInsightLoading,
|
||||
funnelMetricInsightLoading,
|
||||
} = useValues(experimentLogic({ experimentId }))
|
||||
const { closeExperimentCollectionGoalModal, updateExperimentCollectionGoal } = useActions(
|
||||
experimentLogic({ experimentId })
|
||||
)
|
||||
|
||||
const isInsightLoading =
|
||||
getMetricType(0) === InsightType.TRENDS ? trendMetricInsightLoading : funnelMetricInsightLoading
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
isOpen={isExperimentCollectionGoalModalOpen}
|
||||
@ -201,7 +211,7 @@ export function DataCollectionGoalModal({ experimentId }: { experimentId: Experi
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{goalInsightDataLoading ? (
|
||||
{isInsightLoading ? (
|
||||
<div className="flex flex-col flex-1 justify-center items-center mb-6">
|
||||
<Animation type={AnimationType.LaptopHog} />
|
||||
<div className="text-xs text-muted w-60">
|
||||
|
@ -9,9 +9,8 @@ import { insightLogic } from 'scenes/insights/insightLogic'
|
||||
import { Query } from '~/queries/Query/Query'
|
||||
import { ExperimentIdType, InsightType } from '~/types'
|
||||
|
||||
import { EXPERIMENT_INSIGHT_ID } from '../constants'
|
||||
import { MetricInsightId } from '../constants'
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
|
||||
interface ExperimentCalculatorProps {
|
||||
experimentId: ExperimentIdType
|
||||
}
|
||||
@ -108,20 +107,25 @@ function TrendCalculation({ experimentId }: ExperimentCalculatorProps): JSX.Elem
|
||||
}
|
||||
|
||||
export function DataCollectionCalculator({ experimentId }: ExperimentCalculatorProps): JSX.Element {
|
||||
const { experimentInsightType, minimumDetectableEffect, experiment, conversionMetrics } = useValues(
|
||||
const { getMetricType, minimumDetectableEffect, experiment, conversionMetrics } = useValues(
|
||||
experimentLogic({ experimentId })
|
||||
)
|
||||
const { setExperiment } = useActions(experimentLogic({ experimentId }))
|
||||
|
||||
const metricType = getMetricType(0)
|
||||
|
||||
// :KLUDGE: need these to mount the Query component to load the insight */
|
||||
const insightLogicInstance = insightLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID, syncWithUrl: false })
|
||||
const insightLogicInstance = insightLogic({
|
||||
dashboardItemId: metricType === InsightType.FUNNELS ? MetricInsightId.Funnels : MetricInsightId.Trends,
|
||||
syncWithUrl: false,
|
||||
})
|
||||
const { insightProps } = useValues(insightLogicInstance)
|
||||
const { query } = useValues(insightDataLogic(insightProps))
|
||||
|
||||
const funnelConversionRate = conversionMetrics?.totalRate * 100 || 0
|
||||
|
||||
let sliderMaxValue = 0
|
||||
if (experimentInsightType === InsightType.FUNNELS) {
|
||||
if (metricType === InsightType.FUNNELS) {
|
||||
if (100 - funnelConversionRate < 50) {
|
||||
sliderMaxValue = 100 - funnelConversionRate
|
||||
} else {
|
||||
@ -204,7 +208,7 @@ export function DataCollectionCalculator({ experimentId }: ExperimentCalculatorP
|
||||
The calculations are based on the events received in the last 14 days. This event count may
|
||||
differ from what was considered in earlier estimates.
|
||||
</LemonBanner>
|
||||
{experimentInsightType === InsightType.TRENDS ? (
|
||||
{getMetricType(0) === InsightType.TRENDS ? (
|
||||
<TrendCalculation experimentId={experimentId} />
|
||||
) : (
|
||||
<FunnelCalculation experimentId={experimentId} />
|
||||
|
@ -17,7 +17,7 @@ import {
|
||||
import { CumulativeExposuresChart } from './CumulativeExposuresChart'
|
||||
import { DataCollection } from './DataCollection'
|
||||
import { DistributionModal, DistributionTable } from './DistributionTable'
|
||||
import { ExperimentExposureModal, ExperimentGoalModal, Goal } from './Goal'
|
||||
import { Goal } from './Goal'
|
||||
import { Info } from './Info'
|
||||
import { Overview } from './Overview'
|
||||
import { ReleaseConditionsModal, ReleaseConditionsTable } from './ReleaseConditionsTable'
|
||||
@ -26,7 +26,6 @@ import { SecondaryMetricsTable } from './SecondaryMetricsTable'
|
||||
|
||||
const ResultsTab = (): JSX.Element => {
|
||||
const { experiment, experimentResults } = useValues(experimentLogic)
|
||||
const { updateExperimentSecondaryMetrics } = useActions(experimentLogic)
|
||||
|
||||
const hasResultsInsight = experimentResults && experimentResults.insight
|
||||
|
||||
@ -50,12 +49,7 @@ const ResultsTab = (): JSX.Element => {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<SecondaryMetricsTable
|
||||
experimentId={experiment.id}
|
||||
onMetricsChange={(metrics) => updateExperimentSecondaryMetrics(metrics)}
|
||||
initialMetrics={experiment.secondary_metrics}
|
||||
defaultAggregationType={experiment.parameters?.aggregation_group_type_index}
|
||||
/>
|
||||
<SecondaryMetricsTable experimentId={experiment.id} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -126,8 +120,6 @@ export function ExperimentView(): JSX.Element {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<ExperimentGoalModal experimentId={experimentId} />
|
||||
<ExperimentExposureModal experimentId={experimentId} />
|
||||
<DistributionModal experimentId={experimentId} />
|
||||
<ReleaseConditionsModal experimentId={experimentId} />
|
||||
</>
|
||||
|
@ -1,29 +1,84 @@
|
||||
import '../Experiment.scss'
|
||||
|
||||
import { IconInfo, IconPlus } from '@posthog/icons'
|
||||
import { LemonButton, LemonDivider, LemonModal, Tooltip } from '@posthog/lemon-ui'
|
||||
import { LemonButton, LemonDivider, Tooltip } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { Field, Form } from 'kea-forms'
|
||||
import { InsightLabel } from 'lib/components/InsightLabel'
|
||||
import { PropertyFilterButton } from 'lib/components/PropertyFilters/components/PropertyFilterButton'
|
||||
import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
|
||||
import { dayjs } from 'lib/dayjs'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { ActionFilter as ActionFilterType, AnyPropertyFilter, Experiment, FilterType, InsightType } from '~/types'
|
||||
import { ExperimentFunnelsQuery, ExperimentTrendsQuery, FunnelsQuery, NodeKind, TrendsQuery } from '~/queries/schema'
|
||||
import { ActionFilter, AnyPropertyFilter, ChartDisplayType, Experiment, FilterType, InsightType } from '~/types'
|
||||
|
||||
import { EXPERIMENT_EXPOSURE_INSIGHT_ID, EXPERIMENT_INSIGHT_ID } from '../constants'
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { MetricSelector } from '../MetricSelector'
|
||||
import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric } from '../experimentLogic'
|
||||
import { PrimaryMetricModal } from '../Metrics/PrimaryMetricModal'
|
||||
import { PrimaryTrendsExposureModal } from '../Metrics/PrimaryTrendsExposureModal'
|
||||
|
||||
export function MetricDisplay({ filters }: { filters?: FilterType }): JSX.Element {
|
||||
const experimentInsightType = filters?.insight || InsightType.TRENDS
|
||||
export function MetricDisplayTrends({ query }: { query: TrendsQuery | undefined }): JSX.Element {
|
||||
const event = query?.series?.[0] as unknown as ActionFilter
|
||||
|
||||
if (!event) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{([...(filters?.events || []), ...(filters?.actions || [])] as ActionFilterType[])
|
||||
<div className="mb-2">
|
||||
<div className="flex mb-1">
|
||||
<b>
|
||||
<InsightLabel action={event} showCountedByTag={true} hideIcon showEventName />
|
||||
</b>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{event.properties?.map((prop: AnyPropertyFilter) => (
|
||||
<PropertyFilterButton key={prop.key} item={prop} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetricDisplayFunnels({ query }: { query: FunnelsQuery }): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{(query.series || []).map((event: any, idx: number) => (
|
||||
<div key={idx} className="mb-2">
|
||||
<div className="flex mb-1">
|
||||
<div
|
||||
className="shrink-0 w-6 h-6 mr-2 font-bold text-center text-primary-alt border rounded"
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
style={{ backgroundColor: 'var(--bg-table)' }}
|
||||
>
|
||||
{idx + 1}
|
||||
</div>
|
||||
<b>
|
||||
<InsightLabel action={event} hideIcon showEventName />
|
||||
</b>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{event.properties?.map((prop: AnyPropertyFilter) => (
|
||||
<PropertyFilterButton key={prop.key} item={prop} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
export function MetricDisplayOld({ filters }: { filters?: FilterType }): JSX.Element {
|
||||
const metricType = filters?.insight || InsightType.TRENDS
|
||||
|
||||
return (
|
||||
<>
|
||||
{([...(filters?.events || []), ...(filters?.actions || [])] as ActionFilter[])
|
||||
.sort((a, b) => (a.order || 0) - (b.order || 0))
|
||||
.map((event: ActionFilterType, idx: number) => (
|
||||
.map((event: ActionFilter, idx: number) => (
|
||||
<div key={idx} className="mb-2">
|
||||
<div className="flex mb-1">
|
||||
{experimentInsightType === InsightType.FUNNELS && (
|
||||
{metricType === InsightType.FUNNELS && (
|
||||
<div
|
||||
className="shrink-0 w-6 h-6 mr-2 font-bold text-center text-primary-alt border rounded"
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
@ -35,7 +90,7 @@ export function MetricDisplay({ filters }: { filters?: FilterType }): JSX.Elemen
|
||||
<b>
|
||||
<InsightLabel
|
||||
action={event}
|
||||
showCountedByTag={experimentInsightType === InsightType.TRENDS}
|
||||
showCountedByTag={metricType === InsightType.TRENDS}
|
||||
hideIcon
|
||||
showEventName
|
||||
/>
|
||||
@ -53,8 +108,19 @@ export function MetricDisplay({ filters }: { filters?: FilterType }): JSX.Elemen
|
||||
}
|
||||
|
||||
export function ExposureMetric({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
|
||||
const { experiment } = useValues(experimentLogic({ experimentId }))
|
||||
const { openExperimentExposureModal, updateExperimentExposure } = useActions(experimentLogic({ experimentId }))
|
||||
const { experiment, featureFlags } = useValues(experimentLogic({ experimentId }))
|
||||
const { updateExperimentExposure, loadExperiment, setExperiment } = useActions(experimentLogic({ experimentId }))
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const metricIdx = 0
|
||||
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
let hasCustomExposure = false
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
hasCustomExposure = !!(experiment.metrics[metricIdx] as ExperimentTrendsQuery).exposure_query
|
||||
} else {
|
||||
hasCustomExposure = !!experiment.parameters?.custom_exposure_filter
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -66,154 +132,117 @@ export function ExposureMetric({ experimentId }: { experimentId: Experiment['id'
|
||||
<IconInfo className="ml-1 text-muted text-sm" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
{experiment.parameters?.custom_exposure_filter ? (
|
||||
<MetricDisplay filters={experiment.parameters.custom_exposure_filter} />
|
||||
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
|
||||
{featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] ? (
|
||||
hasCustomExposure ? (
|
||||
<MetricDisplayTrends query={(experiment.metrics[0] as ExperimentTrendsQuery).exposure_query} />
|
||||
) : (
|
||||
<span className="description">Default via $feature_flag_called events</span>
|
||||
)
|
||||
) : hasCustomExposure ? (
|
||||
<MetricDisplayOld filters={experiment.parameters.custom_exposure_filter} />
|
||||
) : (
|
||||
<span className="description">Default via $feature_flag_called events</span>
|
||||
)}
|
||||
<div className="mb-2 mt-2">
|
||||
<span className="flex">
|
||||
<LemonButton type="secondary" size="xsmall" onClick={openExperimentExposureModal} className="mr-2">
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
size="xsmall"
|
||||
onClick={() => {
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
if (!hasCustomExposure) {
|
||||
setExperiment({
|
||||
...experiment,
|
||||
metrics: experiment.metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
exposure_query: {
|
||||
kind: NodeKind.TrendsQuery,
|
||||
series: [
|
||||
{
|
||||
kind: NodeKind.EventsNode,
|
||||
name: '$pageview',
|
||||
event: '$pageview',
|
||||
},
|
||||
],
|
||||
interval: 'day',
|
||||
dateRange: {
|
||||
date_from: dayjs()
|
||||
.subtract(EXPERIMENT_DEFAULT_DURATION, 'day')
|
||||
.format('YYYY-MM-DDTHH:mm'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
explicitDate: true,
|
||||
},
|
||||
trendsFilter: {
|
||||
display: ChartDisplayType.ActionsLineGraph,
|
||||
},
|
||||
filterTestAccounts: true,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (!hasCustomExposure) {
|
||||
setExperiment({
|
||||
...experiment,
|
||||
parameters: {
|
||||
...experiment.parameters,
|
||||
custom_exposure_filter: getDefaultFilters(InsightType.TRENDS, undefined),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
className="mr-2"
|
||||
>
|
||||
Change exposure metric
|
||||
</LemonButton>
|
||||
{experiment.parameters?.custom_exposure_filter && (
|
||||
{hasCustomExposure && (
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
status="danger"
|
||||
size="xsmall"
|
||||
onClick={() => updateExperimentExposure(null)}
|
||||
onClick={() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setExperiment({
|
||||
...experiment,
|
||||
metrics: experiment.metrics.map((metric, idx) =>
|
||||
idx === metricIdx ? { ...metric, exposure_query: undefined } : metric
|
||||
),
|
||||
})
|
||||
}
|
||||
updateExperimentExposure(null)
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</LemonButton>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<PrimaryTrendsExposureModal
|
||||
experimentId={experimentId}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
loadExperiment()
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExperimentGoalModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
|
||||
const { experiment, isExperimentGoalModalOpen, experimentLoading, goalInsightDataLoading, experimentInsightType } =
|
||||
useValues(experimentLogic({ experimentId }))
|
||||
const { closeExperimentGoalModal, updateExperimentGoal, setNewExperimentInsight } = useActions(
|
||||
experimentLogic({ experimentId })
|
||||
)
|
||||
|
||||
const experimentFiltersLength =
|
||||
(experiment.filters?.events?.length || 0) + (experiment.filters?.actions?.length || 0)
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
isOpen={isExperimentGoalModalOpen}
|
||||
onClose={closeExperimentGoalModal}
|
||||
width={1000}
|
||||
title="Change experiment goal"
|
||||
footer={
|
||||
<div className="flex items-center gap-2">
|
||||
<LemonButton form="edit-experiment-goal-form" type="secondary" onClick={closeExperimentGoalModal}>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
disabledReason={
|
||||
(goalInsightDataLoading && 'The insight needs to be loaded before saving the goal.') ||
|
||||
(experimentInsightType === InsightType.FUNNELS &&
|
||||
experimentFiltersLength < 2 &&
|
||||
'The experiment needs at least two funnel steps.')
|
||||
}
|
||||
form="edit-experiment-goal-form"
|
||||
onClick={() => {
|
||||
updateExperimentGoal(experiment.filters)
|
||||
}}
|
||||
type="primary"
|
||||
loading={experimentLoading}
|
||||
data-attr="create-annotation-submit"
|
||||
>
|
||||
Save
|
||||
</LemonButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
logic={experimentLogic}
|
||||
props={{ experimentId }}
|
||||
formKey="experiment"
|
||||
id="edit-experiment-goal-form"
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field name="filters">
|
||||
<MetricSelector
|
||||
dashboardItemId={EXPERIMENT_INSIGHT_ID}
|
||||
setPreviewInsight={setNewExperimentInsight}
|
||||
showDateRangeBanner
|
||||
/>
|
||||
</Field>
|
||||
</Form>
|
||||
</LemonModal>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExperimentExposureModal({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
|
||||
const { experiment, isExperimentExposureModalOpen, experimentLoading } = useValues(
|
||||
experimentLogic({ experimentId })
|
||||
)
|
||||
const { closeExperimentExposureModal, updateExperimentExposure, setExperimentExposureInsight } = useActions(
|
||||
experimentLogic({ experimentId })
|
||||
)
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
isOpen={isExperimentExposureModalOpen}
|
||||
onClose={closeExperimentExposureModal}
|
||||
width={1000}
|
||||
title="Change experiment exposure"
|
||||
footer={
|
||||
<div className="flex items-center gap-2">
|
||||
<LemonButton
|
||||
form="edit-experiment-exposure-form"
|
||||
type="secondary"
|
||||
onClick={closeExperimentExposureModal}
|
||||
>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
form="edit-experiment-exposure-form"
|
||||
onClick={() => {
|
||||
if (experiment.parameters.custom_exposure_filter) {
|
||||
updateExperimentExposure(experiment.parameters.custom_exposure_filter)
|
||||
}
|
||||
}}
|
||||
type="primary"
|
||||
loading={experimentLoading}
|
||||
data-attr="create-annotation-submit"
|
||||
>
|
||||
Save
|
||||
</LemonButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
logic={experimentLogic}
|
||||
props={{ experimentId }}
|
||||
formKey="experiment"
|
||||
id="edit-experiment-exposure-form"
|
||||
className="space-y-4"
|
||||
>
|
||||
<Field name="filters">
|
||||
<MetricSelector
|
||||
dashboardItemId={EXPERIMENT_EXPOSURE_INSIGHT_ID}
|
||||
setPreviewInsight={setExperimentExposureInsight}
|
||||
forceTrendExposureMetric
|
||||
/>
|
||||
</Field>
|
||||
</Form>
|
||||
</LemonModal>
|
||||
)
|
||||
}
|
||||
|
||||
export function Goal(): JSX.Element {
|
||||
const { experiment, experimentId, experimentInsightType, experimentMathAggregationForTrends, hasGoalSet } =
|
||||
const { experiment, experimentId, getMetricType, experimentMathAggregationForTrends, hasGoalSet, featureFlags } =
|
||||
useValues(experimentLogic)
|
||||
const { openExperimentGoalModal } = useActions(experimentLogic({ experimentId }))
|
||||
const { setExperiment, loadExperiment } = useActions(experimentLogic)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const metricType = getMetricType(0)
|
||||
|
||||
return (
|
||||
<div>
|
||||
@ -224,8 +253,8 @@ export function Goal(): JSX.Element {
|
||||
title={
|
||||
<>
|
||||
{' '}
|
||||
This <b>{experimentInsightType === InsightType.FUNNELS ? 'funnel' : 'trend'}</b>{' '}
|
||||
{experimentInsightType === InsightType.FUNNELS
|
||||
This <b>{metricType === InsightType.FUNNELS ? 'funnel' : 'trend'}</b>{' '}
|
||||
{metricType === InsightType.FUNNELS
|
||||
? 'experiment measures conversion at each stage.'
|
||||
: 'experiment tracks the count of a single metric.'}
|
||||
</>
|
||||
@ -245,7 +274,20 @@ export function Goal(): JSX.Element {
|
||||
type="secondary"
|
||||
size="small"
|
||||
data-attr="add-experiment-goal"
|
||||
onClick={openExperimentGoalModal}
|
||||
onClick={() => {
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setExperiment({
|
||||
...experiment,
|
||||
metrics: [getDefaultFunnelsMetric()],
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
...experiment,
|
||||
filters: getDefaultFilters(InsightType.FUNNELS, undefined),
|
||||
})
|
||||
}
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Add goal
|
||||
</LemonButton>
|
||||
@ -254,14 +296,27 @@ export function Goal(): JSX.Element {
|
||||
<div className="inline-flex space-x-6">
|
||||
<div>
|
||||
<div className="card-secondary mb-2 mt-2">
|
||||
{experimentInsightType === InsightType.FUNNELS ? 'Conversion goal steps' : 'Trend goal'}
|
||||
{metricType === InsightType.FUNNELS ? 'Conversion goal steps' : 'Trend goal'}
|
||||
</div>
|
||||
<MetricDisplay filters={experiment.filters} />
|
||||
<LemonButton size="xsmall" type="secondary" onClick={openExperimentGoalModal}>
|
||||
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
|
||||
{featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] ? (
|
||||
metricType === InsightType.FUNNELS ? (
|
||||
<MetricDisplayFunnels
|
||||
query={(experiment.metrics[0] as ExperimentFunnelsQuery).funnels_query}
|
||||
/>
|
||||
) : (
|
||||
<MetricDisplayTrends
|
||||
query={(experiment.metrics[0] as ExperimentTrendsQuery).count_query}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<MetricDisplayOld filters={experiment.filters} />
|
||||
)}
|
||||
<LemonButton size="xsmall" type="secondary" onClick={() => setIsModalOpen(true)}>
|
||||
Change goal
|
||||
</LemonButton>
|
||||
</div>
|
||||
{experimentInsightType === InsightType.TRENDS && !experimentMathAggregationForTrends() && (
|
||||
{metricType === InsightType.TRENDS && !experimentMathAggregationForTrends() && (
|
||||
<>
|
||||
<LemonDivider className="" vertical />
|
||||
<div className="">
|
||||
@ -273,6 +328,14 @@ export function Goal(): JSX.Element {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<PrimaryMetricModal
|
||||
experimentId={experimentId}
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
loadExperiment()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
@ -1,131 +1,36 @@
|
||||
import '../Experiment.scss'
|
||||
|
||||
import { IconInfo, IconPencil, IconPlus } from '@posthog/icons'
|
||||
import { LemonButton, LemonInput, LemonModal, LemonTable, LemonTableColumns, Tooltip } from '@posthog/lemon-ui'
|
||||
import { LemonButton, LemonTable, LemonTableColumns, Tooltip } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { Form } from 'kea-forms'
|
||||
import { EntityFilterInfo } from 'lib/components/EntityFilterInfo'
|
||||
import { FEATURE_FLAGS } from 'lib/constants'
|
||||
import { IconAreaChart } from 'lib/lemon-ui/icons'
|
||||
import { LemonField } from 'lib/lemon-ui/LemonField'
|
||||
import { capitalizeFirstLetter } from 'lib/utils'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { InsightType } from '~/types'
|
||||
import { Experiment, InsightType } from '~/types'
|
||||
|
||||
import { SECONDARY_METRIC_INSIGHT_ID } from '../constants'
|
||||
import { experimentLogic, TabularSecondaryMetricResults } from '../experimentLogic'
|
||||
import { MetricSelector } from '../MetricSelector'
|
||||
import { MAX_SECONDARY_METRICS, secondaryMetricsLogic, SecondaryMetricsProps } from '../secondaryMetricsLogic'
|
||||
import { ResultsQuery, VariantTag } from './components'
|
||||
import {
|
||||
experimentLogic,
|
||||
getDefaultFilters,
|
||||
getDefaultFunnelsMetric,
|
||||
TabularSecondaryMetricResults,
|
||||
} from '../experimentLogic'
|
||||
import { SecondaryMetricChartModal } from '../Metrics/SecondaryMetricChartModal'
|
||||
import { SecondaryMetricModal } from '../Metrics/SecondaryMetricModal'
|
||||
import { VariantTag } from './components'
|
||||
|
||||
export function SecondaryMetricsModal({
|
||||
onMetricsChange,
|
||||
initialMetrics,
|
||||
experimentId,
|
||||
defaultAggregationType,
|
||||
}: SecondaryMetricsProps): JSX.Element {
|
||||
const logic = secondaryMetricsLogic({ onMetricsChange, initialMetrics, experimentId, defaultAggregationType })
|
||||
const {
|
||||
secondaryMetricModal,
|
||||
isModalOpen,
|
||||
showResults,
|
||||
isSecondaryMetricModalSubmitting,
|
||||
existingModalSecondaryMetric,
|
||||
metricIdx,
|
||||
} = useValues(logic)
|
||||
const MAX_SECONDARY_METRICS = 10
|
||||
|
||||
const { deleteMetric, closeModal, saveSecondaryMetric, setPreviewInsight } = useActions(logic)
|
||||
const { secondaryMetricResults, isExperimentRunning } = useValues(experimentLogic({ experimentId }))
|
||||
const targetResults = secondaryMetricResults && secondaryMetricResults[metricIdx]
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
width={1000}
|
||||
title={
|
||||
showResults
|
||||
? secondaryMetricModal.name
|
||||
: existingModalSecondaryMetric
|
||||
? 'Edit secondary metric'
|
||||
: 'New secondary metric'
|
||||
}
|
||||
footer={
|
||||
showResults ? (
|
||||
<LemonButton form="secondary-metric-modal-form" type="secondary" onClick={closeModal}>
|
||||
Close
|
||||
</LemonButton>
|
||||
) : (
|
||||
<>
|
||||
{existingModalSecondaryMetric && (
|
||||
<LemonButton
|
||||
className="mr-auto"
|
||||
form="secondary-metric-modal-form"
|
||||
type="secondary"
|
||||
status="danger"
|
||||
onClick={() => deleteMetric(metricIdx)}
|
||||
>
|
||||
Delete
|
||||
</LemonButton>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<LemonButton form="secondary-metric-modal-form" type="secondary" onClick={closeModal}>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
form="secondary-metric-modal-form"
|
||||
onClick={saveSecondaryMetric}
|
||||
type="primary"
|
||||
loading={isSecondaryMetricModalSubmitting}
|
||||
data-attr="create-annotation-submit"
|
||||
>
|
||||
{existingModalSecondaryMetric ? 'Save' : 'Create'}
|
||||
</LemonButton>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{showResults ? (
|
||||
<ResultsQuery targetResults={targetResults} showTable={false} />
|
||||
) : (
|
||||
<Form
|
||||
logic={secondaryMetricsLogic}
|
||||
props={{ onMetricsChange, initialMetrics, experimentId, defaultAggregationType }}
|
||||
formKey="secondaryMetricModal"
|
||||
id="secondary-metric-modal-form"
|
||||
className="space-y-4"
|
||||
>
|
||||
<LemonField name="name" label="Name">
|
||||
<LemonInput data-attr="secondary-metric-name" />
|
||||
</LemonField>
|
||||
<LemonField name="filters" label="Query">
|
||||
<MetricSelector
|
||||
dashboardItemId={SECONDARY_METRIC_INSIGHT_ID}
|
||||
setPreviewInsight={setPreviewInsight}
|
||||
showDateRangeBanner={isExperimentRunning}
|
||||
/>
|
||||
</LemonField>
|
||||
</Form>
|
||||
)}
|
||||
</LemonModal>
|
||||
)
|
||||
}
|
||||
|
||||
export function SecondaryMetricsTable({
|
||||
onMetricsChange,
|
||||
initialMetrics,
|
||||
experimentId,
|
||||
defaultAggregationType,
|
||||
}: SecondaryMetricsProps): JSX.Element {
|
||||
const logic = secondaryMetricsLogic({ onMetricsChange, initialMetrics, experimentId, defaultAggregationType })
|
||||
const { metrics } = useValues(logic)
|
||||
|
||||
const { openModalToCreateSecondaryMetric, openModalToEditSecondaryMetric } = useActions(logic)
|
||||
export function SecondaryMetricsTable({ experimentId }: { experimentId: Experiment['id'] }): JSX.Element {
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false)
|
||||
const [isChartModalOpen, setIsChartModalOpen] = useState(false)
|
||||
const [modalMetricIdx, setModalMetricIdx] = useState<number | null>(null)
|
||||
|
||||
const {
|
||||
experimentResults,
|
||||
secondaryMetricResultsLoading,
|
||||
experiment,
|
||||
getSecondaryMetricType,
|
||||
secondaryMetricResults,
|
||||
tabularSecondaryMetricResults,
|
||||
countDataForVariant,
|
||||
@ -134,7 +39,38 @@ export function SecondaryMetricsTable({
|
||||
credibleIntervalForVariant,
|
||||
experimentMathAggregationForTrends,
|
||||
getHighestProbabilityVariant,
|
||||
featureFlags,
|
||||
} = useValues(experimentLogic({ experimentId }))
|
||||
const { loadExperiment } = useActions(experimentLogic({ experimentId }))
|
||||
|
||||
const openEditModal = (idx: number): void => {
|
||||
setModalMetricIdx(idx)
|
||||
setIsEditModalOpen(true)
|
||||
}
|
||||
|
||||
const closeEditModal = (): void => {
|
||||
setIsEditModalOpen(false)
|
||||
setModalMetricIdx(null)
|
||||
loadExperiment()
|
||||
}
|
||||
|
||||
const openChartModal = (idx: number): void => {
|
||||
setModalMetricIdx(idx)
|
||||
setIsChartModalOpen(true)
|
||||
}
|
||||
|
||||
const closeChartModal = (): void => {
|
||||
setIsChartModalOpen(false)
|
||||
setModalMetricIdx(null)
|
||||
}
|
||||
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
let metrics
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
metrics = experiment.metrics_secondary
|
||||
} else {
|
||||
metrics = experiment.secondary_metrics
|
||||
}
|
||||
|
||||
const columns: LemonTableColumns<any> = [
|
||||
{
|
||||
@ -156,14 +92,15 @@ export function SecondaryMetricsTable({
|
||||
},
|
||||
]
|
||||
|
||||
experiment.secondary_metrics?.forEach((metric, idx) => {
|
||||
metrics?.forEach((metric, idx) => {
|
||||
const targetResults = secondaryMetricResults?.[idx]
|
||||
const winningVariant = getHighestProbabilityVariant(targetResults || null)
|
||||
const metricType = getSecondaryMetricType(idx)
|
||||
|
||||
const Header = (): JSX.Element => (
|
||||
<div className="">
|
||||
<div className="flex">
|
||||
<div className="w-3/4 truncate">{capitalizeFirstLetter(metric.name)}</div>
|
||||
<div className="w-3/4 truncate">{capitalizeFirstLetter(metric.name || '')}</div>
|
||||
<div className="w-1/4 flex flex-col justify-end">
|
||||
<div className="ml-auto space-x-2 pb-1 inline-flex">
|
||||
<LemonButton
|
||||
@ -171,7 +108,7 @@ export function SecondaryMetricsTable({
|
||||
type="secondary"
|
||||
size="xsmall"
|
||||
icon={<IconAreaChart />}
|
||||
onClick={() => openModalToEditSecondaryMetric(metric, idx, true)}
|
||||
onClick={() => openChartModal(idx)}
|
||||
disabledReason={
|
||||
targetResults && targetResults.insight
|
||||
? undefined
|
||||
@ -183,7 +120,7 @@ export function SecondaryMetricsTable({
|
||||
type="secondary"
|
||||
size="xsmall"
|
||||
icon={<IconPencil />}
|
||||
onClick={() => openModalToEditSecondaryMetric(metric, idx, false)}
|
||||
onClick={() => openEditModal(idx)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -191,7 +128,7 @@ export function SecondaryMetricsTable({
|
||||
</div>
|
||||
)
|
||||
|
||||
if (metric.filters.insight === InsightType.TRENDS) {
|
||||
if (metricType === InsightType.TRENDS) {
|
||||
columns.push({
|
||||
title: <Header />,
|
||||
children: [
|
||||
@ -230,7 +167,11 @@ export function SecondaryMetricsTable({
|
||||
if (item.variant === 'control') {
|
||||
return <em>Baseline</em>
|
||||
}
|
||||
const credibleInterval = credibleIntervalForVariant(targetResults || null, item.variant)
|
||||
const credibleInterval = credibleIntervalForVariant(
|
||||
targetResults || null,
|
||||
item.variant,
|
||||
metricType
|
||||
)
|
||||
if (!credibleInterval) {
|
||||
return <>—</>
|
||||
}
|
||||
@ -281,7 +222,11 @@ export function SecondaryMetricsTable({
|
||||
return <em>Baseline</em>
|
||||
}
|
||||
|
||||
const credibleInterval = credibleIntervalForVariant(targetResults || null, item.variant)
|
||||
const credibleInterval = credibleIntervalForVariant(
|
||||
targetResults || null,
|
||||
item.variant,
|
||||
metricType
|
||||
)
|
||||
if (!credibleInterval) {
|
||||
return <>—</>
|
||||
}
|
||||
@ -332,18 +277,11 @@ export function SecondaryMetricsTable({
|
||||
<div className="ml-auto">
|
||||
{metrics && metrics.length > 0 && (
|
||||
<div className="mb-2 mt-4 justify-end">
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={openModalToCreateSecondaryMetric}
|
||||
disabledReason={
|
||||
metrics.length >= MAX_SECONDARY_METRICS
|
||||
? `You can only add up to ${MAX_SECONDARY_METRICS} secondary metrics.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Add metric
|
||||
</LemonButton>
|
||||
<AddSecondaryMetricButton
|
||||
experimentId={experimentId}
|
||||
metrics={metrics}
|
||||
openEditModal={openEditModal}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -365,24 +303,76 @@ export function SecondaryMetricsTable({
|
||||
Add up to {MAX_SECONDARY_METRICS} secondary metrics to monitor side effects of your
|
||||
experiment.
|
||||
</div>
|
||||
<LemonButton
|
||||
icon={<IconPlus />}
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={openModalToCreateSecondaryMetric}
|
||||
>
|
||||
Add metric
|
||||
</LemonButton>
|
||||
<AddSecondaryMetricButton
|
||||
experimentId={experimentId}
|
||||
metrics={metrics}
|
||||
openEditModal={openEditModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SecondaryMetricsModal
|
||||
onMetricsChange={onMetricsChange}
|
||||
initialMetrics={initialMetrics}
|
||||
<SecondaryMetricModal
|
||||
metricIdx={modalMetricIdx ?? 0}
|
||||
isOpen={isEditModalOpen}
|
||||
onClose={closeEditModal}
|
||||
experimentId={experimentId}
|
||||
defaultAggregationType={defaultAggregationType}
|
||||
/>
|
||||
<SecondaryMetricChartModal
|
||||
experimentId={experimentId}
|
||||
metricIdx={modalMetricIdx ?? 0}
|
||||
isOpen={isChartModalOpen}
|
||||
onClose={closeChartModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const AddSecondaryMetricButton = ({
|
||||
experimentId,
|
||||
metrics,
|
||||
openEditModal,
|
||||
}: {
|
||||
experimentId: Experiment['id']
|
||||
metrics: any
|
||||
openEditModal: (metricIdx: number) => void
|
||||
}): JSX.Element => {
|
||||
const { experiment, featureFlags } = useValues(experimentLogic({ experimentId }))
|
||||
const { setExperiment } = useActions(experimentLogic({ experimentId }))
|
||||
return (
|
||||
<LemonButton
|
||||
icon={<IconPlus />}
|
||||
type="secondary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const newMetricsSecondary = [...experiment.metrics_secondary, getDefaultFunnelsMetric()]
|
||||
setExperiment({
|
||||
metrics_secondary: newMetricsSecondary,
|
||||
})
|
||||
openEditModal(newMetricsSecondary.length - 1)
|
||||
} else {
|
||||
const newSecondaryMetrics = [
|
||||
...experiment.secondary_metrics,
|
||||
{
|
||||
name: '',
|
||||
filters: getDefaultFilters(InsightType.FUNNELS, undefined),
|
||||
},
|
||||
]
|
||||
setExperiment({
|
||||
secondary_metrics: newSecondaryMetrics,
|
||||
})
|
||||
openEditModal(newSecondaryMetrics.length - 1)
|
||||
}
|
||||
}}
|
||||
disabledReason={
|
||||
metrics.length >= MAX_SECONDARY_METRICS
|
||||
? `You can only add up to ${MAX_SECONDARY_METRICS} secondary metrics.`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Add metric
|
||||
</LemonButton>
|
||||
)
|
||||
}
|
||||
|
@ -11,8 +11,6 @@ import posthog from 'posthog-js'
|
||||
import { urls } from 'scenes/urls'
|
||||
|
||||
import {
|
||||
_FunnelExperimentResults,
|
||||
_TrendsExperimentResults,
|
||||
FilterLogicalOperator,
|
||||
FunnelExperimentVariant,
|
||||
InsightType,
|
||||
@ -33,13 +31,15 @@ export function SummaryTable(): JSX.Element {
|
||||
experiment,
|
||||
experimentResults,
|
||||
tabularExperimentResults,
|
||||
experimentInsightType,
|
||||
getMetricType,
|
||||
exposureCountDataForVariant,
|
||||
conversionRateForVariant,
|
||||
experimentMathAggregationForTrends,
|
||||
countDataForVariant,
|
||||
getHighestProbabilityVariant,
|
||||
credibleIntervalForVariant,
|
||||
} = useValues(experimentLogic)
|
||||
const metricType = getMetricType(0)
|
||||
|
||||
if (!experimentResults) {
|
||||
return <></>
|
||||
@ -61,7 +61,7 @@ export function SummaryTable(): JSX.Element {
|
||||
},
|
||||
]
|
||||
|
||||
if (experimentInsightType === InsightType.TRENDS) {
|
||||
if (metricType === InsightType.TRENDS) {
|
||||
columns.push({
|
||||
key: 'counts',
|
||||
title: (
|
||||
@ -163,22 +163,11 @@ export function SummaryTable(): JSX.Element {
|
||||
return <em>Baseline</em>
|
||||
}
|
||||
|
||||
const credibleInterval = (experimentResults as _TrendsExperimentResults)?.credible_intervals?.[
|
||||
variant.key
|
||||
]
|
||||
const credibleInterval = credibleIntervalForVariant(experimentResults || null, variant.key, metricType)
|
||||
if (!credibleInterval) {
|
||||
return <>—</>
|
||||
}
|
||||
|
||||
const controlVariant = (experimentResults.variants as TrendExperimentVariant[]).find(
|
||||
({ key }) => key === 'control'
|
||||
) as TrendExperimentVariant
|
||||
const controlMean = controlVariant.count / controlVariant.absolute_exposure
|
||||
|
||||
// Calculate the percentage difference between the credible interval bounds of the variant and the control's mean.
|
||||
// This represents the range in which the true percentage change relative to the control is likely to fall.
|
||||
const lowerBound = ((credibleInterval[0] - controlMean) / controlMean) * 100
|
||||
const upperBound = ((credibleInterval[1] - controlMean) / controlMean) * 100
|
||||
const [lowerBound, upperBound] = credibleInterval
|
||||
|
||||
return (
|
||||
<div className="font-semibold">{`[${lowerBound > 0 ? '+' : ''}${lowerBound.toFixed(2)}%, ${
|
||||
@ -189,7 +178,7 @@ export function SummaryTable(): JSX.Element {
|
||||
})
|
||||
}
|
||||
|
||||
if (experimentInsightType === InsightType.FUNNELS) {
|
||||
if (metricType === InsightType.FUNNELS) {
|
||||
columns.push({
|
||||
key: 'conversionRate',
|
||||
title: 'Conversion rate',
|
||||
@ -248,27 +237,11 @@ export function SummaryTable(): JSX.Element {
|
||||
return <em>Baseline</em>
|
||||
}
|
||||
|
||||
const credibleInterval = (experimentResults as _FunnelExperimentResults)?.credible_intervals?.[
|
||||
item.key
|
||||
]
|
||||
const credibleInterval = credibleIntervalForVariant(experimentResults || null, item.key, metricType)
|
||||
if (!credibleInterval) {
|
||||
return <>—</>
|
||||
}
|
||||
|
||||
const controlVariant = (experimentResults.variants as FunnelExperimentVariant[]).find(
|
||||
({ key }) => key === 'control'
|
||||
) as FunnelExperimentVariant
|
||||
const controlConversionRate =
|
||||
controlVariant.success_count / (controlVariant.success_count + controlVariant.failure_count)
|
||||
|
||||
if (!controlConversionRate) {
|
||||
return <>—</>
|
||||
}
|
||||
|
||||
// Calculate the percentage difference between the credible interval bounds of the variant and the control's conversion rate.
|
||||
// This represents the range in which the true percentage change relative to the control is likely to fall.
|
||||
const lowerBound = ((credibleInterval[0] - controlConversionRate) / controlConversionRate) * 100
|
||||
const upperBound = ((credibleInterval[1] - controlConversionRate) / controlConversionRate) * 100
|
||||
const [lowerBound, upperBound] = credibleInterval
|
||||
|
||||
return (
|
||||
<div className="font-semibold">{`[${lowerBound > 0 ? '+' : ''}${lowerBound.toFixed(2)}%, ${
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { IconUpload, IconX } from '@posthog/icons'
|
||||
import { IconX } from '@posthog/icons'
|
||||
import { LemonButton, LemonDivider, LemonFileInput, LemonModal, LemonSkeleton, lemonToast } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { useUploadFiles } from 'lib/hooks/useUploadFiles'
|
||||
@ -17,20 +17,34 @@ export function VariantScreenshot({
|
||||
const { experiment } = useValues(experimentLogic)
|
||||
const { updateExperimentVariantImages, reportExperimentVariantScreenshotUploaded } = useActions(experimentLogic)
|
||||
|
||||
const [mediaId, setMediaId] = useState(experiment.parameters?.variant_screenshot_media_ids?.[variantKey] || null)
|
||||
const [isLoadingImage, setIsLoadingImage] = useState(true)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const getInitialMediaIds = (): string[] => {
|
||||
const variantImages = experiment.parameters?.variant_screenshot_media_ids?.[variantKey]
|
||||
if (!variantImages) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.isArray(variantImages) ? variantImages : [variantImages]
|
||||
}
|
||||
|
||||
const [mediaIds, setMediaIds] = useState<string[]>(getInitialMediaIds())
|
||||
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({})
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState<number | null>(null)
|
||||
|
||||
const { setFilesToUpload, filesToUpload, uploading } = useUploadFiles({
|
||||
onUpload: (_, __, id) => {
|
||||
setMediaId(id)
|
||||
if (id) {
|
||||
if (id && mediaIds.length < 5) {
|
||||
const newMediaIds = [...mediaIds, id]
|
||||
setMediaIds(newMediaIds)
|
||||
|
||||
const updatedVariantImages = {
|
||||
...experiment.parameters?.variant_screenshot_media_ids,
|
||||
[variantKey]: id,
|
||||
[variantKey]: newMediaIds,
|
||||
}
|
||||
|
||||
updateExperimentVariantImages(updatedVariantImages)
|
||||
reportExperimentVariantScreenshotUploaded(experiment.id)
|
||||
} else if (mediaIds.length >= 5) {
|
||||
lemonToast.error('Maximum of 5 images allowed')
|
||||
}
|
||||
},
|
||||
onError: (detail) => {
|
||||
@ -38,64 +52,107 @@ export function VariantScreenshot({
|
||||
},
|
||||
})
|
||||
|
||||
const handleImageLoad = (mediaId: string): void => {
|
||||
setLoadingImages((prev) => ({ ...prev, [mediaId]: false }))
|
||||
}
|
||||
|
||||
const handleImageError = (mediaId: string): void => {
|
||||
setLoadingImages((prev) => ({ ...prev, [mediaId]: false }))
|
||||
}
|
||||
|
||||
const handleDelete = (indexToDelete: number): void => {
|
||||
const newMediaIds = mediaIds.filter((_, index) => index !== indexToDelete)
|
||||
setMediaIds(newMediaIds)
|
||||
|
||||
const updatedVariantImages = {
|
||||
...experiment.parameters?.variant_screenshot_media_ids,
|
||||
[variantKey]: newMediaIds,
|
||||
}
|
||||
|
||||
updateExperimentVariantImages(updatedVariantImages)
|
||||
}
|
||||
|
||||
const getThumbnailWidth = (): string => {
|
||||
const totalItems = mediaIds.length < 5 ? mediaIds.length + 1 : mediaIds.length
|
||||
switch (totalItems) {
|
||||
case 1:
|
||||
return 'w-20'
|
||||
case 2:
|
||||
return 'w-20'
|
||||
case 3:
|
||||
return 'w-16'
|
||||
case 4:
|
||||
return 'w-14'
|
||||
case 5:
|
||||
return 'w-12'
|
||||
default:
|
||||
return 'w-20'
|
||||
}
|
||||
}
|
||||
|
||||
const widthClass = getThumbnailWidth()
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!mediaId ? (
|
||||
<LemonFileInput
|
||||
accept="image/*"
|
||||
multiple={false}
|
||||
onChange={setFilesToUpload}
|
||||
loading={uploading}
|
||||
value={filesToUpload}
|
||||
callToAction={
|
||||
<>
|
||||
<IconUpload className="text-2xl" />
|
||||
<span>Upload a preview of this variant's UI</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="text-muted inline-flex flow-row items-center gap-1 cursor-pointer">
|
||||
<div onClick={() => setIsModalOpen(true)} className="cursor-zoom-in relative">
|
||||
<div className="relative flex overflow-hidden select-none size-20 w-full rounded before:absolute before:inset-0 before:border before:rounded">
|
||||
{isLoadingImage && <LemonSkeleton className="absolute inset-0" />}
|
||||
<img
|
||||
className="size-full object-cover"
|
||||
src={mediaId.startsWith('data:') ? mediaId : `/uploaded_media/${mediaId}`}
|
||||
onError={() => setIsLoadingImage(false)}
|
||||
onLoad={() => setIsLoadingImage(false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -inset-2 group">
|
||||
<LemonButton
|
||||
icon={<IconX />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMediaId(null)
|
||||
const updatedVariantImages = {
|
||||
...experiment.parameters?.variant_screenshot_media_ids,
|
||||
}
|
||||
delete updatedVariantImages[variantKey]
|
||||
updateExperimentVariantImages(updatedVariantImages)
|
||||
}}
|
||||
size="small"
|
||||
tooltip="Remove"
|
||||
tooltipPlacement="right"
|
||||
noPadding
|
||||
className="group-hover:flex hidden absolute right-0 top-0"
|
||||
/>
|
||||
<div className="flex gap-4 items-start">
|
||||
{mediaIds.map((mediaId, index) => (
|
||||
<div key={mediaId} className="relative">
|
||||
<div className="text-muted inline-flex flow-row items-center gap-1 cursor-pointer">
|
||||
<div onClick={() => setSelectedImageIndex(index)} className="cursor-zoom-in relative">
|
||||
<div
|
||||
className={`relative flex overflow-hidden select-none ${widthClass} h-16 rounded before:absolute before:inset-0 before:border before:rounded`}
|
||||
>
|
||||
{loadingImages[mediaId] && <LemonSkeleton className="absolute inset-0" />}
|
||||
<img
|
||||
className="w-full h-full object-cover"
|
||||
src={mediaId.startsWith('data:') ? mediaId : `/uploaded_media/${mediaId}`}
|
||||
onError={() => handleImageError(mediaId)}
|
||||
onLoad={() => handleImageLoad(mediaId)}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -inset-2 group">
|
||||
<LemonButton
|
||||
icon={<IconX />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDelete(index)
|
||||
}}
|
||||
size="small"
|
||||
tooltip="Remove"
|
||||
tooltipPlacement="right"
|
||||
noPadding
|
||||
className="group-hover:flex hidden absolute right-0 top-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
|
||||
{mediaIds.length < 5 && (
|
||||
<div className={`relative ${widthClass} h-16`}>
|
||||
<LemonFileInput
|
||||
accept="image/*"
|
||||
multiple={false}
|
||||
onChange={setFilesToUpload}
|
||||
loading={uploading}
|
||||
value={filesToUpload}
|
||||
callToAction={
|
||||
<div className="flex items-center justify-center w-full h-16 border border-dashed rounded cursor-pointer hover:border-[var(--primary)]">
|
||||
<span className="text-2xl text-muted">+</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<LemonModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
isOpen={selectedImageIndex !== null}
|
||||
onClose={() => setSelectedImageIndex(null)}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Screenshot</span>
|
||||
<span>Screenshot {selectedImageIndex !== null ? selectedImageIndex + 1 : ''}</span>
|
||||
<LemonDivider className="my-0 mx-1" vertical />
|
||||
<VariantTag experimentId={experiment.id} variantKey={variantKey} />
|
||||
{rolloutPercentage !== undefined && (
|
||||
@ -104,12 +161,20 @@ export function VariantScreenshot({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={mediaId?.startsWith('data:') ? mediaId : `/uploaded_media/${mediaId}`}
|
||||
alt={`Screenshot: ${variantKey}`}
|
||||
className="max-w-full max-h-[80vh] overflow-auto"
|
||||
/>
|
||||
{selectedImageIndex !== null && mediaIds[selectedImageIndex] && (
|
||||
<img
|
||||
src={
|
||||
mediaIds[selectedImageIndex]?.startsWith('data:')
|
||||
? mediaIds[selectedImageIndex]
|
||||
: `/uploaded_media/${mediaIds[selectedImageIndex]}`
|
||||
}
|
||||
alt={`Screenshot ${selectedImageIndex + 1}: ${variantKey}`}
|
||||
className="max-w-full max-h-[80vh] overflow-auto"
|
||||
/>
|
||||
)}
|
||||
</LemonModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariantScreenshot
|
||||
|
@ -153,7 +153,6 @@ export function ResultsQuery({
|
||||
} as InsightVizNode,
|
||||
result: newQueryResults?.insight,
|
||||
disable_baseline: true,
|
||||
last_refresh: newQueryResults?.last_refresh,
|
||||
},
|
||||
doNotLoad: true,
|
||||
},
|
||||
@ -265,6 +264,8 @@ export function ExploreButton({ icon = <IconAreaChart /> }: { icon?: JSX.Element
|
||||
}
|
||||
|
||||
export function ResultsHeader(): JSX.Element {
|
||||
const { experimentResults } = useValues(experimentLogic)
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
<div className="w-1/2">
|
||||
@ -275,9 +276,7 @@ export function ResultsHeader(): JSX.Element {
|
||||
</div>
|
||||
|
||||
<div className="w-1/2 flex flex-col justify-end">
|
||||
<div className="ml-auto">
|
||||
<ExploreButton />
|
||||
</div>
|
||||
<div className="ml-auto">{experimentResults && <ExploreButton />}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@ -691,7 +690,7 @@ export function ShipVariantModal({ experimentId }: { experimentId: Experiment['i
|
||||
export function ActionBanner(): JSX.Element {
|
||||
const {
|
||||
experiment,
|
||||
experimentInsightType,
|
||||
getMetricType,
|
||||
experimentResults,
|
||||
experimentLoading,
|
||||
experimentResultsLoading,
|
||||
@ -708,6 +707,9 @@ export function ActionBanner(): JSX.Element {
|
||||
const { archiveExperiment } = useActions(experimentLogic)
|
||||
|
||||
const { aggregationLabel } = useValues(groupsModel)
|
||||
|
||||
const metricType = getMetricType(0)
|
||||
|
||||
const aggregationTargetName =
|
||||
experiment.filters.aggregation_group_type_index != null
|
||||
? aggregationLabel(experiment.filters.aggregation_group_type_index).plural
|
||||
@ -766,7 +768,7 @@ export function ActionBanner(): JSX.Element {
|
||||
// Results insignificant, but a large enough sample/running time has been achieved
|
||||
// Further collection unlikely to change the result -> recommmend cutting the losses
|
||||
if (
|
||||
experimentInsightType === InsightType.FUNNELS &&
|
||||
metricType === InsightType.FUNNELS &&
|
||||
funnelResultsPersonsTotal > Math.max(recommendedSampleSize, 500) &&
|
||||
dayjs().diff(experiment.start_date, 'day') > 2 // at least 2 days running
|
||||
) {
|
||||
@ -778,7 +780,7 @@ export function ActionBanner(): JSX.Element {
|
||||
</LemonBanner>
|
||||
)
|
||||
}
|
||||
if (experimentInsightType === InsightType.TRENDS && actualRunningTime > Math.max(recommendedRunningTime, 7)) {
|
||||
if (metricType === InsightType.TRENDS && actualRunningTime > Math.max(recommendedRunningTime, 7)) {
|
||||
return (
|
||||
<LemonBanner type="warning" className="mt-4">
|
||||
Your experiment has been running long enough, but the results are still inconclusive. Continuing the
|
||||
@ -807,7 +809,7 @@ export function ActionBanner(): JSX.Element {
|
||||
|
||||
// Win probability only slightly over 0.9 and the recommended sample/time just met -> proceed with caution
|
||||
if (
|
||||
experimentInsightType === InsightType.FUNNELS &&
|
||||
metricType === InsightType.FUNNELS &&
|
||||
funnelResultsPersonsTotal < recommendedSampleSize + 50 &&
|
||||
winProbability < 0.93
|
||||
) {
|
||||
@ -821,7 +823,7 @@ export function ActionBanner(): JSX.Element {
|
||||
}
|
||||
|
||||
if (
|
||||
experimentInsightType === InsightType.TRENDS &&
|
||||
metricType === InsightType.TRENDS &&
|
||||
actualRunningTime < recommendedRunningTime + 2 &&
|
||||
winProbability < 0.93
|
||||
) {
|
||||
|
@ -1,73 +0,0 @@
|
||||
import './Experiment.scss'
|
||||
|
||||
import { IconCheckCircle } from '@posthog/icons'
|
||||
import clsx from 'clsx'
|
||||
import { IconRadioButtonUnchecked } from 'lib/lemon-ui/icons'
|
||||
import { useState } from 'react'
|
||||
|
||||
export function ExperimentWorkflow(): JSX.Element {
|
||||
const [workflowValidateStepCompleted, setWorkflowValidateStepCompleted] = useState(false)
|
||||
const [workflowLaunchStepCompleted, setWorkflowLaunchStepCompleted] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="border rounded">
|
||||
<div className="card-secondary p-4 border-b">Experiment workflow</div>
|
||||
<div className="p-6 space-y-1.5">
|
||||
<div className="flex">
|
||||
<div className="exp-workflow-step rounded p-2 bg-primary-highlight w-full">
|
||||
<div className="flex items-center">
|
||||
<IconCheckCircle className="text-xl text-primary" />
|
||||
<b className="ml-2">Create experiment</b>
|
||||
</div>
|
||||
<div className="ml-8">Set variants, select participants, and add secondary metrics</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full exp-workflow-step rounded p-2 cursor-pointer',
|
||||
workflowValidateStepCompleted && 'bg-primary-highlight'
|
||||
)}
|
||||
onClick={() => setWorkflowValidateStepCompleted(!workflowValidateStepCompleted)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{workflowValidateStepCompleted ? (
|
||||
<IconCheckCircle className="text-xl text-primary" />
|
||||
) : (
|
||||
<IconRadioButtonUnchecked className="text-xl" />
|
||||
)}
|
||||
<b className="ml-2">Validate experiment</b>
|
||||
</div>
|
||||
<div className="ml-8">
|
||||
Once you've written your code, it's a good idea to test that each variant behaves as
|
||||
you'd expect.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div
|
||||
className={clsx(
|
||||
'w-full exp-workflow-step rounded p-2 cursor-pointer',
|
||||
workflowLaunchStepCompleted && 'bg-primary-highlight'
|
||||
)}
|
||||
onClick={() => setWorkflowLaunchStepCompleted(!workflowLaunchStepCompleted)}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{workflowLaunchStepCompleted ? (
|
||||
<IconCheckCircle className="text-xl text-primary" />
|
||||
) : (
|
||||
<IconRadioButtonUnchecked className="text-xl" />
|
||||
)}
|
||||
<b className="ml-2">Launch experiment</b>
|
||||
</div>
|
||||
<div className="ml-8">
|
||||
Run your experiment, monitor results, and decide when to terminate your experiment.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -1,212 +0,0 @@
|
||||
import './Experiment.scss'
|
||||
|
||||
import { IconInfo } from '@posthog/icons'
|
||||
import { LemonSelect, Link } from '@posthog/lemon-ui'
|
||||
import { BindLogic, useActions, useValues } from 'kea'
|
||||
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
|
||||
import { EXPERIMENT_DEFAULT_DURATION } from 'lib/constants'
|
||||
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
|
||||
import { Tooltip } from 'lib/lemon-ui/Tooltip'
|
||||
import { useEffect } from 'react'
|
||||
import { Attribution } from 'scenes/insights/EditorFilters/AttributionFilter'
|
||||
import { SamplingFilter } from 'scenes/insights/EditorFilters/SamplingFilter'
|
||||
import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
|
||||
import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
|
||||
import { AggregationSelect } from 'scenes/insights/filters/AggregationSelect'
|
||||
import { insightDataLogic } from 'scenes/insights/insightDataLogic'
|
||||
import { insightLogic } from 'scenes/insights/insightLogic'
|
||||
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
|
||||
import { FunnelConversionWindowFilter } from 'scenes/insights/views/Funnels/FunnelConversionWindowFilter'
|
||||
|
||||
import { actionsAndEventsToSeries } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
|
||||
import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
|
||||
import { InsightTestAccountFilter } from '~/queries/nodes/InsightViz/filters/InsightTestAccountFilter'
|
||||
import { Query } from '~/queries/Query/Query'
|
||||
import { FunnelsQuery, InsightQueryNode, TrendsQuery } from '~/queries/schema'
|
||||
import { EditorFilterProps, FilterType, InsightLogicProps, InsightShortId, InsightType } from '~/types'
|
||||
|
||||
export interface MetricSelectorProps {
|
||||
dashboardItemId: InsightShortId
|
||||
setPreviewInsight: (filters?: Partial<FilterType>) => void
|
||||
showDateRangeBanner?: boolean
|
||||
forceTrendExposureMetric?: boolean
|
||||
}
|
||||
|
||||
export function MetricSelector({
|
||||
dashboardItemId,
|
||||
setPreviewInsight,
|
||||
showDateRangeBanner,
|
||||
forceTrendExposureMetric,
|
||||
}: MetricSelectorProps): JSX.Element {
|
||||
// insightLogic
|
||||
const logic = insightLogic({ dashboardItemId, syncWithUrl: false })
|
||||
const { insightProps } = useValues(logic)
|
||||
|
||||
// insightDataLogic
|
||||
const { query } = useValues(insightDataLogic(insightProps))
|
||||
|
||||
// insightVizDataLogic
|
||||
const { isTrends } = useValues(insightVizDataLogic(insightProps))
|
||||
|
||||
useEffect(() => {
|
||||
if (forceTrendExposureMetric && !isTrends) {
|
||||
setPreviewInsight({ insight: InsightType.TRENDS })
|
||||
}
|
||||
}, [forceTrendExposureMetric, isTrends])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center w-full gap-2 mb-4">
|
||||
<span>Insight Type</span>
|
||||
<LemonSelect
|
||||
data-attr="metrics-selector"
|
||||
value={isTrends ? InsightType.TRENDS : InsightType.FUNNELS}
|
||||
onChange={(val) => {
|
||||
val && setPreviewInsight({ insight: val })
|
||||
}}
|
||||
options={[
|
||||
{ value: InsightType.TRENDS, label: <b>Trends</b> },
|
||||
{ value: InsightType.FUNNELS, label: <b>Funnels</b> },
|
||||
]}
|
||||
disabledReason={forceTrendExposureMetric ? 'Exposure metric can only be a trend graph' : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<SamplingFilter
|
||||
insightProps={insightProps}
|
||||
infoTooltipContent="Sampling on experiment goals is an Alpha feature to enable faster computation of experiment results."
|
||||
/>
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<ExperimentInsightCreator insightProps={insightProps} />
|
||||
|
||||
{showDateRangeBanner && (
|
||||
<LemonBanner type="info" className="mt-3 mb-3">
|
||||
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
|
||||
mismatch between the preview and the actual results.
|
||||
</LemonBanner>
|
||||
)}
|
||||
|
||||
<div className="mt-4">
|
||||
<BindLogic logic={insightLogic} props={insightProps}>
|
||||
<Query query={query} context={{ insightProps }} readOnly />
|
||||
</BindLogic>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExperimentInsightCreator({ insightProps }: { insightProps: InsightLogicProps }): JSX.Element {
|
||||
// insightVizDataLogic
|
||||
const { isTrends, series, querySource } = useValues(insightVizDataLogic(insightProps))
|
||||
const { updateQuerySource } = useActions(insightVizDataLogic(insightProps))
|
||||
|
||||
// calculated properties
|
||||
const filterSteps = series || []
|
||||
const isStepsEmpty = filterSteps.length === 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionFilter
|
||||
bordered
|
||||
filters={queryNodeToFilter(querySource as InsightQueryNode)}
|
||||
setFilters={(payload: Partial<FilterType>): void => {
|
||||
updateQuerySource({
|
||||
series: actionsAndEventsToSeries(
|
||||
payload as any,
|
||||
true,
|
||||
isTrends ? MathAvailability.All : MathAvailability.None
|
||||
),
|
||||
} as TrendsQuery | FunnelsQuery)
|
||||
}}
|
||||
typeKey={`experiment-${isTrends ? InsightType.TRENDS : InsightType.FUNNELS}-${
|
||||
insightProps.dashboardItemId
|
||||
}-metric`}
|
||||
mathAvailability={isTrends ? undefined : MathAvailability.None}
|
||||
hideDeleteBtn={isTrends || filterSteps.length === 1}
|
||||
buttonCopy={isTrends ? 'Add graph series' : 'Add funnel step'}
|
||||
showSeriesIndicator={isTrends || !isStepsEmpty}
|
||||
entitiesLimit={isTrends ? 1 : undefined}
|
||||
seriesIndicatorType={isTrends ? undefined : 'numeric'}
|
||||
sortable={isTrends ? undefined : true}
|
||||
showNestedArrow={isTrends ? undefined : true}
|
||||
showNumericalPropsOnly={isTrends}
|
||||
actionsTaxonomicGroupTypes={[
|
||||
TaxonomicFilterGroupType.Events,
|
||||
TaxonomicFilterGroupType.Actions,
|
||||
TaxonomicFilterGroupType.DataWarehouse,
|
||||
]}
|
||||
propertiesTaxonomicGroupTypes={[
|
||||
TaxonomicFilterGroupType.EventProperties,
|
||||
TaxonomicFilterGroupType.PersonProperties,
|
||||
TaxonomicFilterGroupType.EventFeatureFlags,
|
||||
TaxonomicFilterGroupType.Cohorts,
|
||||
TaxonomicFilterGroupType.Elements,
|
||||
TaxonomicFilterGroupType.SessionProperties,
|
||||
TaxonomicFilterGroupType.HogQLExpression,
|
||||
TaxonomicFilterGroupType.DataWarehouseProperties,
|
||||
TaxonomicFilterGroupType.DataWarehousePersonProperties,
|
||||
]}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
{!isTrends && (
|
||||
<>
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<span>Aggregating by</span>
|
||||
<AggregationSelect insightProps={insightProps} hogqlAvailable />
|
||||
</div>
|
||||
<FunnelConversionWindowFilter insightProps={insightProps} />
|
||||
<AttributionSelect insightProps={insightProps} />
|
||||
</>
|
||||
)}
|
||||
<InsightTestAccountFilter query={querySource as InsightQueryNode} setQuery={updateQuerySource} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function AttributionSelect({ insightProps }: EditorFilterProps): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex">
|
||||
<span>Attribution type</span>
|
||||
<Tooltip
|
||||
closeDelayMs={200}
|
||||
title={
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
When breaking down funnels, it's possible that the same properties don't exist on every
|
||||
event. For example, if you want to break down by browser on a funnel that contains both
|
||||
frontend and backend events.
|
||||
</div>
|
||||
<div>
|
||||
In this case, you can choose from which step the properties should be selected from by
|
||||
modifying the attribution type. There are four modes to choose from:
|
||||
</div>
|
||||
<ul className="list-disc pl-4">
|
||||
<li>First touchpoint: the first property value seen in any of the steps is chosen.</li>
|
||||
<li>Last touchpoint: the last property value seen from all steps is chosen.</li>
|
||||
<li>
|
||||
All steps: the property value must be seen in all steps to be considered in the
|
||||
funnel.
|
||||
</li>
|
||||
<li>Specific step: only the property value seen at the selected step is chosen.</li>
|
||||
</ul>
|
||||
<div>
|
||||
Read more in the{' '}
|
||||
<Link to="https://posthog.com/docs/product-analytics/funnels#attribution-types">
|
||||
documentation.
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<IconInfo className="text-xl text-muted-alt shrink-0 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Attribution insightProps={insightProps} />
|
||||
</div>
|
||||
)
|
||||
}
|
309
frontend/src/scenes/experiments/Metrics/PrimaryGoalFunnels.tsx
Normal file
@ -0,0 +1,309 @@
|
||||
import { LemonLabel } from '@posthog/lemon-ui'
|
||||
import { LemonInput } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
|
||||
import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
|
||||
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
|
||||
import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
|
||||
import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
|
||||
import { getHogQLValue } from 'scenes/insights/filters/AggregationSelect'
|
||||
import { teamLogic } from 'scenes/teamLogic'
|
||||
|
||||
import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
|
||||
import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
|
||||
import { Query } from '~/queries/Query/Query'
|
||||
import { ExperimentFunnelsQuery, NodeKind } from '~/queries/schema'
|
||||
import { BreakdownAttributionType, FilterType, FunnelsFilterType } from '~/types'
|
||||
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import {
|
||||
commonActionFilterProps,
|
||||
FunnelAggregationSelect,
|
||||
FunnelAttributionSelect,
|
||||
FunnelConversionWindowFilter,
|
||||
} from './Selectors'
|
||||
export function PrimaryGoalFunnels(): JSX.Element {
|
||||
const { currentTeam } = useValues(teamLogic)
|
||||
const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
|
||||
const { setExperiment, setFunnelsMetric } = useActions(experimentLogic)
|
||||
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
|
||||
|
||||
const metricIdx = 0
|
||||
const currentMetric = experiment.metrics[metricIdx] as ExperimentFunnelsQuery
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<LemonLabel>Name (optional)</LemonLabel>
|
||||
{featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && (
|
||||
<LemonInput
|
||||
value={currentMetric.name}
|
||||
onChange={(newName) => {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
name: newName,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ActionFilter
|
||||
bordered
|
||||
filters={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return queryNodeToFilter(currentMetric.funnels_query)
|
||||
}
|
||||
return experiment.filters
|
||||
})()}
|
||||
setFilters={({ actions, events, data_warehouse }: Partial<FilterType>): void => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const series = actionsAndEventsToSeries(
|
||||
{ actions, events, data_warehouse } as any,
|
||||
true,
|
||||
MathAvailability.None
|
||||
)
|
||||
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
series,
|
||||
})
|
||||
} else {
|
||||
if (actions?.length) {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
actions,
|
||||
events: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
})
|
||||
} else if (events?.length) {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
events,
|
||||
actions: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
})
|
||||
} else if (data_warehouse?.length) {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
data_warehouse,
|
||||
actions: undefined,
|
||||
events: undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
typeKey="experiment-metric"
|
||||
mathAvailability={MathAvailability.None}
|
||||
buttonCopy="Add funnel step"
|
||||
showSeriesIndicator={true}
|
||||
seriesIndicatorType="numeric"
|
||||
sortable={true}
|
||||
showNestedArrow={true}
|
||||
{...commonActionFilterProps}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
<FunnelAggregationSelect
|
||||
value={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return getHogQLValue(
|
||||
currentMetric.funnels_query.aggregation_group_type_index ?? undefined,
|
||||
currentMetric.funnels_query.funnelsFilter?.funnelAggregateByHogQL ?? undefined
|
||||
)
|
||||
}
|
||||
return getHogQLValue(
|
||||
experiment.filters.aggregation_group_type_index,
|
||||
(experiment.filters as FunnelsFilterType).funnel_aggregate_by_hogql
|
||||
)
|
||||
})()}
|
||||
onChange={(value) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
funnelAggregateByHogQL: value,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
funnel_aggregate_by_hogql: value,
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FunnelConversionWindowFilter
|
||||
funnelWindowInterval={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.funnels_query?.funnelsFilter?.funnelWindowInterval
|
||||
}
|
||||
return (experiment.filters as FunnelsFilterType).funnel_window_interval
|
||||
})()}
|
||||
funnelWindowIntervalUnit={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.funnels_query?.funnelsFilter?.funnelWindowIntervalUnit
|
||||
}
|
||||
return (experiment.filters as FunnelsFilterType).funnel_window_interval_unit
|
||||
})()}
|
||||
onFunnelWindowIntervalChange={(funnelWindowInterval) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
funnelWindowInterval: funnelWindowInterval,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
funnel_window_interval: funnelWindowInterval,
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
funnel_window_interval_unit: funnelWindowIntervalUnit || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FunnelAttributionSelect
|
||||
value={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
let breakdownAttributionType
|
||||
let breakdownAttributionValue
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
breakdownAttributionType =
|
||||
currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionType
|
||||
breakdownAttributionValue =
|
||||
currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionValue
|
||||
} else {
|
||||
breakdownAttributionType = (experiment.filters as FunnelsFilterType)
|
||||
.breakdown_attribution_type
|
||||
breakdownAttributionValue = (experiment.filters as FunnelsFilterType)
|
||||
.breakdown_attribution_value
|
||||
}
|
||||
|
||||
const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` =
|
||||
!breakdownAttributionType
|
||||
? BreakdownAttributionType.FirstTouch
|
||||
: breakdownAttributionType === BreakdownAttributionType.Step
|
||||
? `${breakdownAttributionType}/${breakdownAttributionValue || 0}`
|
||||
: breakdownAttributionType
|
||||
|
||||
return currentValue
|
||||
})()}
|
||||
onChange={(value) => {
|
||||
const [breakdownAttributionType, breakdownAttributionValue] = (value || '').split('/')
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType,
|
||||
breakdownAttributionValue: breakdownAttributionValue
|
||||
? parseInt(breakdownAttributionValue)
|
||||
: undefined,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
breakdown_attribution_type: breakdownAttributionType as BreakdownAttributionType,
|
||||
breakdown_attribution_value: breakdownAttributionValue
|
||||
? parseInt(breakdownAttributionValue)
|
||||
: 0,
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
stepsLength={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.funnels_query?.series?.length
|
||||
}
|
||||
return Math.max(
|
||||
experiment.filters.actions?.length ?? 0,
|
||||
experiment.filters.events?.length ?? 0,
|
||||
experiment.filters.data_warehouse?.length ?? 0
|
||||
)
|
||||
})()}
|
||||
/>
|
||||
<TestAccountFilterSwitch
|
||||
checked={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const val = (experiment.metrics[0] as ExperimentFunnelsQuery).funnels_query
|
||||
?.filterTestAccounts
|
||||
return hasFilters ? !!val : false
|
||||
}
|
||||
return hasFilters ? !!experiment.filters.filter_test_accounts : false
|
||||
})()}
|
||||
onChange={(checked: boolean) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
filterTestAccounts: checked,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
filter_test_accounts: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
{isExperimentRunning && (
|
||||
<LemonBanner type="info" className="mt-3 mb-3">
|
||||
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
|
||||
mismatch between the preview and the actual results.
|
||||
</LemonBanner>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
|
||||
<Query
|
||||
query={{
|
||||
kind: NodeKind.InsightVizNode,
|
||||
source: (() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.funnels_query
|
||||
}
|
||||
return filtersToQueryNode(experiment.filters)
|
||||
})(),
|
||||
showTable: false,
|
||||
showLastComputation: true,
|
||||
showLastComputationRefresh: false,
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
160
frontend/src/scenes/experiments/Metrics/PrimaryGoalTrends.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
import { LemonInput, LemonLabel } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
|
||||
import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
|
||||
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
|
||||
import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
|
||||
import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
|
||||
import { teamLogic } from 'scenes/teamLogic'
|
||||
|
||||
import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
|
||||
import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
|
||||
import { Query } from '~/queries/Query/Query'
|
||||
import { ExperimentTrendsQuery, NodeKind } from '~/queries/schema'
|
||||
import { FilterType } from '~/types'
|
||||
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { commonActionFilterProps } from './Selectors'
|
||||
|
||||
export function PrimaryGoalTrends(): JSX.Element {
|
||||
const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
|
||||
const { setExperiment, setTrendsMetric } = useActions(experimentLogic)
|
||||
const { currentTeam } = useValues(teamLogic)
|
||||
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
|
||||
|
||||
const metricIdx = 0
|
||||
const currentMetric = experiment.metrics[metricIdx] as ExperimentTrendsQuery
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<LemonLabel>Name (optional)</LemonLabel>
|
||||
{featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && (
|
||||
<LemonInput
|
||||
value={currentMetric.name}
|
||||
onChange={(newName) => {
|
||||
setTrendsMetric({
|
||||
metricIdx,
|
||||
name: newName,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ActionFilter
|
||||
bordered
|
||||
filters={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return queryNodeToFilter(currentMetric.count_query)
|
||||
}
|
||||
return experiment.filters
|
||||
})()}
|
||||
setFilters={({ actions, events, data_warehouse }: Partial<FilterType>): void => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const series = actionsAndEventsToSeries(
|
||||
{ actions, events, data_warehouse } as any,
|
||||
true,
|
||||
MathAvailability.All
|
||||
)
|
||||
|
||||
setTrendsMetric({
|
||||
metricIdx,
|
||||
series,
|
||||
})
|
||||
} else {
|
||||
if (actions?.length) {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
actions,
|
||||
events: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
})
|
||||
} else if (events?.length) {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
events,
|
||||
actions: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
})
|
||||
} else if (data_warehouse?.length) {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
data_warehouse,
|
||||
actions: undefined,
|
||||
events: undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
typeKey="experiment-metric"
|
||||
buttonCopy="Add graph series"
|
||||
showSeriesIndicator={true}
|
||||
entitiesLimit={1}
|
||||
showNumericalPropsOnly={true}
|
||||
{...commonActionFilterProps}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
<TestAccountFilterSwitch
|
||||
checked={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const val = currentMetric.count_query?.filterTestAccounts
|
||||
return hasFilters ? !!val : false
|
||||
}
|
||||
return hasFilters ? !!experiment.filters.filter_test_accounts : false
|
||||
})()}
|
||||
onChange={(checked: boolean) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setTrendsMetric({
|
||||
metricIdx,
|
||||
filterTestAccounts: checked,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
filters: {
|
||||
...experiment.filters,
|
||||
filter_test_accounts: checked,
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
{isExperimentRunning && (
|
||||
<LemonBanner type="info" className="mt-3 mb-3">
|
||||
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
|
||||
mismatch between the preview and the actual results.
|
||||
</LemonBanner>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
|
||||
<Query
|
||||
query={{
|
||||
kind: NodeKind.InsightVizNode,
|
||||
source: (() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.count_query
|
||||
}
|
||||
return filtersToQueryNode(experiment.filters)
|
||||
})(),
|
||||
showTable: false,
|
||||
showLastComputation: true,
|
||||
showLastComputationRefresh: false,
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1,157 @@
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
|
||||
import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
|
||||
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
|
||||
import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
|
||||
import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
|
||||
import { teamLogic } from 'scenes/teamLogic'
|
||||
|
||||
import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
|
||||
import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
|
||||
import { Query } from '~/queries/Query/Query'
|
||||
import { ExperimentTrendsQuery, InsightQueryNode, NodeKind } from '~/queries/schema'
|
||||
import { FilterType } from '~/types'
|
||||
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { commonActionFilterProps } from './Selectors'
|
||||
|
||||
export function PrimaryGoalTrendsExposure(): JSX.Element {
|
||||
const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
|
||||
const { setExperiment, setTrendsExposureMetric } = useActions(experimentLogic)
|
||||
const { currentTeam } = useValues(teamLogic)
|
||||
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
|
||||
const currentMetric = experiment.metrics[0] as ExperimentTrendsQuery
|
||||
|
||||
return (
|
||||
<>
|
||||
<ActionFilter
|
||||
bordered
|
||||
filters={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return queryNodeToFilter(currentMetric.exposure_query as InsightQueryNode)
|
||||
}
|
||||
return experiment.parameters.custom_exposure_filter as FilterType
|
||||
})()}
|
||||
setFilters={({ actions, events, data_warehouse }: Partial<FilterType>): void => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const series = actionsAndEventsToSeries(
|
||||
{ actions, events, data_warehouse } as any,
|
||||
true,
|
||||
MathAvailability.All
|
||||
)
|
||||
|
||||
setTrendsExposureMetric({
|
||||
metricIdx: 0,
|
||||
series,
|
||||
})
|
||||
} else {
|
||||
if (actions?.length) {
|
||||
setExperiment({
|
||||
parameters: {
|
||||
...experiment.parameters,
|
||||
custom_exposure_filter: {
|
||||
...experiment.parameters.custom_exposure_filter,
|
||||
actions,
|
||||
events: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else if (events?.length) {
|
||||
setExperiment({
|
||||
parameters: {
|
||||
...experiment.parameters,
|
||||
custom_exposure_filter: {
|
||||
...experiment.parameters.custom_exposure_filter,
|
||||
events,
|
||||
actions: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
} else if (data_warehouse?.length) {
|
||||
setExperiment({
|
||||
parameters: {
|
||||
...experiment.parameters,
|
||||
custom_exposure_filter: {
|
||||
...experiment.parameters.custom_exposure_filter,
|
||||
data_warehouse,
|
||||
actions: undefined,
|
||||
events: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
typeKey="experiment-metric"
|
||||
buttonCopy="Add graph series"
|
||||
showSeriesIndicator={true}
|
||||
entitiesLimit={1}
|
||||
showNumericalPropsOnly={true}
|
||||
{...commonActionFilterProps}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
<TestAccountFilterSwitch
|
||||
checked={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const val = currentMetric.exposure_query?.filterTestAccounts
|
||||
return hasFilters ? !!val : false
|
||||
}
|
||||
return hasFilters
|
||||
? !!(experiment.parameters.custom_exposure_filter as FilterType).filter_test_accounts
|
||||
: false
|
||||
})()}
|
||||
onChange={(checked: boolean) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setTrendsExposureMetric({
|
||||
metricIdx: 0,
|
||||
filterTestAccounts: checked,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
parameters: {
|
||||
...experiment.parameters,
|
||||
custom_exposure_filter: {
|
||||
...experiment.parameters.custom_exposure_filter,
|
||||
filter_test_accounts: checked,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
{isExperimentRunning && (
|
||||
<LemonBanner type="info" className="mt-3 mb-3">
|
||||
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
|
||||
mismatch between the preview and the actual results.
|
||||
</LemonBanner>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
|
||||
<Query
|
||||
query={{
|
||||
kind: NodeKind.InsightVizNode,
|
||||
source: (() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.exposure_query
|
||||
}
|
||||
return filtersToQueryNode(experiment.parameters.custom_exposure_filter as FilterType)
|
||||
})(),
|
||||
showTable: false,
|
||||
showLastComputation: true,
|
||||
showLastComputationRefresh: false,
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
import { LemonButton, LemonModal, LemonSelect } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { FEATURE_FLAGS } from 'lib/constants'
|
||||
|
||||
import { ExperimentFunnelsQuery } from '~/queries/schema'
|
||||
import { Experiment, InsightType } from '~/types'
|
||||
|
||||
import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic'
|
||||
import { PrimaryGoalFunnels } from '../Metrics/PrimaryGoalFunnels'
|
||||
import { PrimaryGoalTrends } from '../Metrics/PrimaryGoalTrends'
|
||||
|
||||
export function PrimaryMetricModal({
|
||||
experimentId,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
experimentId: Experiment['id']
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const { experiment, experimentLoading, getMetricType, featureFlags } = useValues(experimentLogic({ experimentId }))
|
||||
const { updateExperimentGoal, setExperiment } = useActions(experimentLogic({ experimentId }))
|
||||
|
||||
const metricIdx = 0
|
||||
const metricType = getMetricType(metricIdx)
|
||||
|
||||
let funnelStepsLength = 0
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL] && metricType === InsightType.FUNNELS) {
|
||||
const metric = experiment.metrics[metricIdx] as ExperimentFunnelsQuery
|
||||
funnelStepsLength = metric?.funnels_query?.series?.length || 0
|
||||
} else {
|
||||
funnelStepsLength = (experiment.filters?.events?.length || 0) + (experiment.filters?.actions?.length || 0)
|
||||
}
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
width={1000}
|
||||
title="Change experiment goal"
|
||||
footer={
|
||||
<div className="flex items-center gap-2">
|
||||
<LemonButton form="edit-experiment-goal-form" type="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
disabledReason={
|
||||
metricType === InsightType.FUNNELS &&
|
||||
funnelStepsLength < 2 &&
|
||||
'The experiment needs at least two funnel steps.'
|
||||
}
|
||||
form="edit-experiment-goal-form"
|
||||
onClick={() => {
|
||||
updateExperimentGoal(experiment.filters)
|
||||
}}
|
||||
type="primary"
|
||||
loading={experimentLoading}
|
||||
data-attr="create-annotation-submit"
|
||||
>
|
||||
Save
|
||||
</LemonButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center w-full gap-2 mb-4">
|
||||
<span>Metric type</span>
|
||||
<LemonSelect
|
||||
data-attr="metrics-selector"
|
||||
value={metricType}
|
||||
onChange={(newMetricType) => {
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setExperiment({
|
||||
...experiment,
|
||||
metrics: [
|
||||
...experiment.metrics.slice(0, metricIdx),
|
||||
newMetricType === InsightType.TRENDS
|
||||
? getDefaultTrendsMetric()
|
||||
: getDefaultFunnelsMetric(),
|
||||
...experiment.metrics.slice(metricIdx + 1),
|
||||
],
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
...experiment,
|
||||
filters: getDefaultFilters(newMetricType, undefined),
|
||||
})
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: InsightType.TRENDS, label: <b>Trends</b> },
|
||||
{ value: InsightType.FUNNELS, label: <b>Funnels</b> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{metricType === InsightType.TRENDS ? <PrimaryGoalTrends /> : <PrimaryGoalFunnels />}
|
||||
</LemonModal>
|
||||
)
|
||||
}
|
@ -0,0 +1,57 @@
|
||||
import { LemonButton, LemonModal } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { FEATURE_FLAGS } from 'lib/constants'
|
||||
|
||||
import { Experiment } from '~/types'
|
||||
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { PrimaryGoalTrendsExposure } from '../Metrics/PrimaryGoalTrendsExposure'
|
||||
|
||||
export function PrimaryTrendsExposureModal({
|
||||
experimentId,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
experimentId: Experiment['id']
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const { experiment, experimentLoading, featureFlags } = useValues(experimentLogic({ experimentId }))
|
||||
const { updateExperimentExposure, updateExperiment } = useActions(experimentLogic({ experimentId }))
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
width={1000}
|
||||
title="Change experiment exposure"
|
||||
footer={
|
||||
<div className="flex items-center gap-2">
|
||||
<LemonButton form="edit-experiment-exposure-form" type="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
form="edit-experiment-exposure-form"
|
||||
onClick={() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
updateExperiment({
|
||||
metrics: experiment.metrics,
|
||||
})
|
||||
} else {
|
||||
updateExperimentExposure(experiment.parameters.custom_exposure_filter ?? null)
|
||||
}
|
||||
}}
|
||||
type="primary"
|
||||
loading={experimentLoading}
|
||||
data-attr="create-annotation-submit"
|
||||
>
|
||||
Save
|
||||
</LemonButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<PrimaryGoalTrendsExposure />
|
||||
</LemonModal>
|
||||
)
|
||||
}
|
391
frontend/src/scenes/experiments/Metrics/SecondaryGoalFunnels.tsx
Normal file
@ -0,0 +1,391 @@
|
||||
import { LemonLabel } from '@posthog/lemon-ui'
|
||||
import { LemonInput } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
|
||||
import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
|
||||
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
|
||||
import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
|
||||
import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
|
||||
import { getHogQLValue } from 'scenes/insights/filters/AggregationSelect'
|
||||
import { teamLogic } from 'scenes/teamLogic'
|
||||
|
||||
import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
|
||||
import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
|
||||
import { Query } from '~/queries/Query/Query'
|
||||
import { ExperimentFunnelsQuery, NodeKind } from '~/queries/schema'
|
||||
import { BreakdownAttributionType, FilterType, FunnelsFilterType } from '~/types'
|
||||
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import {
|
||||
commonActionFilterProps,
|
||||
FunnelAggregationSelect,
|
||||
FunnelAttributionSelect,
|
||||
FunnelConversionWindowFilter,
|
||||
} from './Selectors'
|
||||
|
||||
export function SecondaryGoalFunnels({ metricIdx }: { metricIdx: number }): JSX.Element {
|
||||
const { currentTeam } = useValues(teamLogic)
|
||||
const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
|
||||
const { setExperiment, setFunnelsMetric } = useActions(experimentLogic)
|
||||
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
|
||||
const currentMetric = experiment.metrics_secondary[metricIdx] as ExperimentFunnelsQuery
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<LemonLabel>Name (optional)</LemonLabel>
|
||||
<LemonInput
|
||||
value={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.name
|
||||
}
|
||||
return experiment.secondary_metrics[metricIdx].name
|
||||
})()}
|
||||
onChange={(newName) => {
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
name: newName,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx ? { ...metric, name: newName } : metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ActionFilter
|
||||
bordered
|
||||
filters={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return queryNodeToFilter(currentMetric.funnels_query)
|
||||
}
|
||||
return experiment.secondary_metrics[metricIdx].filters
|
||||
})()}
|
||||
setFilters={({ actions, events, data_warehouse }: Partial<FilterType>): void => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const series = actionsAndEventsToSeries(
|
||||
{ actions, events, data_warehouse } as any,
|
||||
true,
|
||||
MathAvailability.None
|
||||
)
|
||||
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
series,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
if (actions?.length) {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
actions,
|
||||
events: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
} else if (events?.length) {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
events,
|
||||
actions: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
} else if (data_warehouse?.length) {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
data_warehouse,
|
||||
actions: undefined,
|
||||
events: undefined,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
typeKey="experiment-metric"
|
||||
mathAvailability={MathAvailability.None}
|
||||
buttonCopy="Add funnel step"
|
||||
showSeriesIndicator={true}
|
||||
seriesIndicatorType="numeric"
|
||||
sortable={true}
|
||||
showNestedArrow={true}
|
||||
{...commonActionFilterProps}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
<FunnelAggregationSelect
|
||||
value={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return getHogQLValue(
|
||||
currentMetric.funnels_query.aggregation_group_type_index ?? undefined,
|
||||
currentMetric.funnels_query.funnelsFilter?.funnelAggregateByHogQL ?? undefined
|
||||
)
|
||||
}
|
||||
return getHogQLValue(
|
||||
experiment.secondary_metrics[metricIdx].filters.aggregation_group_type_index,
|
||||
(experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType)
|
||||
.funnel_aggregate_by_hogql
|
||||
)
|
||||
})()}
|
||||
onChange={(value) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
funnelAggregateByHogQL: value,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
funnel_aggregate_by_hogql: value,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FunnelConversionWindowFilter
|
||||
funnelWindowInterval={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.funnels_query?.funnelsFilter?.funnelWindowInterval
|
||||
}
|
||||
return (experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType)
|
||||
.funnel_window_interval
|
||||
})()}
|
||||
funnelWindowIntervalUnit={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.funnels_query?.funnelsFilter?.funnelWindowIntervalUnit
|
||||
}
|
||||
return (experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType)
|
||||
.funnel_window_interval_unit
|
||||
})()}
|
||||
onFunnelWindowIntervalChange={(funnelWindowInterval) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
funnelWindowInterval: funnelWindowInterval,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
funnel_window_interval: funnelWindowInterval,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}}
|
||||
onFunnelWindowIntervalUnitChange={(funnelWindowIntervalUnit) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
funnelWindowIntervalUnit: funnelWindowIntervalUnit || undefined,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
funnel_window_interval_unit: funnelWindowIntervalUnit || undefined,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FunnelAttributionSelect
|
||||
value={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
let breakdownAttributionType
|
||||
let breakdownAttributionValue
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
breakdownAttributionType =
|
||||
currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionType
|
||||
breakdownAttributionValue =
|
||||
currentMetric.funnels_query?.funnelsFilter?.breakdownAttributionValue
|
||||
} else {
|
||||
breakdownAttributionType = (
|
||||
experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType
|
||||
).breakdown_attribution_type
|
||||
breakdownAttributionValue = (
|
||||
experiment.secondary_metrics[metricIdx].filters as FunnelsFilterType
|
||||
).breakdown_attribution_value
|
||||
}
|
||||
|
||||
const currentValue: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}` =
|
||||
!breakdownAttributionType
|
||||
? BreakdownAttributionType.FirstTouch
|
||||
: breakdownAttributionType === BreakdownAttributionType.Step
|
||||
? `${breakdownAttributionType}/${breakdownAttributionValue || 0}`
|
||||
: breakdownAttributionType
|
||||
|
||||
return currentValue
|
||||
})()}
|
||||
onChange={(value) => {
|
||||
const [breakdownAttributionType, breakdownAttributionValue] = (value || '').split('/')
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
breakdownAttributionType: breakdownAttributionType as BreakdownAttributionType,
|
||||
breakdownAttributionValue: breakdownAttributionValue
|
||||
? parseInt(breakdownAttributionValue)
|
||||
: undefined,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
breakdown_attribution_type:
|
||||
breakdownAttributionType as BreakdownAttributionType,
|
||||
breakdown_attribution_value: breakdownAttributionValue
|
||||
? parseInt(breakdownAttributionValue)
|
||||
: 0,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}}
|
||||
stepsLength={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.funnels_query?.series?.length
|
||||
}
|
||||
return Math.max(
|
||||
experiment.secondary_metrics[metricIdx].filters.actions?.length ?? 0,
|
||||
experiment.secondary_metrics[metricIdx].filters.events?.length ?? 0,
|
||||
experiment.secondary_metrics[metricIdx].filters.data_warehouse?.length ?? 0
|
||||
)
|
||||
})()}
|
||||
/>
|
||||
<TestAccountFilterSwitch
|
||||
checked={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const val = (experiment.metrics_secondary[metricIdx] as ExperimentFunnelsQuery)
|
||||
.funnels_query?.filterTestAccounts
|
||||
return hasFilters ? !!val : false
|
||||
}
|
||||
return hasFilters
|
||||
? !!experiment.secondary_metrics[metricIdx].filters.filter_test_accounts
|
||||
: false
|
||||
})()}
|
||||
onChange={(checked: boolean) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setFunnelsMetric({
|
||||
metricIdx,
|
||||
filterTestAccounts: checked,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
filter_test_accounts: checked,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
{isExperimentRunning && (
|
||||
<LemonBanner type="info" className="mt-3 mb-3">
|
||||
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
|
||||
mismatch between the preview and the actual results.
|
||||
</LemonBanner>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
|
||||
<Query
|
||||
query={{
|
||||
kind: NodeKind.InsightVizNode,
|
||||
source: (() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.funnels_query
|
||||
}
|
||||
return filtersToQueryNode(experiment.secondary_metrics[metricIdx].filters)
|
||||
})(),
|
||||
showTable: false,
|
||||
showLastComputation: true,
|
||||
showLastComputationRefresh: false,
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
204
frontend/src/scenes/experiments/Metrics/SecondaryGoalTrends.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
import { LemonLabel } from '@posthog/lemon-ui'
|
||||
import { LemonInput } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { TestAccountFilterSwitch } from 'lib/components/TestAccountFiltersSwitch'
|
||||
import { EXPERIMENT_DEFAULT_DURATION, FEATURE_FLAGS } from 'lib/constants'
|
||||
import { LemonBanner } from 'lib/lemon-ui/LemonBanner'
|
||||
import { ActionFilter } from 'scenes/insights/filters/ActionFilter/ActionFilter'
|
||||
import { MathAvailability } from 'scenes/insights/filters/ActionFilter/ActionFilterRow/ActionFilterRow'
|
||||
import { teamLogic } from 'scenes/teamLogic'
|
||||
|
||||
import { actionsAndEventsToSeries, filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
|
||||
import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
|
||||
import { Query } from '~/queries/Query/Query'
|
||||
import { ExperimentTrendsQuery, NodeKind } from '~/queries/schema'
|
||||
import { FilterType } from '~/types'
|
||||
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { commonActionFilterProps } from './Selectors'
|
||||
|
||||
export function SecondaryGoalTrends({ metricIdx }: { metricIdx: number }): JSX.Element {
|
||||
const { experiment, isExperimentRunning, featureFlags } = useValues(experimentLogic)
|
||||
const { setExperiment, setTrendsMetric } = useActions(experimentLogic)
|
||||
const { currentTeam } = useValues(teamLogic)
|
||||
const hasFilters = (currentTeam?.test_account_filters || []).length > 0
|
||||
const currentMetric = experiment.metrics_secondary[metricIdx] as ExperimentTrendsQuery
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4">
|
||||
<LemonLabel>Name (optional)</LemonLabel>
|
||||
<LemonInput
|
||||
value={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.name
|
||||
}
|
||||
return experiment.secondary_metrics[metricIdx].name
|
||||
})()}
|
||||
onChange={(newName) => {
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setTrendsMetric({
|
||||
metricIdx,
|
||||
name: newName,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx ? { ...metric, name: newName } : metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ActionFilter
|
||||
bordered
|
||||
filters={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return queryNodeToFilter(currentMetric.count_query)
|
||||
}
|
||||
return experiment.secondary_metrics[metricIdx].filters
|
||||
})()}
|
||||
setFilters={({ actions, events, data_warehouse }: Partial<FilterType>): void => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const series = actionsAndEventsToSeries(
|
||||
{ actions, events, data_warehouse } as any,
|
||||
true,
|
||||
MathAvailability.All
|
||||
)
|
||||
|
||||
setTrendsMetric({
|
||||
metricIdx,
|
||||
series,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
if (actions?.length) {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
actions,
|
||||
events: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
} else if (events?.length) {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
events,
|
||||
actions: undefined,
|
||||
data_warehouse: undefined,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
} else if (data_warehouse?.length) {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
data_warehouse,
|
||||
actions: undefined,
|
||||
events: undefined,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
typeKey="experiment-metric"
|
||||
buttonCopy="Add graph series"
|
||||
showSeriesIndicator={true}
|
||||
entitiesLimit={1}
|
||||
showNumericalPropsOnly={true}
|
||||
{...commonActionFilterProps}
|
||||
/>
|
||||
<div className="mt-4 space-y-4">
|
||||
<TestAccountFilterSwitch
|
||||
checked={(() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const val = currentMetric.count_query?.filterTestAccounts
|
||||
return hasFilters ? !!val : false
|
||||
}
|
||||
return hasFilters
|
||||
? !!experiment.secondary_metrics[metricIdx].filters.filter_test_accounts
|
||||
: false
|
||||
})()}
|
||||
onChange={(checked: boolean) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setTrendsMetric({
|
||||
metricIdx,
|
||||
filterTestAccounts: checked,
|
||||
isSecondary: true,
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics.map((metric, idx) =>
|
||||
idx === metricIdx
|
||||
? {
|
||||
...metric,
|
||||
filters: {
|
||||
...metric.filters,
|
||||
filter_test_accounts: checked,
|
||||
},
|
||||
}
|
||||
: metric
|
||||
),
|
||||
})
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
{isExperimentRunning && (
|
||||
<LemonBanner type="info" className="mt-3 mb-3">
|
||||
Preview insights are generated based on {EXPERIMENT_DEFAULT_DURATION} days of data. This can cause a
|
||||
mismatch between the preview and the actual results.
|
||||
</LemonBanner>
|
||||
)}
|
||||
<div className="mt-4">
|
||||
{/* :FLAG: CLEAN UP AFTER MIGRATION */}
|
||||
<Query
|
||||
query={{
|
||||
kind: NodeKind.InsightVizNode,
|
||||
source: (() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return currentMetric.count_query
|
||||
}
|
||||
return filtersToQueryNode(experiment.secondary_metrics[metricIdx].filters)
|
||||
})(),
|
||||
showTable: false,
|
||||
showLastComputation: true,
|
||||
showLastComputationRefresh: false,
|
||||
}}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
import { LemonButton, LemonModal } from '@posthog/lemon-ui'
|
||||
import { useValues } from 'kea'
|
||||
|
||||
import { Experiment } from '~/types'
|
||||
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { ResultsQuery } from '../ExperimentView/components'
|
||||
|
||||
export function SecondaryMetricChartModal({
|
||||
experimentId,
|
||||
metricIdx,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
experimentId: Experiment['id']
|
||||
metricIdx: number
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const { secondaryMetricResults } = useValues(experimentLogic({ experimentId }))
|
||||
const targetResults = secondaryMetricResults && secondaryMetricResults[metricIdx]
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
width={1000}
|
||||
title="Results"
|
||||
footer={
|
||||
<LemonButton form="secondary-metric-modal-form" type="secondary" onClick={onClose}>
|
||||
Close
|
||||
</LemonButton>
|
||||
}
|
||||
>
|
||||
<ResultsQuery targetResults={targetResults} showTable={false} />
|
||||
</LemonModal>
|
||||
)
|
||||
}
|
137
frontend/src/scenes/experiments/Metrics/SecondaryMetricModal.tsx
Normal file
@ -0,0 +1,137 @@
|
||||
import { LemonButton, LemonModal, LemonSelect } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { FEATURE_FLAGS } from 'lib/constants'
|
||||
|
||||
import { Experiment, InsightType } from '~/types'
|
||||
|
||||
import { experimentLogic, getDefaultFilters, getDefaultFunnelsMetric, getDefaultTrendsMetric } from '../experimentLogic'
|
||||
import { SecondaryGoalFunnels } from './SecondaryGoalFunnels'
|
||||
import { SecondaryGoalTrends } from './SecondaryGoalTrends'
|
||||
|
||||
export function SecondaryMetricModal({
|
||||
experimentId,
|
||||
metricIdx,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
experimentId: Experiment['id']
|
||||
metricIdx: number
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const { experiment, experimentLoading, getSecondaryMetricType, featureFlags } = useValues(
|
||||
experimentLogic({ experimentId })
|
||||
)
|
||||
const { setExperiment, updateExperiment } = useActions(experimentLogic({ experimentId }))
|
||||
const metricType = getSecondaryMetricType(metricIdx)
|
||||
|
||||
return (
|
||||
<LemonModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
width={1000}
|
||||
title="Change secondary metric"
|
||||
footer={
|
||||
<div className="flex items-center w-full">
|
||||
<LemonButton
|
||||
type="secondary"
|
||||
status="danger"
|
||||
onClick={() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const newMetricsSecondary = experiment.metrics_secondary.filter(
|
||||
(_, idx) => idx !== metricIdx
|
||||
)
|
||||
setExperiment({
|
||||
metrics_secondary: newMetricsSecondary,
|
||||
})
|
||||
updateExperiment({
|
||||
metrics_secondary: newMetricsSecondary,
|
||||
})
|
||||
} else {
|
||||
const newSecondaryMetrics = experiment.secondary_metrics.filter(
|
||||
(_, idx) => idx !== metricIdx
|
||||
)
|
||||
setExperiment({
|
||||
secondary_metrics: newSecondaryMetrics,
|
||||
})
|
||||
updateExperiment({
|
||||
secondary_metrics: newSecondaryMetrics,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</LemonButton>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<LemonButton type="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</LemonButton>
|
||||
<LemonButton
|
||||
onClick={() => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
updateExperiment({
|
||||
metrics_secondary: experiment.metrics_secondary,
|
||||
})
|
||||
} else {
|
||||
updateExperiment({
|
||||
secondary_metrics: experiment.secondary_metrics,
|
||||
})
|
||||
}
|
||||
}}
|
||||
type="primary"
|
||||
loading={experimentLoading}
|
||||
data-attr="create-annotation-submit"
|
||||
>
|
||||
Save
|
||||
</LemonButton>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center w-full gap-2 mb-4">
|
||||
<span>Metric type</span>
|
||||
<LemonSelect
|
||||
data-attr="metrics-selector"
|
||||
value={metricType}
|
||||
onChange={(newMetricType) => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
setExperiment({
|
||||
...experiment,
|
||||
metrics_secondary: [
|
||||
...experiment.metrics_secondary.slice(0, metricIdx),
|
||||
newMetricType === InsightType.TRENDS
|
||||
? getDefaultTrendsMetric()
|
||||
: getDefaultFunnelsMetric(),
|
||||
...experiment.metrics_secondary.slice(metricIdx + 1),
|
||||
],
|
||||
})
|
||||
} else {
|
||||
setExperiment({
|
||||
...experiment,
|
||||
secondary_metrics: [
|
||||
...experiment.secondary_metrics.slice(0, metricIdx),
|
||||
newMetricType === InsightType.TRENDS
|
||||
? { name: '', filters: getDefaultFilters(InsightType.TRENDS, undefined) }
|
||||
: { name: '', filters: getDefaultFilters(InsightType.FUNNELS, undefined) },
|
||||
...experiment.secondary_metrics.slice(metricIdx + 1),
|
||||
],
|
||||
})
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ value: InsightType.TRENDS, label: <b>Trends</b> },
|
||||
{ value: InsightType.FUNNELS, label: <b>Funnels</b> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{metricType === InsightType.TRENDS ? (
|
||||
<SecondaryGoalTrends metricIdx={metricIdx} />
|
||||
) : (
|
||||
<SecondaryGoalFunnels metricIdx={metricIdx} />
|
||||
)}
|
||||
</LemonModal>
|
||||
)
|
||||
}
|
253
frontend/src/scenes/experiments/Metrics/Selectors.tsx
Normal file
@ -0,0 +1,253 @@
|
||||
import { IconInfo } from '@posthog/icons'
|
||||
import { LemonInput, LemonSelect, LemonSelectOption, LemonSelectSection, Link } from '@posthog/lemon-ui'
|
||||
import { useValues } from 'kea'
|
||||
import { HogQLEditor } from 'lib/components/HogQLEditor/HogQLEditor'
|
||||
import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types'
|
||||
import { groupsAccessLogic } from 'lib/introductions/groupsAccessLogic'
|
||||
import { Tooltip } from 'lib/lemon-ui/Tooltip'
|
||||
import { capitalizeFirstLetter, pluralize } from 'lib/utils'
|
||||
import { GroupIntroductionFooter } from 'scenes/groups/GroupsIntroduction'
|
||||
import { FUNNEL_STEP_COUNT_LIMIT } from 'scenes/insights/EditorFilters/FunnelsQuerySteps'
|
||||
import { TIME_INTERVAL_BOUNDS } from 'scenes/insights/views/Funnels/FunnelConversionWindowFilter'
|
||||
|
||||
import { groupsModel } from '~/models/groupsModel'
|
||||
import { BreakdownAttributionType, FunnelConversionWindowTimeUnit, StepOrderValue } from '~/types'
|
||||
|
||||
export const commonActionFilterProps = {
|
||||
actionsTaxonomicGroupTypes: [
|
||||
TaxonomicFilterGroupType.Events,
|
||||
TaxonomicFilterGroupType.Actions,
|
||||
TaxonomicFilterGroupType.DataWarehouse,
|
||||
],
|
||||
propertiesTaxonomicGroupTypes: [
|
||||
TaxonomicFilterGroupType.EventProperties,
|
||||
TaxonomicFilterGroupType.PersonProperties,
|
||||
TaxonomicFilterGroupType.EventFeatureFlags,
|
||||
TaxonomicFilterGroupType.Cohorts,
|
||||
TaxonomicFilterGroupType.Elements,
|
||||
TaxonomicFilterGroupType.SessionProperties,
|
||||
TaxonomicFilterGroupType.HogQLExpression,
|
||||
TaxonomicFilterGroupType.DataWarehouseProperties,
|
||||
TaxonomicFilterGroupType.DataWarehousePersonProperties,
|
||||
],
|
||||
}
|
||||
|
||||
// Forked from https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/insights/filters/AggregationSelect.tsx
|
||||
export function FunnelAggregationSelect({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}): JSX.Element {
|
||||
const { groupTypes, aggregationLabel } = useValues(groupsModel)
|
||||
const { needsUpgradeForGroups, canStartUsingGroups } = useValues(groupsAccessLogic)
|
||||
|
||||
const UNIQUE_USERS = 'person_id'
|
||||
const baseValues = [UNIQUE_USERS]
|
||||
const optionSections: LemonSelectSection<string>[] = [
|
||||
{
|
||||
title: 'Event Aggregation',
|
||||
options: [
|
||||
{
|
||||
value: UNIQUE_USERS,
|
||||
label: 'Unique users',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
if (needsUpgradeForGroups || canStartUsingGroups) {
|
||||
// if (false) {
|
||||
optionSections[0].footer = <GroupIntroductionFooter needsUpgrade={needsUpgradeForGroups} />
|
||||
} else {
|
||||
Array.from(groupTypes.values()).forEach((groupType) => {
|
||||
baseValues.push(`$group_${groupType.group_type_index}`)
|
||||
optionSections[0].options.push({
|
||||
value: `$group_${groupType.group_type_index}`,
|
||||
label: `Unique ${aggregationLabel(groupType.group_type_index).plural}`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
baseValues.push(`properties.$session_id`)
|
||||
optionSections[0].options.push({
|
||||
value: 'properties.$session_id',
|
||||
label: `Unique sessions`,
|
||||
})
|
||||
optionSections[0].options.push({
|
||||
label: 'Custom HogQL expression',
|
||||
options: [
|
||||
{
|
||||
// This is a bit of a hack so that the HogQL option is only highlighted as active when the user has
|
||||
// set a custom value (because actually _all_ the options are HogQL)
|
||||
value: !value || baseValues.includes(value) ? '' : value,
|
||||
label: <span className="font-mono">{value}</span>,
|
||||
labelInMenu: function CustomHogQLOptionWrapped({ onSelect }) {
|
||||
return (
|
||||
// eslint-disable-next-line react/forbid-dom-props
|
||||
<div className="w-120" style={{ maxWidth: 'max(60vw, 20rem)' }}>
|
||||
<HogQLEditor
|
||||
onChange={onSelect}
|
||||
value={value}
|
||||
placeholder={
|
||||
"Enter HogQL expression, such as:\n- distinct_id\n- properties.$session_id\n- concat(distinct_id, ' ', properties.$session_id)\n- if(1 < 2, 'one', 'two')"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<span>Aggregating by</span>
|
||||
<LemonSelect
|
||||
className="flex-1"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={optionSections}
|
||||
dropdownMatchSelectWidth={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Forked from https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/insights/views/Funnels/FunnelConversionWindowFilter.tsx
|
||||
export function FunnelConversionWindowFilter({
|
||||
funnelWindowInterval,
|
||||
funnelWindowIntervalUnit,
|
||||
onFunnelWindowIntervalChange,
|
||||
onFunnelWindowIntervalUnitChange,
|
||||
}: {
|
||||
funnelWindowInterval: number | undefined
|
||||
funnelWindowIntervalUnit: FunnelConversionWindowTimeUnit | undefined
|
||||
onFunnelWindowIntervalChange: (funnelWindowInterval: number | undefined) => void
|
||||
onFunnelWindowIntervalUnitChange: (funnelWindowIntervalUnit: FunnelConversionWindowTimeUnit) => void
|
||||
}): JSX.Element {
|
||||
const options: LemonSelectOption<FunnelConversionWindowTimeUnit>[] = Object.keys(TIME_INTERVAL_BOUNDS).map(
|
||||
(unit) => ({
|
||||
label: capitalizeFirstLetter(pluralize(funnelWindowInterval ?? 7, unit, `${unit}s`, false)),
|
||||
value: unit as FunnelConversionWindowTimeUnit,
|
||||
})
|
||||
)
|
||||
const intervalBounds = TIME_INTERVAL_BOUNDS[funnelWindowIntervalUnit ?? FunnelConversionWindowTimeUnit.Day]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex whitespace-nowrap">
|
||||
Conversion window limit
|
||||
<Tooltip
|
||||
title={
|
||||
<>
|
||||
<b>Recommended!</b> Limit to participants that converted within a specific time frame.
|
||||
Participants that do not convert in this time frame will be considered as drop-offs.
|
||||
</>
|
||||
}
|
||||
>
|
||||
<IconInfo className="w-4 info-indicator" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<LemonInput
|
||||
type="number"
|
||||
className="max-w-20"
|
||||
fullWidth={false}
|
||||
min={intervalBounds[0]}
|
||||
max={intervalBounds[1]}
|
||||
value={funnelWindowInterval}
|
||||
onChange={onFunnelWindowIntervalChange}
|
||||
/>
|
||||
<LemonSelect
|
||||
dropdownMatchSelectWidth={false}
|
||||
value={funnelWindowIntervalUnit}
|
||||
onChange={onFunnelWindowIntervalUnitChange}
|
||||
options={options}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Forked from https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/insights/EditorFilters/AttributionFilter.tsx
|
||||
export function FunnelAttributionSelect({
|
||||
value,
|
||||
onChange,
|
||||
stepsLength,
|
||||
}: {
|
||||
value: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}`
|
||||
onChange: (value: BreakdownAttributionType | `${BreakdownAttributionType.Step}/${number}`) => void
|
||||
stepsLength: number
|
||||
}): JSX.Element {
|
||||
const funnelOrderType = undefined
|
||||
|
||||
return (
|
||||
<div className="flex items-center w-full gap-2">
|
||||
<div className="flex">
|
||||
<span>Attribution type</span>
|
||||
<Tooltip
|
||||
closeDelayMs={200}
|
||||
title={
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
When breaking down funnels, it's possible that the same properties don't exist on every
|
||||
event. For example, if you want to break down by browser on a funnel that contains both
|
||||
frontend and backend events.
|
||||
</div>
|
||||
<div>
|
||||
In this case, you can choose from which step the properties should be selected from by
|
||||
modifying the attribution type. There are four modes to choose from:
|
||||
</div>
|
||||
<ul className="list-disc pl-4">
|
||||
<li>First touchpoint: the first property value seen in any of the steps is chosen.</li>
|
||||
<li>Last touchpoint: the last property value seen from all steps is chosen.</li>
|
||||
<li>
|
||||
All steps: the property value must be seen in all steps to be considered in the
|
||||
funnel.
|
||||
</li>
|
||||
<li>Specific step: only the property value seen at the selected step is chosen.</li>
|
||||
</ul>
|
||||
<div>
|
||||
Read more in the{' '}
|
||||
<Link to="https://posthog.com/docs/product-analytics/funnels#attribution-types">
|
||||
documentation.
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<IconInfo className="text-xl text-muted-alt shrink-0 ml-1" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<LemonSelect
|
||||
value={value}
|
||||
placeholder="Attribution"
|
||||
options={[
|
||||
{ value: BreakdownAttributionType.FirstTouch, label: 'First touchpoint' },
|
||||
{ value: BreakdownAttributionType.LastTouch, label: 'Last touchpoint' },
|
||||
{ value: BreakdownAttributionType.AllSteps, label: 'All steps' },
|
||||
{
|
||||
value: BreakdownAttributionType.Step,
|
||||
label: 'Any step',
|
||||
hidden: funnelOrderType !== StepOrderValue.UNORDERED,
|
||||
},
|
||||
{
|
||||
label: 'Specific step',
|
||||
options: Array(FUNNEL_STEP_COUNT_LIMIT)
|
||||
.fill(null)
|
||||
.map((_, stepIndex) => ({
|
||||
value: `${BreakdownAttributionType.Step}/${stepIndex}` as const,
|
||||
label: `Step ${stepIndex + 1}`,
|
||||
hidden: stepIndex >= stepsLength,
|
||||
})),
|
||||
hidden: funnelOrderType === StepOrderValue.UNORDERED,
|
||||
},
|
||||
]}
|
||||
onChange={onChange}
|
||||
dropdownMaxContentWidth={true}
|
||||
data-attr="breakdown-attributions"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -2,6 +2,12 @@ import { InsightShortId } from '~/types'
|
||||
|
||||
// :TRICKY: `new-` prefix indicates an unsaved insight and slightly alters
|
||||
// behaviour of insight related logics
|
||||
export const EXPERIMENT_INSIGHT_ID = 'new-experiment-insight' as InsightShortId
|
||||
export const EXPERIMENT_EXPOSURE_INSIGHT_ID = 'new-experiment-exposure-insight' as InsightShortId
|
||||
export const SECONDARY_METRIC_INSIGHT_ID = 'new-secondary-metric-insight' as InsightShortId
|
||||
|
||||
export enum MetricInsightId {
|
||||
Trends = 'new-experiment-trends-metric',
|
||||
TrendsExposure = 'new-experiment-trends-exposure',
|
||||
Funnels = 'new-experiment-funnels-metric',
|
||||
SecondaryTrends = 'new-experiment-secondary-trends',
|
||||
SecondaryFunnels = 'new-experiment-secondary-funnels',
|
||||
}
|
||||
|
@ -16,7 +16,6 @@ import { ReactElement } from 'react'
|
||||
import { validateFeatureFlagKey } from 'scenes/feature-flags/featureFlagLogic'
|
||||
import { funnelDataLogic } from 'scenes/funnels/funnelDataLogic'
|
||||
import { insightDataLogic } from 'scenes/insights/insightDataLogic'
|
||||
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
|
||||
import { cleanFilters, getDefaultEvent } from 'scenes/insights/utils/cleanFilters'
|
||||
import { sceneLogic } from 'scenes/sceneLogic'
|
||||
import { Scene } from 'scenes/sceneTypes'
|
||||
@ -26,41 +25,41 @@ import { urls } from 'scenes/urls'
|
||||
|
||||
import { cohortsModel } from '~/models/cohortsModel'
|
||||
import { groupsModel } from '~/models/groupsModel'
|
||||
import { filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
|
||||
import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
|
||||
import { performQuery } from '~/queries/query'
|
||||
import {
|
||||
CachedExperimentFunnelsQueryResponse,
|
||||
CachedExperimentTrendsQueryResponse,
|
||||
ExperimentFunnelsQuery,
|
||||
ExperimentTrendsQuery,
|
||||
FunnelsQuery,
|
||||
InsightVizNode,
|
||||
NodeKind,
|
||||
TrendsQuery,
|
||||
} from '~/queries/schema'
|
||||
import { isFunnelsQuery } from '~/queries/utils'
|
||||
import {
|
||||
ActionFilter as ActionFilterType,
|
||||
Breadcrumb,
|
||||
BreakdownAttributionType,
|
||||
ChartDisplayType,
|
||||
CohortType,
|
||||
CountPerActorMathType,
|
||||
EntityTypes,
|
||||
Experiment,
|
||||
ExperimentResults,
|
||||
FeatureFlagType,
|
||||
FilterType,
|
||||
FunnelConversionWindowTimeUnit,
|
||||
FunnelExperimentVariant,
|
||||
FunnelStep,
|
||||
FunnelVizType,
|
||||
InsightType,
|
||||
MultivariateFlagVariant,
|
||||
PropertyMathType,
|
||||
SecondaryExperimentMetric,
|
||||
SecondaryMetricResults,
|
||||
SignificanceCode,
|
||||
TrendExperimentVariant,
|
||||
TrendResult,
|
||||
TrendsFilterType,
|
||||
} from '~/types'
|
||||
|
||||
import { EXPERIMENT_EXPOSURE_INSIGHT_ID, EXPERIMENT_INSIGHT_ID } from './constants'
|
||||
import { MetricInsightId } from './constants'
|
||||
import type { experimentLogicType } from './experimentLogicType'
|
||||
import { experimentsLogic } from './experimentsLogic'
|
||||
import { holdoutsLogic } from './holdoutsLogic'
|
||||
@ -73,6 +72,7 @@ const NEW_EXPERIMENT: Experiment = {
|
||||
feature_flag_key: '',
|
||||
filters: {},
|
||||
metrics: [],
|
||||
metrics_secondary: [],
|
||||
parameters: {
|
||||
feature_flag_variants: [
|
||||
{ key: 'control', rollout_percentage: 50 },
|
||||
@ -105,12 +105,14 @@ export interface ExperimentResultCalculationError {
|
||||
statusCode: number
|
||||
}
|
||||
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
export interface CachedSecondaryMetricExperimentFunnelsQueryResponse extends CachedExperimentFunnelsQueryResponse {
|
||||
filters?: {
|
||||
insight?: InsightType
|
||||
}
|
||||
}
|
||||
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
export interface CachedSecondaryMetricExperimentTrendsQueryResponse extends CachedExperimentTrendsQueryResponse {
|
||||
filters?: {
|
||||
insight?: InsightType
|
||||
@ -129,16 +131,20 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
['aggregationLabel', 'groupTypes', 'showGroupsOptions'],
|
||||
sceneLogic,
|
||||
['activeScene'],
|
||||
funnelDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }),
|
||||
['results as funnelResults', 'conversionMetrics'],
|
||||
trendsDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }),
|
||||
['results as trendResults'],
|
||||
insightDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }),
|
||||
['insightDataLoading as goalInsightDataLoading'],
|
||||
featureFlagLogic,
|
||||
['featureFlags'],
|
||||
holdoutsLogic,
|
||||
['holdouts'],
|
||||
// Hook the insight state to get the results for the sample size estimation
|
||||
funnelDataLogic({ dashboardItemId: MetricInsightId.Funnels }),
|
||||
['results as funnelResults', 'conversionMetrics'],
|
||||
trendsDataLogic({ dashboardItemId: MetricInsightId.Trends }),
|
||||
['results as trendResults'],
|
||||
// Hook into the loading state of the metric insight
|
||||
insightDataLogic({ dashboardItemId: MetricInsightId.Trends }),
|
||||
['insightDataLoading as trendMetricInsightLoading'],
|
||||
insightDataLogic({ dashboardItemId: MetricInsightId.Funnels }),
|
||||
['insightDataLoading as funnelMetricInsightLoading'],
|
||||
],
|
||||
actions: [
|
||||
experimentsLogic,
|
||||
@ -157,24 +163,13 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
'reportExperimentResultsLoadingTimeout',
|
||||
'reportExperimentReleaseConditionsViewed',
|
||||
],
|
||||
insightDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }),
|
||||
['setQuery'],
|
||||
insightVizDataLogic({ dashboardItemId: EXPERIMENT_INSIGHT_ID }),
|
||||
['updateQuerySource'],
|
||||
insightDataLogic({ dashboardItemId: EXPERIMENT_EXPOSURE_INSIGHT_ID }),
|
||||
['setQuery as setExposureQuery'],
|
||||
insightVizDataLogic({ dashboardItemId: EXPERIMENT_EXPOSURE_INSIGHT_ID }),
|
||||
['updateQuerySource as updateExposureQuerySource'],
|
||||
],
|
||||
})),
|
||||
actions({
|
||||
setExperimentMissing: true,
|
||||
setExperiment: (experiment: Partial<Experiment>) => ({ experiment }),
|
||||
createExperiment: (draft?: boolean) => ({ draft }),
|
||||
setExperimentFeatureFlagKeyFromName: true,
|
||||
setNewExperimentInsight: (filters?: Partial<FilterType>) => ({ filters }),
|
||||
setExperimentType: (type?: string) => ({ type }),
|
||||
setExperimentExposureInsight: (filters?: Partial<FilterType>) => ({ filters }),
|
||||
removeExperimentGroup: (idx: number) => ({ idx }),
|
||||
setEditExperiment: (editing: boolean) => ({ editing }),
|
||||
setExperimentResultCalculationError: (error: ExperimentResultCalculationError) => ({ error }),
|
||||
@ -183,7 +178,6 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
updateExperimentGoal: (filters: Partial<FilterType>) => ({ filters }),
|
||||
updateExperimentCollectionGoal: true,
|
||||
updateExperimentExposure: (filters: Partial<FilterType> | null) => ({ filters }),
|
||||
updateExperimentSecondaryMetrics: (metrics: SecondaryExperimentMetric[]) => ({ metrics }),
|
||||
changeExperimentStartDate: (startDate: string) => ({ startDate }),
|
||||
launchExperiment: true,
|
||||
endExperiment: true,
|
||||
@ -191,10 +185,6 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
archiveExperiment: true,
|
||||
resetRunningExperiment: true,
|
||||
checkFlagImplementationWarning: true,
|
||||
openExperimentGoalModal: true,
|
||||
closeExperimentGoalModal: true,
|
||||
openExperimentExposureModal: true,
|
||||
closeExperimentExposureModal: true,
|
||||
openExperimentCollectionGoalModal: true,
|
||||
closeExperimentCollectionGoalModal: true,
|
||||
openShipVariantModal: true,
|
||||
@ -203,7 +193,70 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
closeDistributionModal: true,
|
||||
openReleaseConditionsModal: true,
|
||||
closeReleaseConditionsModal: true,
|
||||
updateExperimentVariantImages: (variantPreviewMediaIds: Record<string, string>) => ({ variantPreviewMediaIds }),
|
||||
updateExperimentVariantImages: (variantPreviewMediaIds: Record<string, string[]>) => ({
|
||||
variantPreviewMediaIds,
|
||||
}),
|
||||
setTrendsMetric: ({
|
||||
metricIdx,
|
||||
name,
|
||||
series,
|
||||
filterTestAccounts,
|
||||
isSecondary = false,
|
||||
}: {
|
||||
metricIdx: number
|
||||
name?: string
|
||||
series?: any[]
|
||||
filterTestAccounts?: boolean
|
||||
isSecondary?: boolean
|
||||
}) => ({ metricIdx, name, series, filterTestAccounts, isSecondary }),
|
||||
setTrendsExposureMetric: ({
|
||||
metricIdx,
|
||||
name,
|
||||
series,
|
||||
filterTestAccounts,
|
||||
}: {
|
||||
metricIdx: number
|
||||
name?: string
|
||||
series?: any[]
|
||||
filterTestAccounts?: boolean
|
||||
}) => ({ metricIdx, name, series, filterTestAccounts }),
|
||||
setFunnelsMetric: ({
|
||||
metricIdx,
|
||||
name,
|
||||
series,
|
||||
filterTestAccounts,
|
||||
breakdownAttributionType,
|
||||
breakdownAttributionValue,
|
||||
funnelWindowInterval,
|
||||
funnelWindowIntervalUnit,
|
||||
aggregation_group_type_index,
|
||||
funnelAggregateByHogQL,
|
||||
isSecondary = false,
|
||||
}: {
|
||||
metricIdx: number
|
||||
name?: string
|
||||
series?: any[]
|
||||
filterTestAccounts?: boolean
|
||||
breakdownAttributionType?: BreakdownAttributionType
|
||||
breakdownAttributionValue?: number
|
||||
funnelWindowInterval?: number
|
||||
funnelWindowIntervalUnit?: string
|
||||
aggregation_group_type_index?: number
|
||||
funnelAggregateByHogQL?: string
|
||||
isSecondary?: boolean
|
||||
}) => ({
|
||||
metricIdx,
|
||||
name,
|
||||
series,
|
||||
filterTestAccounts,
|
||||
breakdownAttributionType,
|
||||
breakdownAttributionValue,
|
||||
funnelWindowInterval,
|
||||
funnelWindowIntervalUnit,
|
||||
aggregation_group_type_index,
|
||||
funnelAggregateByHogQL,
|
||||
isSecondary,
|
||||
}),
|
||||
setTabKey: (tabKey: string) => ({ tabKey }),
|
||||
}),
|
||||
reducers({
|
||||
@ -211,15 +264,6 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
{ ...NEW_EXPERIMENT } as Experiment,
|
||||
{
|
||||
setExperiment: (state, { experiment }) => {
|
||||
if (experiment.filters) {
|
||||
return { ...state, ...experiment, filters: experiment.filters }
|
||||
}
|
||||
|
||||
// assuming setExperiment isn't called with new filters & parameters at the same time
|
||||
if (experiment.parameters) {
|
||||
const newParameters = { ...state?.parameters, ...experiment.parameters }
|
||||
return { ...state, ...experiment, parameters: newParameters }
|
||||
}
|
||||
return { ...state, ...experiment }
|
||||
},
|
||||
addExperimentGroup: (state) => {
|
||||
@ -271,6 +315,89 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
},
|
||||
}
|
||||
},
|
||||
setTrendsMetric: (state, { metricIdx, name, series, filterTestAccounts, isSecondary }) => {
|
||||
const metricsKey = isSecondary ? 'metrics_secondary' : 'metrics'
|
||||
const metrics = [...(state?.[metricsKey] || [])]
|
||||
const metric = metrics[metricIdx]
|
||||
|
||||
metrics[metricIdx] = {
|
||||
...metric,
|
||||
...(name !== undefined && { name }),
|
||||
count_query: {
|
||||
...(metric as ExperimentTrendsQuery).count_query,
|
||||
...(series && { series }),
|
||||
...(filterTestAccounts !== undefined && { filterTestAccounts }),
|
||||
},
|
||||
} as ExperimentTrendsQuery
|
||||
|
||||
return {
|
||||
...state,
|
||||
[metricsKey]: metrics,
|
||||
}
|
||||
},
|
||||
setTrendsExposureMetric: (state, { metricIdx, name, series, filterTestAccounts }) => {
|
||||
const metrics = [...(state?.metrics || [])]
|
||||
const metric = metrics[metricIdx]
|
||||
|
||||
metrics[metricIdx] = {
|
||||
...metric,
|
||||
...(name !== undefined && { name }),
|
||||
exposure_query: {
|
||||
...(metric as ExperimentTrendsQuery).exposure_query,
|
||||
...(series && { series }),
|
||||
...(filterTestAccounts !== undefined && { filterTestAccounts }),
|
||||
},
|
||||
} as ExperimentTrendsQuery
|
||||
|
||||
return {
|
||||
...state,
|
||||
metrics,
|
||||
}
|
||||
},
|
||||
setFunnelsMetric: (
|
||||
state,
|
||||
{
|
||||
metricIdx,
|
||||
name,
|
||||
series,
|
||||
filterTestAccounts,
|
||||
breakdownAttributionType,
|
||||
breakdownAttributionValue,
|
||||
funnelWindowInterval,
|
||||
funnelWindowIntervalUnit,
|
||||
aggregation_group_type_index,
|
||||
funnelAggregateByHogQL,
|
||||
isSecondary,
|
||||
}
|
||||
) => {
|
||||
const metricsKey = isSecondary ? 'metrics_secondary' : 'metrics'
|
||||
const metrics = [...(state?.[metricsKey] || [])]
|
||||
const metric = metrics[metricIdx]
|
||||
|
||||
metrics[metricIdx] = {
|
||||
...metric,
|
||||
...(name !== undefined && { name }),
|
||||
funnels_query: {
|
||||
...(metric as ExperimentFunnelsQuery).funnels_query,
|
||||
...(series && { series }),
|
||||
...(filterTestAccounts !== undefined && { filterTestAccounts }),
|
||||
...(aggregation_group_type_index !== undefined && { aggregation_group_type_index }),
|
||||
funnelsFilter: {
|
||||
...(metric as ExperimentFunnelsQuery).funnels_query.funnelsFilter,
|
||||
...(breakdownAttributionType && { breakdownAttributionType }),
|
||||
...(breakdownAttributionValue !== undefined && { breakdownAttributionValue }),
|
||||
...(funnelWindowInterval !== undefined && { funnelWindowInterval }),
|
||||
...(funnelWindowIntervalUnit && { funnelWindowIntervalUnit }),
|
||||
...(funnelAggregateByHogQL !== undefined && { funnelAggregateByHogQL }),
|
||||
},
|
||||
},
|
||||
} as ExperimentFunnelsQuery
|
||||
|
||||
return {
|
||||
...state,
|
||||
[metricsKey]: metrics,
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
experimentMissing: [
|
||||
@ -285,22 +412,6 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
setEditExperiment: (_, { editing }) => editing,
|
||||
},
|
||||
],
|
||||
changingGoalMetric: [
|
||||
false,
|
||||
{
|
||||
updateExperimentGoal: () => true,
|
||||
updateExperimentExposure: () => true,
|
||||
changeExperimentStartDate: () => true,
|
||||
loadExperimentResults: () => false,
|
||||
},
|
||||
],
|
||||
changingSecondaryMetrics: [
|
||||
false,
|
||||
{
|
||||
updateExperimentSecondaryMetrics: () => true,
|
||||
loadSecondaryMetricResults: () => false,
|
||||
},
|
||||
],
|
||||
experimentResultCalculationError: [
|
||||
null as ExperimentResultCalculationError | null,
|
||||
{
|
||||
@ -313,27 +424,6 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
setFlagImplementationWarning: (_, { warning }) => warning,
|
||||
},
|
||||
],
|
||||
// TODO: delete with the old UI
|
||||
exposureAndSampleSize: [
|
||||
{ exposure: 0, sampleSize: 0 } as { exposure: number; sampleSize: number },
|
||||
{
|
||||
setExposureAndSampleSize: (_, { exposure, sampleSize }) => ({ exposure, sampleSize }),
|
||||
},
|
||||
],
|
||||
isExperimentGoalModalOpen: [
|
||||
false,
|
||||
{
|
||||
openExperimentGoalModal: () => true,
|
||||
closeExperimentGoalModal: () => false,
|
||||
},
|
||||
],
|
||||
isExperimentExposureModalOpen: [
|
||||
false,
|
||||
{
|
||||
openExperimentExposureModal: () => true,
|
||||
closeExperimentExposureModal: () => false,
|
||||
},
|
||||
],
|
||||
isExperimentCollectionGoalModalOpen: [
|
||||
false,
|
||||
{
|
||||
@ -467,79 +557,6 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
setExperimentType: async ({ type }) => {
|
||||
actions.setExperiment({ type: type })
|
||||
},
|
||||
setNewExperimentInsight: async ({ filters }) => {
|
||||
let newInsightFilters
|
||||
const aggregationGroupTypeIndex = values.experiment.parameters?.aggregation_group_type_index
|
||||
if (filters?.insight === InsightType.TRENDS) {
|
||||
const groupAggregation =
|
||||
aggregationGroupTypeIndex !== undefined
|
||||
? { math: 'unique_group', math_group_type_index: aggregationGroupTypeIndex }
|
||||
: {}
|
||||
const eventAddition =
|
||||
filters?.actions || filters?.events
|
||||
? {}
|
||||
: { events: [{ ...getDefaultEvent(), ...groupAggregation }] }
|
||||
newInsightFilters = cleanFilters({
|
||||
insight: InsightType.TRENDS,
|
||||
date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
...eventAddition,
|
||||
...filters,
|
||||
})
|
||||
} else {
|
||||
newInsightFilters = cleanFilters({
|
||||
insight: InsightType.FUNNELS,
|
||||
funnel_viz_type: FunnelVizType.Steps,
|
||||
date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
layout: FunnelLayout.horizontal,
|
||||
aggregation_group_type_index: aggregationGroupTypeIndex,
|
||||
...filters,
|
||||
})
|
||||
}
|
||||
|
||||
// This allows switching between insight types. It's necessary as `updateQuerySource` merges
|
||||
// the new query with any existing query and that causes validation problems when there are
|
||||
// unsupported properties in the now merged query.
|
||||
const newQuery = filtersToQueryNode(newInsightFilters)
|
||||
if (newInsightFilters?.insight === InsightType.FUNNELS) {
|
||||
;(newQuery as TrendsQuery).trendsFilter = undefined
|
||||
} else {
|
||||
;(newQuery as FunnelsQuery).funnelsFilter = undefined
|
||||
}
|
||||
|
||||
// TRICKY: We always know what the group type index should be for funnel queries, so we don't care
|
||||
// what the previous value was. Hence, instead of a partial update with `updateQuerySource`, we always
|
||||
// explicitly set it to what it should be
|
||||
if (isFunnelsQuery(newQuery)) {
|
||||
newQuery.aggregation_group_type_index = aggregationGroupTypeIndex
|
||||
}
|
||||
|
||||
actions.updateQuerySource(newQuery)
|
||||
},
|
||||
// sync form value `filters` with query
|
||||
setQuery: ({ query }) => {
|
||||
actions.setExperiment({ filters: queryNodeToFilter((query as InsightVizNode).source) })
|
||||
},
|
||||
setExperimentExposureInsight: async ({ filters }) => {
|
||||
const newInsightFilters = cleanFilters({
|
||||
insight: InsightType.TRENDS,
|
||||
date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
...filters,
|
||||
})
|
||||
|
||||
actions.updateExposureQuerySource(filtersToQueryNode(newInsightFilters))
|
||||
},
|
||||
// sync form value `filters` with query
|
||||
setExposureQuery: ({ query }) => {
|
||||
actions.setExperiment({
|
||||
parameters: {
|
||||
custom_exposure_filter: queryNodeToFilter((query as InsightVizNode).source),
|
||||
feature_flag_variants: values.experiment?.parameters?.feature_flag_variants,
|
||||
},
|
||||
})
|
||||
},
|
||||
loadExperimentSuccess: async ({ experiment }) => {
|
||||
experiment && actions.reportExperimentViewed(experiment)
|
||||
|
||||
@ -578,18 +595,13 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
})
|
||||
|
||||
const { recommendedRunningTime, recommendedSampleSize, minimumDetectableEffect } = values
|
||||
if (!minimumDetectableEffect) {
|
||||
eventUsageLogic.actions.reportExperimentInsightLoadFailed()
|
||||
return lemonToast.error(
|
||||
'Failed to load insight. Experiment cannot be saved without this value. Try changing the experiment goal.'
|
||||
)
|
||||
}
|
||||
|
||||
const filtersToUpdate = { ...filters }
|
||||
delete filtersToUpdate.properties
|
||||
|
||||
actions.updateExperiment({
|
||||
filters: filtersToUpdate,
|
||||
metrics: values.experiment.metrics,
|
||||
parameters: {
|
||||
...values.experiment?.parameters,
|
||||
recommended_running_time: recommendedRunningTime,
|
||||
@ -597,7 +609,6 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
minimum_detectable_effect: minimumDetectableEffect,
|
||||
},
|
||||
})
|
||||
actions.closeExperimentGoalModal()
|
||||
},
|
||||
updateExperimentCollectionGoal: async () => {
|
||||
const { recommendedRunningTime, recommendedSampleSize, minimumDetectableEffect } = values
|
||||
@ -614,25 +625,12 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
},
|
||||
updateExperimentExposure: async ({ filters }) => {
|
||||
actions.updateExperiment({
|
||||
metrics: values.experiment.metrics,
|
||||
parameters: {
|
||||
custom_exposure_filter: filters ?? undefined,
|
||||
feature_flag_variants: values.experiment?.parameters?.feature_flag_variants,
|
||||
},
|
||||
})
|
||||
actions.closeExperimentExposureModal()
|
||||
},
|
||||
updateExperimentSecondaryMetrics: async ({ metrics }) => {
|
||||
actions.updateExperiment({ secondary_metrics: metrics })
|
||||
},
|
||||
closeExperimentGoalModal: () => {
|
||||
if (values.experimentValuesChangedLocally) {
|
||||
actions.loadExperiment()
|
||||
}
|
||||
},
|
||||
closeExperimentExposureModal: () => {
|
||||
if (values.experimentValuesChangedLocally) {
|
||||
actions.loadExperiment()
|
||||
}
|
||||
},
|
||||
closeExperimentCollectionGoalModal: () => {
|
||||
if (values.experimentValuesChangedLocally) {
|
||||
@ -648,15 +646,8 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
},
|
||||
updateExperimentSuccess: async ({ experiment }) => {
|
||||
actions.updateExperiments(experiment)
|
||||
if (values.changingGoalMetric) {
|
||||
actions.loadExperimentResults()
|
||||
}
|
||||
if (values.changingSecondaryMetrics && values.experiment?.start_date) {
|
||||
actions.loadSecondaryMetricResults()
|
||||
}
|
||||
if (values.experiment?.start_date) {
|
||||
actions.loadExperimentResults()
|
||||
}
|
||||
actions.loadExperimentResults()
|
||||
actions.loadSecondaryMetricResults()
|
||||
},
|
||||
setExperiment: async ({ experiment }) => {
|
||||
const experimentEntitiesChanged =
|
||||
@ -729,12 +720,6 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
}
|
||||
}
|
||||
},
|
||||
openExperimentGoalModal: async () => {
|
||||
actions.setNewExperimentInsight(values.experiment?.filters)
|
||||
},
|
||||
openExperimentExposureModal: async () => {
|
||||
actions.setExperimentExposureInsight(values.experiment?.parameters?.custom_exposure_filter)
|
||||
},
|
||||
createExposureCohortSuccess: ({ exposureCohort }) => {
|
||||
if (exposureCohort && exposureCohort.id !== 'new') {
|
||||
cohortsModel.actions.cohortCreated(exposureCohort)
|
||||
@ -821,18 +806,19 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
| null
|
||||
> => {
|
||||
try {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const query = values.experiment.metrics[0].query
|
||||
// Queries are shareable, so we need to set the experiment_id for the backend to correctly associate the query with the experiment
|
||||
const queryWithExperimentId = {
|
||||
...values.experiment.metrics[0],
|
||||
experiment_id: values.experimentId,
|
||||
}
|
||||
|
||||
const response: ExperimentResults = await api.create(
|
||||
`api/projects/${values.currentTeamId}/query`,
|
||||
{ query }
|
||||
)
|
||||
const response = await performQuery(queryWithExperimentId, undefined, refresh)
|
||||
|
||||
return {
|
||||
...response,
|
||||
fakeInsightId: Math.random().toString(36).substring(2, 15),
|
||||
last_refresh: response.last_refresh || '',
|
||||
} as unknown as CachedExperimentTrendsQueryResponse | CachedExperimentFunnelsQueryResponse
|
||||
}
|
||||
|
||||
@ -846,7 +832,13 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
last_refresh: response.last_refresh,
|
||||
}
|
||||
} catch (error: any) {
|
||||
actions.setExperimentResultCalculationError({ detail: error.detail, statusCode: error.status })
|
||||
let errorDetail = error.detail
|
||||
// :HANDLE FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const errorDetailMatch = error.detail.match(/\{.*\}/)
|
||||
errorDetail = errorDetailMatch[0]
|
||||
}
|
||||
actions.setExperimentResultCalculationError({ detail: errorDetail, statusCode: error.status })
|
||||
if (error.status === 504) {
|
||||
actions.reportExperimentResultsLoadingTimeout(values.experimentId)
|
||||
}
|
||||
@ -869,15 +861,17 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
| null
|
||||
> => {
|
||||
if (values.featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const secondaryMetrics =
|
||||
values.experiment?.metrics?.filter((metric) => metric.type === 'secondary') || []
|
||||
|
||||
return (await Promise.all(
|
||||
secondaryMetrics.map(async (metric) => {
|
||||
values.experiment?.metrics_secondary.map(async (metric) => {
|
||||
try {
|
||||
// Queries are shareable, so we need to set the experiment_id for the backend to correctly associate the query with the experiment
|
||||
const queryWithExperimentId = {
|
||||
...metric,
|
||||
experiment_id: values.experimentId,
|
||||
}
|
||||
const response: ExperimentResults = await api.create(
|
||||
`api/projects/${values.currentTeamId}/query`,
|
||||
{ query: metric.query }
|
||||
{ query: queryWithExperimentId, refresh: 'lazy_async' }
|
||||
)
|
||||
|
||||
return {
|
||||
@ -970,16 +964,29 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
() => [(_, props) => props.experimentId ?? 'new'],
|
||||
(experimentId): Experiment['id'] => experimentId,
|
||||
],
|
||||
experimentInsightType: [
|
||||
getMetricType: [
|
||||
(s) => [s.experiment, s.featureFlags],
|
||||
(experiment, featureFlags): InsightType => {
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const query = experiment?.metrics?.[0]?.query
|
||||
return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS
|
||||
}
|
||||
(experiment, featureFlags) =>
|
||||
(metricIdx: number = 0) => {
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const query = experiment?.metrics?.[metricIdx]
|
||||
return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS
|
||||
}
|
||||
|
||||
return experiment?.filters?.insight || InsightType.FUNNELS
|
||||
},
|
||||
return experiment?.filters?.insight || InsightType.FUNNELS
|
||||
},
|
||||
],
|
||||
getSecondaryMetricType: [
|
||||
(s) => [s.experiment, s.featureFlags],
|
||||
(experiment, featureFlags) =>
|
||||
(metricIdx: number = 0) => {
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const query = experiment?.metrics_secondary?.[metricIdx]
|
||||
return query?.kind === NodeKind.ExperimentTrendsQuery ? InsightType.TRENDS : InsightType.FUNNELS
|
||||
}
|
||||
|
||||
return experiment?.secondary_metrics?.[metricIdx]?.filters?.insight || InsightType.FUNNELS
|
||||
},
|
||||
],
|
||||
isExperimentRunning: [
|
||||
(s) => [s.experiment],
|
||||
@ -1028,7 +1035,7 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
let entities: { math?: string }[] = []
|
||||
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
const query = experiment?.metrics?.[0]?.query as ExperimentTrendsQuery
|
||||
const query = experiment?.metrics?.[0] as ExperimentTrendsQuery
|
||||
if (!query) {
|
||||
return undefined
|
||||
}
|
||||
@ -1059,12 +1066,12 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
},
|
||||
],
|
||||
minimumDetectableEffect: [
|
||||
(s) => [s.experiment, s.experimentInsightType, s.conversionMetrics, s.trendResults],
|
||||
(newExperiment, experimentInsightType, conversionMetrics, trendResults): number => {
|
||||
(s) => [s.experiment, s.getMetricType, s.conversionMetrics, s.trendResults],
|
||||
(newExperiment, getMetricType, conversionMetrics, trendResults): number => {
|
||||
return (
|
||||
newExperiment?.parameters?.minimum_detectable_effect ||
|
||||
// :KLUDGE: extracted the method due to difficulties with logic tests
|
||||
getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults) ||
|
||||
getMinimumDetectableEffect(getMetricType(0), conversionMetrics, trendResults) ||
|
||||
0
|
||||
)
|
||||
},
|
||||
@ -1175,7 +1182,7 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
(s) => [
|
||||
s.experiment,
|
||||
s.variants,
|
||||
s.experimentInsightType,
|
||||
s.getMetricType,
|
||||
s.funnelResults,
|
||||
s.conversionMetrics,
|
||||
s.expectedRunningTime,
|
||||
@ -1186,7 +1193,7 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
(
|
||||
experiment,
|
||||
variants,
|
||||
experimentInsightType,
|
||||
getMetricType,
|
||||
funnelResults,
|
||||
conversionMetrics,
|
||||
expectedRunningTime,
|
||||
@ -1194,7 +1201,7 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
minimumSampleSizePerVariant,
|
||||
recommendedExposureForCountData
|
||||
): number => {
|
||||
if (experimentInsightType === InsightType.FUNNELS) {
|
||||
if (getMetricType(0) === InsightType.FUNNELS) {
|
||||
const currentDuration = dayjs().diff(dayjs(experiment?.start_date), 'hour')
|
||||
const funnelEntrants = funnelResults?.[0]?.count
|
||||
|
||||
@ -1282,14 +1289,15 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
| CachedSecondaryMetricExperimentFunnelsQueryResponse
|
||||
| CachedSecondaryMetricExperimentTrendsQueryResponse
|
||||
| null,
|
||||
variantKey: string
|
||||
variantKey: string,
|
||||
metricType: InsightType
|
||||
): [number, number] | null => {
|
||||
const credibleInterval = experimentResults?.credible_intervals?.[variantKey]
|
||||
if (!credibleInterval) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (experimentResults.filters?.insight === InsightType.FUNNELS) {
|
||||
if (metricType === InsightType.FUNNELS) {
|
||||
const controlVariant = (experimentResults.variants as FunnelExperimentVariant[]).find(
|
||||
({ key }) => key === 'control'
|
||||
) as FunnelExperimentVariant
|
||||
@ -1321,8 +1329,8 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
},
|
||||
],
|
||||
getIndexForVariant: [
|
||||
(s) => [s.experimentInsightType],
|
||||
(experimentInsightType) =>
|
||||
(s) => [s.getMetricType],
|
||||
(getMetricType) =>
|
||||
(
|
||||
experimentResults:
|
||||
| Partial<ExperimentResults['result']>
|
||||
@ -1338,7 +1346,7 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
}
|
||||
|
||||
let index = -1
|
||||
if (experimentInsightType === InsightType.FUNNELS) {
|
||||
if (getMetricType(0) === InsightType.FUNNELS) {
|
||||
// Funnel Insight is displayed in order of decreasing count
|
||||
index = (Array.isArray(experimentResults.insight) ? [...experimentResults.insight] : [])
|
||||
.sort((a, b) => {
|
||||
@ -1360,7 +1368,7 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
}
|
||||
const result = index === -1 ? null : index
|
||||
|
||||
if (result !== null && experimentInsightType === InsightType.FUNNELS) {
|
||||
if (result !== null && getMetricType(0) === InsightType.FUNNELS) {
|
||||
return result + 1
|
||||
}
|
||||
return result
|
||||
@ -1479,16 +1487,17 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
},
|
||||
],
|
||||
tabularExperimentResults: [
|
||||
(s) => [s.experiment, s.experimentResults, s.experimentInsightType],
|
||||
(experiment, experimentResults, experimentInsightType): any => {
|
||||
(s) => [s.experiment, s.experimentResults, s.getMetricType],
|
||||
(experiment, experimentResults, getMetricType): any => {
|
||||
const tabularResults = []
|
||||
const metricType = getMetricType(0)
|
||||
|
||||
if (experimentResults) {
|
||||
for (const variantObj of experimentResults.variants) {
|
||||
if (experimentInsightType === InsightType.FUNNELS) {
|
||||
if (metricType === InsightType.FUNNELS) {
|
||||
const { key, success_count, failure_count } = variantObj as FunnelExperimentVariant
|
||||
tabularResults.push({ key, success_count, failure_count })
|
||||
} else if (experimentInsightType === InsightType.TRENDS) {
|
||||
} else if (metricType === InsightType.TRENDS) {
|
||||
const { key, count, exposure, absolute_exposure } = variantObj as TrendExperimentVariant
|
||||
tabularResults.push({ key, count, exposure, absolute_exposure })
|
||||
}
|
||||
@ -1501,9 +1510,9 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
continue
|
||||
}
|
||||
|
||||
if (experimentInsightType === InsightType.FUNNELS) {
|
||||
if (metricType === InsightType.FUNNELS) {
|
||||
tabularResults.push({ key, success_count: null, failure_count: null })
|
||||
} else if (experimentInsightType === InsightType.TRENDS) {
|
||||
} else if (metricType === InsightType.TRENDS) {
|
||||
tabularResults.push({ key, count: null, exposure: null, absolute_exposure: null })
|
||||
}
|
||||
}
|
||||
@ -1569,9 +1578,9 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
},
|
||||
],
|
||||
funnelResultsPersonsTotal: [
|
||||
(s) => [s.experimentResults, s.experimentInsightType],
|
||||
(experimentResults: ExperimentResults['result'], experimentInsightType: InsightType): number => {
|
||||
if (experimentInsightType !== InsightType.FUNNELS || !experimentResults?.insight) {
|
||||
(s) => [s.experimentResults, s.getMetricType],
|
||||
(experimentResults, getMetricType): number => {
|
||||
if (getMetricType(0) !== InsightType.FUNNELS || !experimentResults?.insight) {
|
||||
return 0
|
||||
}
|
||||
|
||||
@ -1614,8 +1623,13 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
},
|
||||
],
|
||||
hasGoalSet: [
|
||||
(s) => [s.experiment],
|
||||
(experiment): boolean => {
|
||||
(s) => [s.experiment, s.featureFlags],
|
||||
(experiment, featureFlags): boolean => {
|
||||
// :FLAG: CLEAN UP AFTER MIGRATION
|
||||
if (featureFlags[FEATURE_FLAGS.EXPERIMENTS_HOGQL]) {
|
||||
return !!experiment.metrics[0]
|
||||
}
|
||||
|
||||
const filters = experiment?.filters
|
||||
return !!(
|
||||
(filters?.actions && filters.actions.length > 0) ||
|
||||
@ -1671,3 +1685,110 @@ function percentageDistribution(variantCount: number): number[] {
|
||||
percentages[variantCount - 1] = percentageRounded - delta
|
||||
return percentages
|
||||
}
|
||||
|
||||
export function getDefaultFilters(insightType: InsightType, aggregationGroupTypeIndex: number | undefined): FilterType {
|
||||
let newInsightFilters
|
||||
if (insightType === InsightType.TRENDS) {
|
||||
const groupAggregation =
|
||||
aggregationGroupTypeIndex !== undefined
|
||||
? { math: 'unique_group', math_group_type_index: aggregationGroupTypeIndex }
|
||||
: {}
|
||||
|
||||
newInsightFilters = cleanFilters({
|
||||
insight: InsightType.TRENDS,
|
||||
events: [{ ...getDefaultEvent(), ...groupAggregation }],
|
||||
date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
display: ChartDisplayType.ActionsLineGraph,
|
||||
entity: EntityTypes.EVENTS,
|
||||
filter_test_accounts: true,
|
||||
} as TrendsFilterType)
|
||||
} else {
|
||||
newInsightFilters = cleanFilters({
|
||||
insight: InsightType.FUNNELS,
|
||||
events: [
|
||||
{
|
||||
id: '$pageview',
|
||||
name: '$pageview',
|
||||
type: 'events',
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
id: '$pageview',
|
||||
name: 'Pageview',
|
||||
type: 'events',
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
funnel_viz_type: FunnelVizType.Steps,
|
||||
date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
layout: FunnelLayout.horizontal,
|
||||
aggregation_group_type_index: aggregationGroupTypeIndex,
|
||||
funnel_window_interval: 14,
|
||||
funnel_window_interval_unit: FunnelConversionWindowTimeUnit.Day,
|
||||
filter_test_accounts: true,
|
||||
})
|
||||
}
|
||||
|
||||
return newInsightFilters
|
||||
}
|
||||
|
||||
export function getDefaultTrendsMetric(): ExperimentTrendsQuery {
|
||||
return {
|
||||
kind: NodeKind.ExperimentTrendsQuery,
|
||||
count_query: {
|
||||
kind: NodeKind.TrendsQuery,
|
||||
series: [
|
||||
{
|
||||
kind: NodeKind.EventsNode,
|
||||
name: '$pageview',
|
||||
event: '$pageview',
|
||||
},
|
||||
],
|
||||
interval: 'day',
|
||||
dateRange: {
|
||||
date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
explicitDate: true,
|
||||
},
|
||||
trendsFilter: {
|
||||
display: ChartDisplayType.ActionsLineGraph,
|
||||
},
|
||||
filterTestAccounts: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultFunnelsMetric(): ExperimentFunnelsQuery {
|
||||
return {
|
||||
kind: NodeKind.ExperimentFunnelsQuery,
|
||||
funnels_query: {
|
||||
kind: NodeKind.FunnelsQuery,
|
||||
filterTestAccounts: true,
|
||||
dateRange: {
|
||||
date_from: dayjs().subtract(EXPERIMENT_DEFAULT_DURATION, 'day').format('YYYY-MM-DDTHH:mm'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
explicitDate: true,
|
||||
},
|
||||
series: [
|
||||
{
|
||||
kind: NodeKind.EventsNode,
|
||||
event: '$pageview',
|
||||
name: '$pageview',
|
||||
},
|
||||
{
|
||||
kind: NodeKind.EventsNode,
|
||||
event: '$pageview',
|
||||
name: '$pageview',
|
||||
},
|
||||
],
|
||||
funnelsFilter: {
|
||||
funnelVizType: FunnelVizType.Steps,
|
||||
funnelWindowIntervalUnit: FunnelConversionWindowTimeUnit.Day,
|
||||
funnelWindowInterval: 14,
|
||||
layout: FunnelLayout.horizontal,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
@ -1,221 +0,0 @@
|
||||
import { actions, connect, kea, key, listeners, path, props, reducers } from 'kea'
|
||||
import { forms } from 'kea-forms'
|
||||
import { FunnelLayout } from 'lib/constants'
|
||||
import { dayjs } from 'lib/dayjs'
|
||||
import { insightDataLogic } from 'scenes/insights/insightDataLogic'
|
||||
import { insightLogic } from 'scenes/insights/insightLogic'
|
||||
import { insightVizDataLogic } from 'scenes/insights/insightVizDataLogic'
|
||||
import { cleanFilters, getDefaultEvent } from 'scenes/insights/utils/cleanFilters'
|
||||
import { teamLogic } from 'scenes/teamLogic'
|
||||
|
||||
import { filtersToQueryNode } from '~/queries/nodes/InsightQuery/utils/filtersToQueryNode'
|
||||
import { queryNodeToFilter } from '~/queries/nodes/InsightQuery/utils/queryNodeToFilter'
|
||||
import { FunnelsQuery, InsightVizNode, TrendsQuery } from '~/queries/schema'
|
||||
import { Experiment, FilterType, FunnelVizType, InsightType, SecondaryExperimentMetric } from '~/types'
|
||||
|
||||
import { SECONDARY_METRIC_INSIGHT_ID } from './constants'
|
||||
import { experimentLogic } from './experimentLogic'
|
||||
import type { secondaryMetricsLogicType } from './secondaryMetricsLogicType'
|
||||
|
||||
const DEFAULT_DURATION = 14
|
||||
|
||||
export const MAX_SECONDARY_METRICS = 10
|
||||
|
||||
export interface SecondaryMetricsProps {
|
||||
onMetricsChange: (metrics: SecondaryExperimentMetric[]) => void
|
||||
initialMetrics: SecondaryExperimentMetric[]
|
||||
experimentId: Experiment['id']
|
||||
defaultAggregationType?: number
|
||||
}
|
||||
|
||||
export interface SecondaryMetricForm {
|
||||
name: string
|
||||
filters: Partial<FilterType>
|
||||
}
|
||||
|
||||
const defaultFormValuesGenerator: (
|
||||
aggregationType?: number,
|
||||
disableAddEventToDefault?: boolean,
|
||||
cohortIdToFilter?: number
|
||||
) => SecondaryMetricForm = (aggregationType, disableAddEventToDefault, cohortIdToFilter) => {
|
||||
const groupAggregation =
|
||||
aggregationType !== undefined ? { math: 'unique_group', math_group_type_index: aggregationType } : {}
|
||||
|
||||
const cohortFilter = cohortIdToFilter
|
||||
? { properties: [{ key: 'id', type: 'cohort', value: cohortIdToFilter }] }
|
||||
: {}
|
||||
const eventAddition = disableAddEventToDefault
|
||||
? {}
|
||||
: { events: [{ ...getDefaultEvent(), ...groupAggregation, ...cohortFilter }] }
|
||||
|
||||
return {
|
||||
name: '',
|
||||
filters: {
|
||||
insight: InsightType.TRENDS,
|
||||
...eventAddition,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const secondaryMetricsLogic = kea<secondaryMetricsLogicType>([
|
||||
props({} as SecondaryMetricsProps),
|
||||
key((props) => `${props.experimentId || 'new'}-${props.defaultAggregationType}`),
|
||||
path((key) => ['scenes', 'experiment', 'secondaryMetricsLogic', key]),
|
||||
connect((props: SecondaryMetricsProps) => ({
|
||||
logic: [insightLogic({ dashboardItemId: SECONDARY_METRIC_INSIGHT_ID, syncWithUrl: false })],
|
||||
values: [teamLogic, ['currentTeamId'], experimentLogic({ experimentId: props.experimentId }), ['experiment']],
|
||||
actions: [
|
||||
insightDataLogic({ dashboardItemId: SECONDARY_METRIC_INSIGHT_ID }),
|
||||
['setQuery'],
|
||||
insightVizDataLogic({ dashboardItemId: SECONDARY_METRIC_INSIGHT_ID }),
|
||||
['updateQuerySource'],
|
||||
],
|
||||
})),
|
||||
actions({
|
||||
// modal
|
||||
openModalToCreateSecondaryMetric: true,
|
||||
openModalToEditSecondaryMetric: (
|
||||
metric: SecondaryExperimentMetric,
|
||||
metricIdx: number,
|
||||
showResults: boolean = false
|
||||
) => ({
|
||||
metric,
|
||||
metricIdx,
|
||||
showResults,
|
||||
}),
|
||||
saveSecondaryMetric: true,
|
||||
closeModal: true,
|
||||
|
||||
// metrics
|
||||
setMetricId: (metricIdx: number) => ({ metricIdx }),
|
||||
addNewMetric: (metric: SecondaryExperimentMetric) => ({ metric }),
|
||||
updateMetric: (metric: SecondaryExperimentMetric, metricIdx: number) => ({ metric, metricIdx }),
|
||||
deleteMetric: (metricIdx: number) => ({ metricIdx }),
|
||||
|
||||
// preview insight
|
||||
setPreviewInsight: (filters?: Partial<FilterType>) => ({ filters }),
|
||||
}),
|
||||
reducers(({ props }) => ({
|
||||
isModalOpen: [
|
||||
false,
|
||||
{
|
||||
openModalToCreateSecondaryMetric: () => true,
|
||||
openModalToEditSecondaryMetric: () => true,
|
||||
closeModal: () => false,
|
||||
},
|
||||
],
|
||||
showResults: [
|
||||
false,
|
||||
{
|
||||
openModalToEditSecondaryMetric: (_, { showResults }) => showResults,
|
||||
closeModal: () => false,
|
||||
},
|
||||
],
|
||||
existingModalSecondaryMetric: [
|
||||
null as SecondaryExperimentMetric | null,
|
||||
{
|
||||
openModalToCreateSecondaryMetric: () => null,
|
||||
openModalToEditSecondaryMetric: (_, { metric }) => metric,
|
||||
},
|
||||
],
|
||||
metrics: [
|
||||
props.initialMetrics,
|
||||
{
|
||||
addNewMetric: (metrics, { metric }) => {
|
||||
return [...metrics, { ...metric }]
|
||||
},
|
||||
updateMetric: (metrics, { metric, metricIdx }) => {
|
||||
const metricsCopy = [...metrics]
|
||||
metricsCopy[metricIdx] = metric
|
||||
return metricsCopy
|
||||
},
|
||||
deleteMetric: (metrics, { metricIdx }) => metrics.filter((_, idx) => idx !== metricIdx),
|
||||
},
|
||||
],
|
||||
metricIdx: [
|
||||
0 as number,
|
||||
{
|
||||
setMetricId: (_, { metricIdx }) => metricIdx,
|
||||
},
|
||||
],
|
||||
})),
|
||||
forms(({ props, values }) => ({
|
||||
secondaryMetricModal: {
|
||||
defaults: defaultFormValuesGenerator(
|
||||
props.defaultAggregationType,
|
||||
false,
|
||||
values.experiment?.exposure_cohort
|
||||
),
|
||||
errors: () => ({}),
|
||||
submit: async () => {
|
||||
// We don't use the form submit anymore
|
||||
},
|
||||
},
|
||||
})),
|
||||
listeners(({ props, actions, values }) => ({
|
||||
openModalToCreateSecondaryMetric: () => {
|
||||
actions.resetSecondaryMetricModal()
|
||||
actions.setPreviewInsight(
|
||||
defaultFormValuesGenerator(props.defaultAggregationType, false, values.experiment?.exposure_cohort)
|
||||
.filters
|
||||
)
|
||||
},
|
||||
openModalToEditSecondaryMetric: ({ metric: { name, filters }, metricIdx }) => {
|
||||
actions.setSecondaryMetricModalValue('name', name)
|
||||
actions.setPreviewInsight(filters)
|
||||
actions.setMetricId(metricIdx)
|
||||
},
|
||||
setPreviewInsight: async ({ filters }) => {
|
||||
let newInsightFilters
|
||||
if (filters?.insight === InsightType.FUNNELS) {
|
||||
newInsightFilters = cleanFilters({
|
||||
insight: InsightType.FUNNELS,
|
||||
funnel_viz_type: FunnelVizType.Steps,
|
||||
date_from: dayjs().subtract(DEFAULT_DURATION, 'day').format('YYYY-MM-DD'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
layout: FunnelLayout.horizontal,
|
||||
aggregation_group_type_index: props.defaultAggregationType,
|
||||
...filters,
|
||||
})
|
||||
} else {
|
||||
newInsightFilters = cleanFilters({
|
||||
insight: InsightType.TRENDS,
|
||||
date_from: dayjs().subtract(DEFAULT_DURATION, 'day').format('YYYY-MM-DD'),
|
||||
date_to: dayjs().endOf('d').format('YYYY-MM-DDTHH:mm'),
|
||||
...defaultFormValuesGenerator(
|
||||
props.defaultAggregationType,
|
||||
(filters?.actions?.length || 0) + (filters?.events?.length || 0) > 0
|
||||
).filters,
|
||||
...filters,
|
||||
})
|
||||
}
|
||||
|
||||
// This allows switching between insight types. It's necessary as `updateQuerySource` merges
|
||||
// the new query with any existing query and that causes validation problems when there are
|
||||
// unsupported properties in the now merged query.
|
||||
const newQuery = filtersToQueryNode(newInsightFilters)
|
||||
if (filters?.insight === InsightType.FUNNELS) {
|
||||
;(newQuery as TrendsQuery).trendsFilter = undefined
|
||||
} else {
|
||||
;(newQuery as FunnelsQuery).funnelsFilter = undefined
|
||||
}
|
||||
actions.updateQuerySource(newQuery)
|
||||
},
|
||||
// sync form value `filters` with query
|
||||
setQuery: ({ query }) => {
|
||||
actions.setSecondaryMetricModalValue('filters', queryNodeToFilter((query as InsightVizNode).source))
|
||||
},
|
||||
saveSecondaryMetric: () => {
|
||||
if (values.existingModalSecondaryMetric) {
|
||||
actions.updateMetric(values.secondaryMetricModal, values.metricIdx)
|
||||
} else {
|
||||
actions.addNewMetric(values.secondaryMetricModal)
|
||||
}
|
||||
props.onMetricsChange(values.metrics)
|
||||
actions.closeModal()
|
||||
},
|
||||
deleteMetric: () => {
|
||||
props.onMetricsChange(values.metrics)
|
||||
},
|
||||
})),
|
||||
])
|
@ -4,7 +4,7 @@ import { getMinimumDetectableEffect, transformFiltersForWinningVariant } from '.
|
||||
|
||||
describe('utils', () => {
|
||||
it('Funnel experiment returns correct MDE', async () => {
|
||||
const experimentInsightType = InsightType.FUNNELS
|
||||
const metricType = InsightType.FUNNELS
|
||||
const trendResults = [
|
||||
{
|
||||
action: {
|
||||
@ -26,36 +26,36 @@ describe('utils', () => {
|
||||
]
|
||||
|
||||
let conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(1)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(1)
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 1 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(1)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(1)
|
||||
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.01 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(1)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(1)
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.99 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(1)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(1)
|
||||
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.1 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5)
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.9 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5)
|
||||
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.3 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(3)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(3)
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.7 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(3)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(3)
|
||||
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.2 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(4)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(4)
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.8 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(4)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(4)
|
||||
|
||||
conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0.5 }
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5)
|
||||
})
|
||||
|
||||
it('Trend experiment returns correct MDE', async () => {
|
||||
const experimentInsightType = InsightType.TRENDS
|
||||
const metricType = InsightType.TRENDS
|
||||
const conversionMetrics = { averageTime: 0, stepRate: 0, totalRate: 0 }
|
||||
const trendResults = [
|
||||
{
|
||||
@ -78,19 +78,19 @@ describe('utils', () => {
|
||||
]
|
||||
|
||||
trendResults[0].count = 0
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(100)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(100)
|
||||
|
||||
trendResults[0].count = 200
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(100)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(100)
|
||||
|
||||
trendResults[0].count = 201
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(20)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(20)
|
||||
|
||||
trendResults[0].count = 1001
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5)
|
||||
|
||||
trendResults[0].count = 20000
|
||||
expect(getMinimumDetectableEffect(experimentInsightType, conversionMetrics, trendResults)).toEqual(5)
|
||||
expect(getMinimumDetectableEffect(metricType, conversionMetrics, trendResults)).toEqual(5)
|
||||
})
|
||||
|
||||
it('transforms filters for a winning variant', async () => {
|
||||
|
@ -31,11 +31,11 @@ export function formatUnitByQuantity(value: number, unit: string): string {
|
||||
}
|
||||
|
||||
export function getMinimumDetectableEffect(
|
||||
experimentInsightType: InsightType,
|
||||
metricType: InsightType,
|
||||
conversionMetrics: FunnelTimeConversionMetrics,
|
||||
trendResults: TrendResult[]
|
||||
): number | null {
|
||||
if (experimentInsightType === InsightType.FUNNELS) {
|
||||
if (metricType === InsightType.FUNNELS) {
|
||||
// FUNNELS
|
||||
// Given current CR, find a realistic target CR increase and return MDE based on it
|
||||
if (!conversionMetrics) {
|
||||
|
@ -164,7 +164,7 @@ export const featureFlagReleaseConditionsLogic = kea<featureFlagReleaseCondition
|
||||
actions.setTotalUsers(response.total_users)
|
||||
},
|
||||
addConditionSet: () => {
|
||||
actions.setAffectedUsers(values.filters.groups.length - 1, -1)
|
||||
actions.setAffectedUsers(values.filters.groups.length - 1, values.totalUsers || -1)
|
||||
},
|
||||
removeConditionSet: ({ index }) => {
|
||||
const previousLength = Object.keys(values.affectedUsers).length
|
||||
@ -183,9 +183,20 @@ export const featureFlagReleaseConditionsLogic = kea<featureFlagReleaseCondition
|
||||
actions.setAffectedUsers(index, undefined)
|
||||
|
||||
const properties = condition.properties
|
||||
if (!properties || properties?.length === 0 || properties.some(isEmptyProperty)) {
|
||||
// don't compute for full rollouts or empty conditions
|
||||
if (!properties || properties.some(isEmptyProperty)) {
|
||||
// don't compute for incomplete conditions
|
||||
usersAffected.push(Promise.resolve({ users_affected: -1, total_users: -1 }))
|
||||
} else if (properties.length === 0) {
|
||||
// Request total users for empty condition sets
|
||||
const responsePromise = api.create(
|
||||
`api/projects/${values.currentTeamId}/feature_flags/user_blast_radius`,
|
||||
{
|
||||
condition: { properties: [] },
|
||||
group_type_index: values.filters?.aggregation_group_type_index ?? null,
|
||||
}
|
||||
)
|
||||
|
||||
usersAffected.push(responsePromise)
|
||||
} else {
|
||||
const responsePromise = api.create(
|
||||
`api/projects/${values.currentTeamId}/feature_flags/user_blast_radius`,
|
||||
|
@ -436,6 +436,7 @@ export function FeatureFlags(): JSX.Element {
|
||||
content: <ActivityLog scope={ActivityScope.FEATURE_FLAG} />,
|
||||
},
|
||||
]}
|
||||
data-attr="feature-flags-tab-navigation"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
@ -302,7 +302,7 @@ export const featureFlagLogic = kea<featureFlagLogicType>([
|
||||
}),
|
||||
forms(({ actions, values }) => ({
|
||||
featureFlag: {
|
||||
defaults: { ...NEW_FLAG } as FeatureFlagType,
|
||||
defaults: { ...NEW_FLAG },
|
||||
errors: ({ key, filters }) => {
|
||||
return {
|
||||
key: validateFeatureFlagKey(key),
|
||||
|
@ -128,6 +128,7 @@ describe('the feature flag release conditions logic', () => {
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 140, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 240, total_users: 2002 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 500, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 750, total_users: 2001 }))
|
||||
|
||||
logic.mount()
|
||||
})
|
||||
@ -138,30 +139,44 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: undefined, 2: undefined, 3: undefined },
|
||||
affectedUsers: { 0: 140, 1: undefined, 2: undefined, 3: undefined },
|
||||
totalUsers: null,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: 140 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: 140, 2: 240 },
|
||||
affectedUsers: { 0: 140, 1: 240 },
|
||||
totalUsers: 2002,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: 140, 2: 240, 3: 500 },
|
||||
affectedUsers: { 0: 140, 1: 240, 2: 500 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: 240, 2: 500, 3: 750 },
|
||||
totalUsers: 2001,
|
||||
})
|
||||
})
|
||||
|
||||
it('updates when adding conditions to a flag', async () => {
|
||||
jest.spyOn(api, 'create')
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 140, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 240, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 124, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 248, total_users: 2000 }))
|
||||
.mockReturnValueOnce(Promise.resolve({ users_affected: 496, total_users: 2000 }))
|
||||
|
||||
logic?.unmount()
|
||||
logic = featureFlagReleaseConditionsLogic({
|
||||
id: '5678',
|
||||
filters: generateFeatureFlagFilters([
|
||||
{
|
||||
properties: [],
|
||||
rollout_percentage: 50,
|
||||
variant: null,
|
||||
},
|
||||
]),
|
||||
})
|
||||
logic.mount()
|
||||
|
||||
await expectLogic(logic, () => {
|
||||
logic.actions.updateConditionSet(0, 20, [
|
||||
@ -176,12 +191,11 @@ describe('the feature flag release conditions logic', () => {
|
||||
// first call is to clear the affected users on mount
|
||||
// second call is to set the affected users for mount logic conditions
|
||||
// third call is to set the affected users for the updateConditionSet action
|
||||
.toDispatchActions(['setAffectedUsers', 'setAffectedUsers', 'setAffectedUsers'])
|
||||
.toDispatchActions(['setAffectedUsers', 'setAffectedUsers', 'setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: undefined },
|
||||
totalUsers: null,
|
||||
affectedUsers: { 0: 124 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toNotHaveDispatchedActions(['setTotalUsers'])
|
||||
|
||||
await expectLogic(logic, () => {
|
||||
logic.actions.updateConditionSet(0, 20, [
|
||||
@ -196,11 +210,11 @@ describe('the feature flag release conditions logic', () => {
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: undefined },
|
||||
totalUsers: null,
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140 },
|
||||
affectedUsers: { 0: 248 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
|
||||
@ -210,7 +224,8 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: -1 },
|
||||
// expect the new empty condition set to initialize affected users to be same as total users
|
||||
affectedUsers: { 0: 248, 1: 2000 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toNotHaveDispatchedActions(['setTotalUsers'])
|
||||
@ -228,7 +243,7 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: undefined },
|
||||
affectedUsers: { 0: 248, 1: undefined },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toNotHaveDispatchedActions(['setTotalUsers'])
|
||||
@ -246,12 +261,12 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: undefined },
|
||||
affectedUsers: { 0: 248, 1: undefined },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 140, 1: 240 },
|
||||
affectedUsers: { 0: 248, 1: 496 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
|
||||
@ -261,11 +276,11 @@ describe('the feature flag release conditions logic', () => {
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 240, 1: 240 },
|
||||
affectedUsers: { 0: 496, 1: 496 },
|
||||
})
|
||||
.toDispatchActions(['setAffectedUsers'])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: 240, 1: undefined },
|
||||
affectedUsers: { 0: 496, 1: undefined },
|
||||
})
|
||||
})
|
||||
|
||||
@ -313,7 +328,6 @@ describe('the feature flag release conditions logic', () => {
|
||||
jest.spyOn(api, 'create')
|
||||
|
||||
logic?.unmount()
|
||||
|
||||
logic = featureFlagReleaseConditionsLogic({
|
||||
id: '12345',
|
||||
filters: generateFeatureFlagFilters([
|
||||
@ -359,11 +373,11 @@ describe('the feature flag release conditions logic', () => {
|
||||
'setTotalUsers',
|
||||
])
|
||||
.toMatchValues({
|
||||
affectedUsers: { 0: -1, 1: 120, 2: 120 },
|
||||
affectedUsers: { 0: 120, 1: 120, 2: 120 },
|
||||
totalUsers: 2000,
|
||||
})
|
||||
|
||||
expect(api.create).toHaveBeenCalledTimes(2)
|
||||
expect(api.create).toHaveBeenCalledTimes(4)
|
||||
|
||||
await expectLogic(logic, () => {
|
||||
logic.actions.updateConditionSet(0, 20, undefined, undefined)
|
||||
@ -378,7 +392,7 @@ describe('the feature flag release conditions logic', () => {
|
||||
}).toNotHaveDispatchedActions(['setAffectedUsers', 'setTotalUsers'])
|
||||
|
||||
// no extra calls when changing rollout percentage
|
||||
expect(api.create).toHaveBeenCalledTimes(2)
|
||||
expect(api.create).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@ -239,7 +239,17 @@ export const featureFlagsLogic = kea<featureFlagsLogicType>([
|
||||
pageFiltersFromUrl.page = parseInt(page)
|
||||
}
|
||||
|
||||
actions.setFeatureFlagsFilters({ ...DEFAULT_FILTERS, ...pageFiltersFromUrl })
|
||||
// Initialize filters with the URL params if none are set
|
||||
const isInitializingFilters =
|
||||
objectsEqual(DEFAULT_FILTERS, values.filters) && !objectsEqual(DEFAULT_FILTERS, pageFiltersFromUrl)
|
||||
/**
|
||||
* Pagination search param in the URL is modified directly by the LemonTable component,
|
||||
* so let's update filter state if it changes
|
||||
*/
|
||||
const isChangingPage = page !== undefined && page !== values.filters.page
|
||||
if (isInitializingFilters || isChangingPage) {
|
||||
actions.setFeatureFlagsFilters({ ...DEFAULT_FILTERS, ...pageFiltersFromUrl })
|
||||
}
|
||||
},
|
||||
})),
|
||||
events(({ actions }) => ({
|
||||
|
@ -40,6 +40,7 @@ import {
|
||||
} from 'scenes/trends/mathsLogic'
|
||||
|
||||
import { actionsModel } from '~/models/actionsModel'
|
||||
import { NodeKind } from '~/queries/schema'
|
||||
import { isInsightVizNode, isStickinessQuery } from '~/queries/utils'
|
||||
import {
|
||||
ActionFilter,
|
||||
@ -596,9 +597,20 @@ export function ActionFilterRow({
|
||||
onChange={(properties) => updateFilterProperty({ properties, index })}
|
||||
showNestedArrow={showNestedArrow}
|
||||
disablePopover={!propertyFiltersPopover}
|
||||
metadataSource={
|
||||
filter.type == TaxonomicFilterGroupType.DataWarehouse
|
||||
? {
|
||||
kind: NodeKind.HogQLQuery,
|
||||
query: `select ${filter.distinct_id_field} from ${filter.table_name}`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
taxonomicGroupTypes={
|
||||
filter.type == TaxonomicFilterGroupType.DataWarehouse
|
||||
? [TaxonomicFilterGroupType.DataWarehouseProperties]
|
||||
? [
|
||||
TaxonomicFilterGroupType.DataWarehouseProperties,
|
||||
TaxonomicFilterGroupType.HogQLExpression,
|
||||
]
|
||||
: propertiesTaxonomicGroupTypes
|
||||
}
|
||||
eventNames={
|
||||
|
@ -10,7 +10,7 @@ import { FunnelsQuery } from '~/queries/schema'
|
||||
import { isFunnelsQuery, isInsightQueryNode, isStickinessQuery } from '~/queries/utils'
|
||||
import { InsightLogicProps } from '~/types'
|
||||
|
||||
function getHogQLValue(groupIndex?: number, aggregationQuery?: string): string {
|
||||
export function getHogQLValue(groupIndex?: number, aggregationQuery?: string): string {
|
||||
if (groupIndex !== undefined) {
|
||||
return `$group_${groupIndex}`
|
||||
} else if (aggregationQuery) {
|
||||
@ -19,7 +19,7 @@ function getHogQLValue(groupIndex?: number, aggregationQuery?: string): string {
|
||||
return UNIQUE_USERS
|
||||
}
|
||||
|
||||
function hogQLToFilterValue(value?: string): { groupIndex?: number; aggregationQuery?: string } {
|
||||
export function hogQLToFilterValue(value?: string): { groupIndex?: number; aggregationQuery?: string } {
|
||||
if (value?.match(/^\$group_[0-9]+$/)) {
|
||||
return { groupIndex: parseInt(value.replace('$group_', '')) }
|
||||
} else if (value === 'person_id') {
|
||||
|
@ -10,7 +10,7 @@ import { useDebouncedCallback } from 'use-debounce'
|
||||
import { FunnelsFilter } from '~/queries/schema'
|
||||
import { EditorFilterProps, FunnelConversionWindow, FunnelConversionWindowTimeUnit } from '~/types'
|
||||
|
||||
const TIME_INTERVAL_BOUNDS: Record<FunnelConversionWindowTimeUnit, number[]> = {
|
||||
export const TIME_INTERVAL_BOUNDS: Record<FunnelConversionWindowTimeUnit, number[]> = {
|
||||
[FunnelConversionWindowTimeUnit.Second]: [1, 3600],
|
||||
[FunnelConversionWindowTimeUnit.Minute]: [1, 1440],
|
||||
[FunnelConversionWindowTimeUnit.Hour]: [1, 24],
|
||||
|
@ -25,7 +25,7 @@ export function QuestionInput(): JSX.Element {
|
||||
className={clsx(
|
||||
!isFloating
|
||||
? 'w-[min(44rem,100%)] relative'
|
||||
: 'w-full max-w-200 sticky z-10 self-center p-1 mx-3 mb-3 bottom-3 border border-[var(--glass-border-3000)] rounded-[0.625rem] backdrop-blur bg-[var(--glass-bg-3000)]'
|
||||
: 'w-full max-w-192 sticky z-10 self-center p-1 mx-4 mb-3 bottom-3 border border-[var(--glass-border-3000)] rounded-[0.625rem] backdrop-blur bg-[var(--glass-bg-3000)]'
|
||||
)}
|
||||
>
|
||||
<LemonTextArea
|
||||
|
@ -54,6 +54,8 @@ export function QuestionSuggestions(): JSX.Element {
|
||||
size="xsmall"
|
||||
type="secondary"
|
||||
sideIcon={<IconArrowUpRight />}
|
||||
center
|
||||
className="shrink"
|
||||
>
|
||||
{suggestion}
|
||||
</LemonButton>
|
||||
|