diff --git a/bin/docker-server b/bin/docker-server index d21a6c85bb362..274df8dd061d1 100755 --- a/bin/docker-server +++ b/bin/docker-server @@ -11,13 +11,6 @@ trap 'rm -rf "$PROMETHEUS_MULTIPROC_DIR"' EXIT export PROMETHEUS_METRICS_EXPORT_PORT=8001 export STATSD_PORT=${STATSD_PORT:-8125} -if [[ -n $INJECT_EC2_CLIENT_RACK ]]; then - # To avoid cross-AZ Kafka traffic, set KAFKA_CLIENT_RACK from the EC2 metadata endpoint. - # TODO: switch to the downwards API when https://github.com/kubernetes/kubernetes/issues/40610 is released - TOKEN=$(curl --max-time 0.1 -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600") - export KAFKA_CLIENT_RACK=$(curl --max-time 0.1 -H "X-aws-ec2-metadata-token: $TOKEN" -v http://169.254.169.254/latest/meta-data/placement/availability-zone-id) -fi - exec gunicorn posthog.wsgi \ --config gunicorn.config.py \ --bind 0.0.0.0:8000 \ diff --git a/ee/clickhouse/queries/experiments/secondary_experiment_result.py b/ee/clickhouse/queries/experiments/secondary_experiment_result.py index 286d408b13d0d..476e09dfd7c73 100644 --- a/ee/clickhouse/queries/experiments/secondary_experiment_result.py +++ b/ee/clickhouse/queries/experiments/secondary_experiment_result.py @@ -3,10 +3,7 @@ from zoneinfo import ZoneInfo from rest_framework.exceptions import ValidationError -from ee.clickhouse.queries.experiments.trend_experiment_result import ( - uses_count_per_property_value_aggregation, - uses_count_per_user_aggregation, -) +from ee.clickhouse.queries.experiments.trend_experiment_result import uses_math_aggregation_by_user_or_property_value from posthog.constants import INSIGHT_FUNNELS, INSIGHT_TRENDS, TRENDS_CUMULATIVE from posthog.models.feature_flag import FeatureFlag @@ -59,7 +56,7 @@ def __init__( self.team = team if query_filter.insight == INSIGHT_TRENDS and not ( - uses_count_per_user_aggregation(query_filter) or uses_count_per_property_value_aggregation(query_filter) + uses_math_aggregation_by_user_or_property_value(query_filter) ): query_filter = query_filter.shallow_clone({"display": TRENDS_CUMULATIVE}) @@ -99,9 +96,7 @@ def get_trend_count_data_for_variants(self, insight_results) -> Dict[str, float] count = result["count"] breakdown_value = result["breakdown_value"] - if uses_count_per_user_aggregation(self.query_filter) or uses_count_per_property_value_aggregation( - self.query_filter - ): + if uses_math_aggregation_by_user_or_property_value(self.query_filter): count = result["count"] / len(result.get("data", [0])) if breakdown_value in self.variants: diff --git a/ee/clickhouse/queries/experiments/trend_experiment_result.py b/ee/clickhouse/queries/experiments/trend_experiment_result.py index ec03370365188..9252e6533ef7a 100644 --- a/ee/clickhouse/queries/experiments/trend_experiment_result.py +++ b/ee/clickhouse/queries/experiments/trend_experiment_result.py @@ -25,7 +25,7 @@ from posthog.models.filters.filter import Filter from posthog.models.team import Team from posthog.queries.trends.trends import Trends -from posthog.queries.trends.util import COUNT_PER_ACTOR_MATH_FUNCTIONS, PROPERTY_MATH_FUNCTIONS +from posthog.queries.trends.util import ALL_SUPPORTED_MATH_FUNCTIONS Probability = float @@ -41,16 +41,18 @@ class Variant: absolute_exposure: int -def uses_count_per_user_aggregation(filter: Filter): +def uses_math_aggregation_by_user_or_property_value(filter: Filter): + # sync with frontend: https://github.com/PostHog/posthog/blob/master/frontend/src/scenes/experiments/experimentLogic.tsx#L662 + # the selector experimentCountPerUserMath + entities = filter.entities - count_per_actor_keys = COUNT_PER_ACTOR_MATH_FUNCTIONS.keys() - return any(entity.math in count_per_actor_keys for entity in entities) + math_keys = ALL_SUPPORTED_MATH_FUNCTIONS + # 'sum' doesn't need special handling, we can have custom exposure for sum filters + if "sum" in math_keys: + math_keys.remove("sum") -def uses_count_per_property_value_aggregation(filter: Filter): - entities = filter.entities - count_per_prop_value_keys = PROPERTY_MATH_FUNCTIONS.keys() - return any(entity.math in count_per_prop_value_keys for entity in entities) + return any(entity.math in math_keys for entity in entities) class ClickhouseTrendExperimentResult: @@ -89,14 +91,11 @@ def __init__( experiment_end_date.astimezone(ZoneInfo(team.timezone)) if experiment_end_date else None ) - count_per_user_aggregation = uses_count_per_user_aggregation(filter) - count_per_property_value_aggregation = uses_count_per_property_value_aggregation(filter) + uses_math_aggregation = uses_math_aggregation_by_user_or_property_value(filter) query_filter = filter.shallow_clone( { - "display": TRENDS_CUMULATIVE - if not (count_per_user_aggregation or count_per_property_value_aggregation) - else TRENDS_LINEAR, + "display": TRENDS_CUMULATIVE if not uses_math_aggregation else TRENDS_LINEAR, "date_from": start_date_in_project_timezone, "date_to": end_date_in_project_timezone, "explicit_date": True, @@ -107,7 +106,7 @@ def __init__( } ) - if count_per_user_aggregation or count_per_property_value_aggregation: + if uses_math_aggregation: # A trend experiment can have only one metric, so take the first one to calculate exposure # We copy the entity to avoid mutating the original filter entity = query_filter.shallow_clone({}).entities[0] @@ -213,9 +212,7 @@ def get_variants(self, insight_results, exposure_results): exposure_counts = {} exposure_ratios = {} - if uses_count_per_user_aggregation(self.query_filter) or uses_count_per_property_value_aggregation( - self.query_filter - ): + if uses_math_aggregation_by_user_or_property_value(self.query_filter): filtered_exposure_results = [ result for result in exposure_results if result["action"]["math"] == UNIQUE_USERS ] diff --git a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr index 9f9e01f13028a..d42c1cb3ff2e1 100644 --- a/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr +++ b/ee/clickhouse/views/test/__snapshots__/test_clickhouse_experiment_secondary_results.ambr @@ -1,6 +1,6 @@ # name: ClickhouseTestExperimentSecondaryResults.test_basic_secondary_metric_results ' - /* user_id:128 celery:posthog.celery.sync_insight_caching_state */ + /* user_id:125 celery:posthog.celery.sync_insight_caching_state */ SELECT team_id, date_diff('second', max(timestamp), now()) AS age FROM events diff --git a/ee/clickhouse/views/test/test_clickhouse_experiments.py b/ee/clickhouse/views/test/test_clickhouse_experiments.py index 07764b83845d8..9afecbfc846bd 100644 --- a/ee/clickhouse/views/test/test_clickhouse_experiments.py +++ b/ee/clickhouse/views/test/test_clickhouse_experiments.py @@ -1505,7 +1505,7 @@ def test_experiment_flow_with_event_results_for_three_test_variants(self): self.assertAlmostEqual(response_data["expected_loss"], 1, places=2) -@flaky(max_runs=10, min_passes=1) +# @flaky(max_runs=10, min_passes=1) class ClickhouseTestTrendExperimentResults(ClickhouseTestMixin, APILicensedTest): @snapshot_clickhouse_queries def test_experiment_flow_with_event_results(self): @@ -2440,3 +2440,128 @@ def test_experiment_flow_with_avg_count_per_property_value_results(self): # The variant has high probability of being better. (effectively Gamma(10,1)) self.assertAlmostEqual(response_data["probability"]["test"], 0.805, places=2) self.assertFalse(response_data["significant"]) + + def test_experiment_flow_with_sum_count_per_property_value_results(self): + journeys_for( + { + "person1": [ + # 5 counts, single person + { + "event": "$pageview", + "timestamp": "2020-01-02", + "properties": {"$feature/a-b-test": "test", "mathable": 1}, + }, + { + "event": "$pageview", + "timestamp": "2020-01-02", + "properties": {"$feature/a-b-test": "test", "mathable": 1}, + }, + { + "event": "$pageview", + "timestamp": "2020-01-02", + "properties": {"$feature/a-b-test": "test", "mathable": 3}, + }, + { + "event": "$pageview", + "timestamp": "2020-01-02", + "properties": {"$feature/a-b-test": "test", "mathable": 3}, + }, + { + "event": "$pageview", + "timestamp": "2020-01-02", + "properties": {"$feature/a-b-test": "test", "mathable": 10}, + }, + ], + "person2": [ + { + "event": "$pageview", + "timestamp": "2020-01-03", + "properties": {"$feature/a-b-test": "control", "mathable": 1}, + }, + { + "event": "$pageview", + "timestamp": "2020-01-04", + "properties": {"$feature/a-b-test": "control", "mathable": 1}, + }, + { + "event": "$pageview", + "timestamp": "2020-01-05", + "properties": {"$feature/a-b-test": "control", "mathable": 1}, + }, + ], + "person3": [ + { + "event": "$pageview", + "timestamp": "2020-01-04", + "properties": {"$feature/a-b-test": "control", "mathable": 2}, + }, + ], + "person4": [ + { + "event": "$pageview", + "timestamp": "2020-01-05", + "properties": {"$feature/a-b-test": "test", "mathable": 1}, + }, + { + "event": "$pageview", + "timestamp": "2020-01-05", + "properties": {"$feature/a-b-test": "test", "mathable": 1.5}, + }, + ], + # doesn't have feature set + "person_out_of_control": [ + {"event": "$pageview", "timestamp": "2020-01-03"}, + ], + "person_out_of_end_date": [ + {"event": "$pageview", "timestamp": "2020-08-03", "properties": {"$feature/a-b-test": "control"}}, + ], + }, + self.team, + ) + + ff_key = "a-b-test" + # generates the FF which should result in the above events^ + creation_response = self.client.post( + f"/api/projects/{self.team.id}/experiments/", + { + "name": "Test Experiment", + "description": "", + "start_date": "2020-01-01T00:00", + "end_date": "2020-01-06T00:00", + "feature_flag_key": ff_key, + "parameters": { + "custom_exposure_filter": { + "events": [ + { + "id": "$pageview", # exposure is total pageviews + "order": 0, + } + ], + } + }, + "filters": { + "insight": "TRENDS", + "events": [{"order": 0, "id": "$pageview", "math": "sum", "math_property": "mathable"}], + "properties": [], + }, + }, + ) + + id = creation_response.json()["id"] + + response = self.client.get(f"/api/projects/{self.team.id}/experiments/{id}/results") + self.assertEqual(200, response.status_code) + + response_data = response.json()["result"] + result = sorted(response_data["insight"], key=lambda x: x["breakdown_value"]) + + self.assertEqual(result[0]["data"], [0.0, 0.0, 1.0, 4.0, 5.0, 5.0]) + self.assertEqual("control", result[0]["breakdown_value"]) + + self.assertEqual(result[1]["data"], [0.0, 18.0, 18.0, 18.0, 20.5, 20.5]) + self.assertEqual("test", result[1]["breakdown_value"]) + + # Variant with test: Gamma(7, 1) and control: Gamma(4, 1) distribution + # The variant has high probability of being better. (effectively Gamma(10,1)) + self.assertAlmostEqual(response_data["probability"]["test"], 0.9513, places=2) + self.assertFalse(response_data["significant"]) diff --git a/ee/settings.py b/ee/settings.py index 49261f3cb9d47..8b381ae9220b1 100644 --- a/ee/settings.py +++ b/ee/settings.py @@ -64,3 +64,5 @@ # Whether to enable the admin portal. Default false for self-hosted as if not setup properly can pose security issues. ADMIN_PORTAL_ENABLED = get_from_env("ADMIN_PORTAL_ENABLED", DEMO or DEBUG, type_cast=str_to_bool) + +ASSET_GENERATION_MAX_TIMEOUT_MINUTES = get_from_env("ASSET_GENERATION_MAX_TIMEOUT_MINUTES", 10, type_cast=int) diff --git a/ee/tasks/subscriptions/__init__.py b/ee/tasks/subscriptions/__init__.py index b449f08ac2535..416e0a3337533 100644 --- a/ee/tasks/subscriptions/__init__.py +++ b/ee/tasks/subscriptions/__init__.py @@ -2,8 +2,8 @@ from typing import Optional import structlog +from prometheus_client import Counter from sentry_sdk import capture_exception -from statshog.defaults.django import statsd from ee.tasks.subscriptions.email_subscriptions import send_email_subscription_report from ee.tasks.subscriptions.slack_subscriptions import send_slack_subscription_report @@ -13,6 +13,14 @@ logger = structlog.get_logger(__name__) +SUBSCRIPTION_QUEUED = Counter( + "subscription_queued", "A subscription was queued for delivery", labelnames=["destination"] +) +SUBSCRIPTION_SUCCESS = Counter( + "subscription_send_success", "A subscription was sent successfully", labelnames=["destination"] +) +SUBSCRIPTION_FAILURE = Counter("subscription_send_failure", "A subscription failed to send", labelnames=["destination"]) + def _deliver_subscription_report( subscription_id: int, previous_value: Optional[str] = None, invite_message: Optional[str] = None @@ -34,6 +42,8 @@ def _deliver_subscription_report( return if subscription.target_type == "email": + SUBSCRIPTION_QUEUED.labels(destination="email").inc() + insights, assets = generate_assets(subscription) # Send emails @@ -51,22 +61,38 @@ def _deliver_subscription_report( invite_message=invite_message or "" if is_new_subscription_target else None, total_asset_count=len(insights), ) - statsd.incr("subscription_email_send_success") except Exception as e: - logger.error(e) + SUBSCRIPTION_FAILURE.labels(destination="email").inc() + logger.error( + "sending subscription failed", + subscription_id=subscription.id, + next_delivery_date=subscription.next_delivery_date, + destination=subscription.target_type, + exc_info=True, + ) capture_exception(e) - statsd.incr("subscription_email_send_failure") + + SUBSCRIPTION_SUCCESS.labels(destination="email").inc() elif subscription.target_type == "slack": + SUBSCRIPTION_QUEUED.labels(destination="slack").inc() + insights, assets = generate_assets(subscription) try: send_slack_subscription_report( subscription, assets, total_asset_count=len(insights), is_new_subscription=is_new_subscription_target ) - statsd.incr("subscription_slack_send_success") + SUBSCRIPTION_SUCCESS.labels(destination="slack").inc() except Exception as e: - statsd.incr("subscription_slack_send_failure") - logger.error(e) + SUBSCRIPTION_FAILURE.labels(destination="slack").inc() + logger.error( + "sending subscription failed", + subscription_id=subscription.id, + next_delivery_date=subscription.next_delivery_date, + destination=subscription.target_type, + exc_info=True, + ) + capture_exception(e) else: raise NotImplementedError(f"{subscription.target_type} is not supported") @@ -91,6 +117,12 @@ def schedule_all_subscriptions() -> None: ) for subscription in subscriptions: + logger.info( + "Scheduling subscription", + subscription_id=subscription.id, + next_delivery_date=subscription.next_delivery_date, + destination=subscription.target_type, + ) deliver_subscription_report.delay(subscription.id) diff --git a/ee/tasks/subscriptions/subscription_utils.py b/ee/tasks/subscriptions/subscription_utils.py index 198cd9f3f9b6b..b75e26ca37856 100644 --- a/ee/tasks/subscriptions/subscription_utils.py +++ b/ee/tasks/subscriptions/subscription_utils.py @@ -1,8 +1,9 @@ from datetime import timedelta from typing import List, Tuple, Union - +from django.conf import settings import structlog from celery import group +from prometheus_client import Histogram from posthog.models.dashboard_tile import get_tiles_ordered_by_position from posthog.models.exported_asset import ExportedAsset @@ -16,31 +17,39 @@ UTM_TAGS_BASE = "utm_source=posthog&utm_campaign=subscription_report" DEFAULT_MAX_ASSET_COUNT = 6 -ASSET_GENERATION_MAX_TIMEOUT = timedelta(minutes=10) + +SUBSCRIPTION_ASSET_GENERATION_TIMER = Histogram( + "subscription_asset_generation_duration_seconds", + "Time spent generating assets for a subscription", + buckets=(1, 5, 10, 30, 60, 120, 240, 300, 360, 420, 480, 540, 600, float("inf")), +) def generate_assets( resource: Union[Subscription, SharingConfiguration], max_asset_count: int = DEFAULT_MAX_ASSET_COUNT ) -> Tuple[List[Insight], List[ExportedAsset]]: - if resource.dashboard: - tiles = get_tiles_ordered_by_position(resource.dashboard) - insights = [tile.insight for tile in tiles if tile.insight] - elif resource.insight: - insights = [resource.insight] - else: - raise Exception("There are no insights to be sent for this Subscription") - - # Create all the assets we need - assets = [ - ExportedAsset(team=resource.team, export_format="image/png", insight=insight, dashboard=resource.dashboard) - for insight in insights[:max_asset_count] - ] - ExportedAsset.objects.bulk_create(assets) - - # Wait for all assets to be exported - tasks = [exporter.export_asset.s(asset.id) for asset in assets] - parallel_job = group(tasks).apply_async() - - wait_for_parallel_celery_group(parallel_job, max_timeout=ASSET_GENERATION_MAX_TIMEOUT) - - return insights, assets + with SUBSCRIPTION_ASSET_GENERATION_TIMER.time(): + if resource.dashboard: + tiles = get_tiles_ordered_by_position(resource.dashboard) + insights = [tile.insight for tile in tiles if tile.insight] + elif resource.insight: + insights = [resource.insight] + else: + raise Exception("There are no insights to be sent for this Subscription") + + # Create all the assets we need + assets = [ + ExportedAsset(team=resource.team, export_format="image/png", insight=insight, dashboard=resource.dashboard) + for insight in insights[:max_asset_count] + ] + ExportedAsset.objects.bulk_create(assets) + + # Wait for all assets to be exported + tasks = [exporter.export_asset.s(asset.id) for asset in assets] + parallel_job = group(tasks).apply_async() + + wait_for_parallel_celery_group( + parallel_job, max_timeout=timedelta(minutes=settings.ASSET_GENERATION_MAX_TIMEOUT_MINUTES) + ) + + return insights, assets diff --git a/ee/tasks/test/subscriptions/test_subscriptions_utils.py b/ee/tasks/test/subscriptions/test_subscriptions_utils.py index 2391537d9534a..440dcc97904f4 100644 --- a/ee/tasks/test/subscriptions/test_subscriptions_utils.py +++ b/ee/tasks/test/subscriptions/test_subscriptions_utils.py @@ -30,31 +30,33 @@ def setUp(self) -> None: self.subscription = create_subscription(team=self.team, insight=self.insight, created_by=self.user) - def test_generate_assets_for_insight(self, mock_export_task: MagicMock, mock_group: MagicMock) -> None: - insights, assets = generate_assets(self.subscription) + def test_generate_assets_for_insight(self, mock_export_task: MagicMock, _mock_group: MagicMock) -> None: + with self.settings(ASSET_GENERATION_MAX_TIMEOUT_MINUTES=1): + insights, assets = generate_assets(self.subscription) - assert insights == [self.insight] - assert len(assets) == 1 - assert mock_export_task.s.call_count == 1 + assert insights == [self.insight] + assert len(assets) == 1 + assert mock_export_task.s.call_count == 1 - def test_generate_assets_for_dashboard(self, mock_export_task: MagicMock, mock_group: MagicMock) -> None: + def test_generate_assets_for_dashboard(self, mock_export_task: MagicMock, _mock_group: MagicMock) -> None: subscription = create_subscription(team=self.team, dashboard=self.dashboard, created_by=self.user) - insights, assets = generate_assets(subscription) + with self.settings(ASSET_GENERATION_MAX_TIMEOUT_MINUTES=1): + insights, assets = generate_assets(subscription) assert len(insights) == len(self.tiles) assert len(assets) == DEFAULT_MAX_ASSET_COUNT assert mock_export_task.s.call_count == DEFAULT_MAX_ASSET_COUNT - def test_raises_if_missing_resource(self, mock_export_task: MagicMock, mock_group: MagicMock) -> None: + def test_raises_if_missing_resource(self, _mock_export_task: MagicMock, _mock_group: MagicMock) -> None: subscription = create_subscription(team=self.team, created_by=self.user) - with pytest.raises(Exception) as e: + with self.settings(ASSET_GENERATION_MAX_TIMEOUT_MINUTES=1), pytest.raises(Exception) as e: generate_assets(subscription) assert str(e.value) == "There are no insights to be sent for this Subscription" - def test_excludes_deleted_insights_for_dashboard(self, mock_export_task: MagicMock, mock_group: MagicMock) -> None: + def test_excludes_deleted_insights_for_dashboard(self, mock_export_task: MagicMock, _mock_group: MagicMock) -> None: for i in range(1, 10): current_tile = self.tiles[i] if current_tile.insight is None: @@ -63,8 +65,30 @@ def test_excludes_deleted_insights_for_dashboard(self, mock_export_task: MagicMo current_tile.insight.save() subscription = create_subscription(team=self.team, dashboard=self.dashboard, created_by=self.user) - insights, assets = generate_assets(subscription) + with self.settings(ASSET_GENERATION_MAX_TIMEOUT_MINUTES=1): + insights, assets = generate_assets(subscription) - assert len(insights) == 1 - assert len(assets) == 1 - assert mock_export_task.s.call_count == 1 + assert len(insights) == 1 + assert len(assets) == 1 + assert mock_export_task.s.call_count == 1 + + def test_cancels_children_if_timed_out(self, _mock_export_task: MagicMock, mock_group: MagicMock) -> None: + # mock the group so that its children are never ready, + # and we capture calls to revoke + mock_running_exports = MagicMock() + mock_ready = MagicMock() + running_export_task = MagicMock() + + running_export_task.state = "PENDING" + + mock_ready.return_value = False + mock_group.return_value.apply_async.return_value = mock_running_exports + + mock_running_exports.children = [running_export_task] + mock_running_exports.ready = mock_ready + + with self.settings(ASSET_GENERATION_MAX_TIMEOUT_MINUTES=0.01), pytest.raises(Exception) as e: + generate_assets(self.subscription) + + assert str(e.value) == "Timed out waiting for celery task to finish" + running_export_task.revoke.assert_called() diff --git a/frontend/__snapshots__/components-compact-list--compact-list.png b/frontend/__snapshots__/components-compact-list--compact-list.png index 77272b769a3be..4a4b5e8704410 100644 Binary files a/frontend/__snapshots__/components-compact-list--compact-list.png and b/frontend/__snapshots__/components-compact-list--compact-list.png differ diff --git a/frontend/__snapshots__/components-empty-message--empty-message.png b/frontend/__snapshots__/components-empty-message--empty-message.png index 129c66bc75a9e..0dd61d3d9307f 100644 Binary files a/frontend/__snapshots__/components-empty-message--empty-message.png and b/frontend/__snapshots__/components-empty-message--empty-message.png differ diff --git a/frontend/__snapshots__/scenes-app-apps--installed.png b/frontend/__snapshots__/scenes-app-apps--installed.png index ca8d090ac795a..868ad1ca750e5 100644 Binary files a/frontend/__snapshots__/scenes-app-apps--installed.png and b/frontend/__snapshots__/scenes-app-apps--installed.png differ diff --git a/frontend/__snapshots__/scenes-app-feature-flags--edit-feature-flag.png b/frontend/__snapshots__/scenes-app-feature-flags--edit-feature-flag.png index d46c377343ff5..b5230de74303a 100644 Binary files a/frontend/__snapshots__/scenes-app-feature-flags--edit-feature-flag.png and b/frontend/__snapshots__/scenes-app-feature-flags--edit-feature-flag.png differ diff --git a/frontend/__snapshots__/scenes-app-feature-flags--edit-multi-variate-feature-flag.png b/frontend/__snapshots__/scenes-app-feature-flags--edit-multi-variate-feature-flag.png index 14c5577ddbf40..0effcab8fe9b8 100644 Binary files a/frontend/__snapshots__/scenes-app-feature-flags--edit-multi-variate-feature-flag.png and b/frontend/__snapshots__/scenes-app-feature-flags--edit-multi-variate-feature-flag.png differ diff --git a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-node-with-group-multivariate-flag-local-evaluation.png b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-node-with-group-multivariate-flag-local-evaluation.png index 9c86ac0b79891..5d5e7f2df7919 100644 Binary files a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-node-with-group-multivariate-flag-local-evaluation.png and b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-node-with-group-multivariate-flag-local-evaluation.png differ diff --git a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-overview.png b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-overview.png index c05094ac415cf..93b63311a152e 100644 Binary files a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-overview.png and b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-overview.png differ diff --git a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-python-with-local-evaluation.png b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-python-with-local-evaluation.png index 02d3bff5c2fcb..a132ecc449fb0 100644 Binary files a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-python-with-local-evaluation.png and b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-python-with-local-evaluation.png differ diff --git a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-react-native-with-bootstrap.png b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-react-native-with-bootstrap.png index 05bd83d8af7f0..7ef3045bcea27 100644 Binary files a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-react-native-with-bootstrap.png and b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-react-native-with-bootstrap.png differ diff --git a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-ruby-with-group-flag-local-evaluation.png b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-ruby-with-group-flag-local-evaluation.png index 2e20a5fab9eaf..c50a2b618be4e 100644 Binary files a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-ruby-with-group-flag-local-evaluation.png and b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructions-ruby-with-group-flag-local-evaluation.png differ diff --git a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructionsi-os-with-multivariate-flag.png b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructionsi-os-with-multivariate-flag.png index f1408645d5743..6bab8977291f3 100644 Binary files a/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructionsi-os-with-multivariate-flag.png and b/frontend/__snapshots__/scenes-app-feature-flags-code-examples--code-instructionsi-os-with-multivariate-flag.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--new-survey.png b/frontend/__snapshots__/scenes-app-surveys--new-survey.png index 68502e4422b8a..7919d71d4d68e 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--new-survey.png and b/frontend/__snapshots__/scenes-app-surveys--new-survey.png differ diff --git a/frontend/__snapshots__/scenes-app-surveys--surveys-list.png b/frontend/__snapshots__/scenes-app-surveys--surveys-list.png index c376b70a4e31f..8f5f6642dbecc 100644 Binary files a/frontend/__snapshots__/scenes-app-surveys--surveys-list.png and b/frontend/__snapshots__/scenes-app-surveys--surveys-list.png differ diff --git a/frontend/src/layout/navigation-3000/navbarItems.tsx b/frontend/src/layout/navigation-3000/navbarItems.tsx index 6628c1bfed15e..67b8aae9e744f 100644 --- a/frontend/src/layout/navigation-3000/navbarItems.tsx +++ b/frontend/src/layout/navigation-3000/navbarItems.tsx @@ -104,7 +104,7 @@ export const NAVBAR_ITEMS: NavbarItem[][] = [ ], [ { - identifier: Scene.Plugins, + identifier: Scene.Apps, label: 'Apps', icon: , }, diff --git a/frontend/src/layout/navigation/SideBar/SideBar.tsx b/frontend/src/layout/navigation/SideBar/SideBar.tsx index 970017b7c9750..6a857327057c3 100644 --- a/frontend/src/layout/navigation/SideBar/SideBar.tsx +++ b/frontend/src/layout/navigation/SideBar/SideBar.tsx @@ -238,7 +238,7 @@ function Pages(): JSX.Element { } - identifier={Scene.Plugins} + identifier={Scene.Apps} to={urls.projectApps()} /> )} diff --git a/frontend/src/lib/CommunityTag.tsx b/frontend/src/lib/CommunityTag.tsx deleted file mode 100644 index 2c5ef46278774..0000000000000 --- a/frontend/src/lib/CommunityTag.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag' - -export function CommunityTag({ isCommunity, noun = 'app' }: { isCommunity?: boolean; noun?: string }): JSX.Element { - return ( - - {isCommunity ? 'Community' : 'Official'} - - ) -} diff --git a/frontend/src/lib/components/Support/SupportModal.tsx b/frontend/src/lib/components/Support/SupportModal.tsx index 3d663df008d5a..a681dd85925fa 100644 --- a/frontend/src/lib/components/Support/SupportModal.tsx +++ b/frontend/src/lib/components/Support/SupportModal.tsx @@ -12,6 +12,7 @@ import { LemonFileInput } from 'lib/lemon-ui/LemonFileInput/LemonFileInput' import { useRef } from 'react' import { LemonInput, lemonToast } from '@posthog/lemon-ui' import { useUploadFiles } from 'lib/hooks/useUploadFiles' +import { userLogic } from 'scenes/userLogic' const SUPPORT_TICKET_OPTIONS: LemonSelectOptions = [ { @@ -45,6 +46,8 @@ export function SupportModal({ loggedIn = true }: { loggedIn?: boolean }): JSX.E const { sendSupportRequest, isSupportFormOpen, sendSupportLoggedOutRequest } = useValues(supportLogic) const { setSendSupportRequestValue, closeSupportForm } = useActions(supportLogic) const { objectStorageAvailable } = useValues(preflightLogic) + // the support model can be shown when logged out, file upload is not offered to anonymous users + const { user } = useValues(userLogic) if (!preflightLogic.values.preflight?.cloud) { if (isSupportFormOpen) { @@ -124,7 +127,7 @@ export function SupportModal({ loggedIn = true }: { loggedIn?: boolean }): JSX.E data-attr="support-form-content-input" {...props} /> - {objectStorageAvailable && ( + {objectStorageAvailable && !!user && ( ) => void 'data-attr'?: string 'aria-label'?: string + /** Whether to stop propagation of events from the input */ + stopPropagation?: boolean } export interface LemonInputPropsText extends LemonInputPropsBase { @@ -80,6 +82,7 @@ export const LemonInput = React.forwardRef(fu value, transparentBackground = false, size = 'medium', + stopPropagation = false, ...textProps }, ref @@ -160,6 +163,9 @@ export const LemonInput = React.forwardRef(fu type={(type === 'password' && passwordVisible ? 'text' : type) || 'text'} value={value} onChange={(event) => { + if (stopPropagation) { + event.stopPropagation() + } if (type === 'number') { onChange?.( !isNaN(event.currentTarget.valueAsNumber) ? event.currentTarget.valueAsNumber : undefined @@ -169,14 +175,23 @@ export const LemonInput = React.forwardRef(fu } }} onFocus={(event) => { + if (stopPropagation) { + event.stopPropagation() + } setFocused(true) onFocus?.(event) }} onBlur={(event) => { + if (stopPropagation) { + event.stopPropagation() + } setFocused(false) onBlur?.(event) }} onKeyDown={(event) => { + if (stopPropagation) { + event.stopPropagation() + } if (onPressEnter && event.key === 'Enter') { onPressEnter(event) } diff --git a/frontend/src/lib/lemon-ui/LemonTextArea/LemonTextArea.tsx b/frontend/src/lib/lemon-ui/LemonTextArea/LemonTextArea.tsx index 80544bf071426..77c94c64aad0f 100644 --- a/frontend/src/lib/lemon-ui/LemonTextArea/LemonTextArea.tsx +++ b/frontend/src/lib/lemon-ui/LemonTextArea/LemonTextArea.tsx @@ -34,11 +34,13 @@ export interface LemonTextAreaProps minRows?: number maxRows?: number rows?: number + /** Whether to stop propagation of events from the input */ + stopPropagation?: boolean } /** A `textarea` component for multi-line text. */ export const LemonTextArea = React.forwardRef(function _LemonTextArea( - { className, onChange, onPressCmdEnter: onPressEnter, minRows = 3, onKeyDown, ...textProps }, + { className, onChange, onPressCmdEnter: onPressEnter, minRows = 3, onKeyDown, stopPropagation, ...textProps }, ref ): JSX.Element { const _ref = useRef(null) @@ -50,13 +52,21 @@ export const LemonTextArea = React.forwardRef { + if (stopPropagation) { + e.stopPropagation() + } if (onPressEnter && e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { onPressEnter(textProps.value?.toString() ?? '') } onKeyDown?.(e) }} - onChange={(event) => onChange?.(event.currentTarget.value ?? '')} + onChange={(event) => { + if (stopPropagation) { + event.stopPropagation() + } + return onChange?.(event.currentTarget.value ?? '') + }} {...textProps} /> ) diff --git a/frontend/src/lib/utils/eventUsageLogic.ts b/frontend/src/lib/utils/eventUsageLogic.ts index fb6a7f022dd31..48029b8fac8fb 100644 --- a/frontend/src/lib/utils/eventUsageLogic.ts +++ b/frontend/src/lib/utils/eventUsageLogic.ts @@ -500,6 +500,10 @@ export const eventUsageLogic = kea({ reportSurveyResumed: (survey: Survey) => ({ survey }), reportSurveyArchived: (survey: Survey) => ({ survey }), reportProductUnsubscribed: (product: string) => ({ product }), + // onboarding + reportOnboardingProductSelected: (productKey: string) => ({ productKey }), + reportOnboardingCompleted: (productKey: string) => ({ productKey }), + reportSubscribedDuringOnboarding: (productKey: string) => ({ productKey }), }, listeners: ({ values }) => ({ reportAxisUnitsChanged: (properties) => { @@ -1239,5 +1243,21 @@ export const eventUsageLogic = kea({ $set: { [property_key]: true }, }) }, + // onboarding + reportOnboardingProductSelected: ({ productKey }) => { + posthog.capture('onboarding product selected', { + product_key: productKey, + }) + }, + reportOnboardingCompleted: ({ productKey }) => { + posthog.capture('onboarding completed', { + product_key: productKey, + }) + }, + reportSubscribedDuringOnboarding: ({ productKey }) => { + posthog.capture('subscribed during onboarding', { + product_key: productKey, + }) + }, }), }) diff --git a/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.scss b/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.scss index a6cf8cf5b3d5f..89ce0aff2b6ed 100644 --- a/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.scss +++ b/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.scss @@ -24,24 +24,11 @@ display: flex; align-items: center; justify-content: flex-start; - padding: 0.5rem; + padding: 0 0.5rem; overflow: hidden; border-radius: var(--radius); margin: calc(var(--radius) / 2) 0; - - &:hover { - background-color: var(--mid); - } - - &.disabled { - cursor: not-allowed; - color: var(--muted); - background-color: var(--mid); - } - - &.selected { - background-color: var(--primary-bg-hover); - } + background-color: var(--primary-bg-hover); } .selected-column-col { @@ -56,23 +43,7 @@ padding-right: 0.25rem; svg { - transform: rotate(90deg) translateX(2px); + transform: rotate(90deg); } } } - -.column-configurator-modal-sortable-container { - z-index: 9999; - display: flex; - align-items: center; - justify-content: flex-start; - padding: 0.5rem; - background-color: var(--primary-bg-hover); - - .drag-handle { - color: var(--default); - font-size: 1.2em; - padding-right: 0.25rem; - transform: rotate(90deg) translateX(2px); - } -} diff --git a/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.tsx b/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.tsx index 260f1d4174d85..0664460091e15 100644 --- a/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.tsx +++ b/frontend/src/queries/nodes/DataTable/ColumnConfigurator/ColumnConfigurator.tsx @@ -3,14 +3,7 @@ import { BindLogic, useActions, useValues } from 'kea' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { dataTableLogic } from '~/queries/nodes/DataTable/dataTableLogic' import { IconClose, IconEdit, IconTuning, SortableDragIcon } from 'lib/lemon-ui/icons' -import clsx from 'clsx' import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { - SortableContainer as sortableContainer, - SortableElement as sortableElement, - SortableHandle as sortableHandle, -} from 'react-sortable-hoc' -import VirtualizedList, { ListRowProps } from 'react-virtualized/dist/es/List' import { AutoSizer } from 'react-virtualized/dist/es/AutoSizer' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' import { useState } from 'react' @@ -26,6 +19,10 @@ import { PropertyFilterType } from '~/types' import { TeamMembershipLevel } from 'lib/constants' import { RestrictedArea, RestrictedComponentProps, RestrictionScope } from 'lib/components/RestrictedArea' import { LemonCheckbox } from 'lib/lemon-ui/LemonCheckbox' +import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { restrictToParentElement, restrictToVerticalAxis } from '@dnd-kit/modifiers' +import { DndContext } from '@dnd-kit/core' +import { CSS } from '@dnd-kit/utilities' let uniqueNode = 0 @@ -85,98 +82,17 @@ export function ColumnConfigurator({ query, setQuery }: ColumnConfiguratorProps) } function ColumnConfiguratorModal({ query }: ColumnConfiguratorProps): JSX.Element { - // the virtualised list doesn't support gaps between items in the list - // setting the container to be larger than we need - // and adding a container with a smaller height to each row item - // allows the new row item to set a margin around itself - const rowContainerHeight = 36 - const rowItemHeight = 32 - const { modalVisible, columns, saveAsDefault } = useValues(columnConfiguratorLogic) const { hideModal, moveColumn, setColumns, selectColumn, unselectColumn, save, toggleSaveAsDefault } = useActions(columnConfiguratorLogic) - const DragHandle = sortableHandle(() => ( - - - - )) - const SelectedColumn = ({ column, dataIndex }: { column: string; dataIndex: number }): JSX.Element => { - let columnType: PropertyFilterType | null = null - let columnKey = column - if (column.startsWith('person.properties.')) { - columnType = PropertyFilterType.Person - columnKey = column.substring(18) - } - if (column.startsWith('properties.')) { - columnType = PropertyFilterType.Event - columnKey = column.substring(11) + const onEditColumn = (column: string, index: number): void => { + const newColumn = window.prompt('Edit column', column) + if (newColumn) { + setColumns(columns.map((c, i) => (i === index ? newColumn : c))) } - - columnKey = trimQuotes(extractExpressionComment(columnKey)) - - return ( -
- - {columnType && } - -
- - { - const newColumn = window.prompt('Edit column', column) - if (newColumn) { - setColumns(columns.map((c, i) => (i === dataIndex ? newColumn : c))) - } - }} - status="primary" - size="small" - > - - - - - unselectColumn(column)} status="danger" size="small"> - - - -
- ) - } - - const SortableSelectedColumn = sortableElement(SelectedColumn) - - const SortableSelectedColumnRenderer = ({ index, style, key }: ListRowProps): JSX.Element => { - return ( -
- -
- ) } - const SortableColumnList = sortableContainer(() => ( -
- - {({ height, width }: { height: number; width: number }) => { - return ( - - ) - }} - -
- )) - return ( Visible columns ({columns.length}) - Drag to reorder - moveColumn(oldIndex, newIndex)} - distance={5} - useDragHandle - lockAxis="y" - /> + { + if (over && active.id !== over.id) { + moveColumn( + columns.indexOf(active.id.toString()), + columns.indexOf(over.id.toString()) + ) + } + }} + modifiers={[restrictToVerticalAxis, restrictToParentElement]} + > + + {columns.map((column, index) => ( + + ))} + +

Available columns

+ {/* eslint-disable-next-line react/forbid-dom-props */}
{({ height, width }: { height: number; width: number }) => ( @@ -271,3 +204,61 @@ function ColumnConfiguratorModal({ query }: ColumnConfiguratorProps): JSX.Elemen ) } + +const SelectedColumn = ({ + column, + dataIndex, + onEdit, + onRemove, +}: { + column: string + dataIndex: number + onEdit: (column: string, index: number) => void + onRemove: (column: string) => void +}): JSX.Element => { + const { setNodeRef, attributes, transform, transition, listeners } = useSortable({ id: column }) + + let columnType: PropertyFilterType | null = null + let columnKey = column + if (column.startsWith('person.properties.')) { + columnType = PropertyFilterType.Person + columnKey = column.substring(18) + } + if (column.startsWith('properties.')) { + columnType = PropertyFilterType.Event + columnKey = column.substring(11) + } + + columnKey = trimQuotes(extractExpressionComment(columnKey)) + + return ( +
+
+ + + + {columnType && } + +
+ + onEdit(column, dataIndex)} status="primary" size="small"> + + + + + onRemove(column)} status="danger" size="small"> + + + +
+
+ ) +} diff --git a/frontend/src/queries/nodes/DataTable/DataTable.tsx b/frontend/src/queries/nodes/DataTable/DataTable.tsx index c834d3bf5903f..67c2c6a6e7e37 100644 --- a/frontend/src/queries/nodes/DataTable/DataTable.tsx +++ b/frontend/src/queries/nodes/DataTable/DataTable.tsx @@ -558,9 +558,9 @@ export function DataTable({ uniqueKey, query, setQuery, context, cachedResults } } footer={ canLoadNextData && - ((response as any).results.length > 0 || !responseLoading) && ( - - ) + ((response as any).results.length > 0 || + (response as any).result.length > 0 || + !responseLoading) && } /> )} diff --git a/frontend/src/queries/nodes/DataTable/dataTableLogic.ts b/frontend/src/queries/nodes/DataTable/dataTableLogic.ts index e027118feb0f9..e4e21bd7321a7 100644 --- a/frontend/src/queries/nodes/DataTable/dataTableLogic.ts +++ b/frontend/src/queries/nodes/DataTable/dataTableLogic.ts @@ -138,9 +138,15 @@ export const dataTableLogic = kea([ })) } - return response && 'results' in response && Array.isArray(response.results) - ? response.results.map((result: any) => ({ result })) ?? null + const results = !response + ? null + : 'results' in response && Array.isArray(response.results) + ? response.results + : 'result' in response && Array.isArray(response.result) + ? response.result : null + + return results ? results.map((result: any) => ({ result })) ?? null : null }, ], queryWithDefaults: [ diff --git a/frontend/src/queries/nodes/DataTable/renderColumn.tsx b/frontend/src/queries/nodes/DataTable/renderColumn.tsx index 31fd84b3e4f06..5ba69f9db77a5 100644 --- a/frontend/src/queries/nodes/DataTable/renderColumn.tsx +++ b/frontend/src/queries/nodes/DataTable/renderColumn.tsx @@ -18,7 +18,7 @@ import { import { combineUrl, router } from 'kea-router' import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' import { DeletePersonButton } from '~/queries/nodes/PersonsNode/DeletePersonButton' -import ReactJson from 'react-json-view' +import ReactJson from '@microlink/react-json-view' import { errorColumn, loadingColumn } from '~/queries/nodes/DataTable/dataTableLogic' import { Spinner } from 'lib/lemon-ui/Spinner/Spinner' import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag' diff --git a/frontend/src/queries/queryFeatures.ts b/frontend/src/queries/queryFeatures.ts index 16325be0a6a44..38544d5271afc 100644 --- a/frontend/src/queries/queryFeatures.ts +++ b/frontend/src/queries/queryFeatures.ts @@ -1,4 +1,12 @@ -import { isEventsQuery, isHogQLQuery, isPersonsNode, isPersonsQuery } from '~/queries/utils' +import { + isEventsQuery, + isHogQLQuery, + isPersonsNode, + isPersonsQuery, + isWebTopClicksQuery, + isWebTopPagesQuery, + isWebTopSourcesQuery, +} from '~/queries/utils' import { Node } from '~/queries/schema' export enum QueryFeature { @@ -46,5 +54,10 @@ export function getQueryFeatures(query: Node): Set { features.add(QueryFeature.resultIsArrayOfArrays) } + if (isWebTopSourcesQuery(query) || isWebTopPagesQuery(query) || isWebTopClicksQuery(query)) { + features.add(QueryFeature.columnsInResponse) + features.add(QueryFeature.resultIsArrayOfArrays) + } + return features } diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index ef494c38d90b1..8910b03486091 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -98,6 +98,15 @@ }, { "$ref": "#/definitions/HogQLMetadata" + }, + { + "$ref": "#/definitions/WebTopSourcesQuery" + }, + { + "$ref": "#/definitions/WebTopClicksQuery" + }, + { + "$ref": "#/definitions/WebTopPagesQuery" } ] }, @@ -320,6 +329,10 @@ "description": "Show the kebab menu at the end of the row", "type": "boolean" }, + "showBackButton": { + "description": "Show a button to go back to the source query", + "type": "boolean" + }, "showColumnConfigurator": { "description": "Show a button to configure the table's columns if possible", "type": "boolean" @@ -395,6 +408,15 @@ }, { "$ref": "#/definitions/TimeToSeeDataSessionsQuery" + }, + { + "$ref": "#/definitions/WebTopSourcesQuery" + }, + { + "$ref": "#/definitions/WebTopClicksQuery" + }, + { + "$ref": "#/definitions/WebTopPagesQuery" } ], "description": "Source of the events" @@ -1997,6 +2019,10 @@ "description": "Show the kebab menu at the end of the row", "type": "boolean" }, + "showBackButton": { + "description": "Show a button to go back to the source query", + "type": "boolean" + }, "showColumnConfigurator": { "description": "Show a button to configure the table's columns if possible", "type": "boolean" @@ -2383,6 +2409,169 @@ }, "required": ["result"], "type": "object" + }, + "WebAnalyticsFilters": {}, + "WebTopClicksQuery": { + "additionalProperties": false, + "properties": { + "dateRange": { + "$ref": "#/definitions/DateRange" + }, + "filters": { + "$ref": "#/definitions/WebAnalyticsFilters" + }, + "kind": { + "const": "WebTopClicksQuery", + "type": "string" + }, + "response": { + "$ref": "#/definitions/WebTopClicksQueryResponse" + } + }, + "required": ["kind", "filters"], + "type": "object" + }, + "WebTopClicksQueryResponse": { + "additionalProperties": false, + "properties": { + "columns": { + "items": {}, + "type": "array" + }, + "is_cached": { + "type": "boolean" + }, + "last_refresh": { + "type": "string" + }, + "next_allowed_client_refresh": { + "type": "string" + }, + "result": { + "items": {}, + "type": "array" + }, + "timings": { + "items": { + "$ref": "#/definitions/QueryTiming" + }, + "type": "array" + }, + "types": { + "items": {}, + "type": "array" + } + }, + "required": ["result"], + "type": "object" + }, + "WebTopPagesQuery": { + "additionalProperties": false, + "properties": { + "dateRange": { + "$ref": "#/definitions/DateRange" + }, + "filters": { + "$ref": "#/definitions/WebAnalyticsFilters" + }, + "kind": { + "const": "WebTopPagesQuery", + "type": "string" + }, + "response": { + "$ref": "#/definitions/WebTopPagesQueryResponse" + } + }, + "required": ["kind", "filters"], + "type": "object" + }, + "WebTopPagesQueryResponse": { + "additionalProperties": false, + "properties": { + "columns": { + "items": {}, + "type": "array" + }, + "is_cached": { + "type": "boolean" + }, + "last_refresh": { + "type": "string" + }, + "next_allowed_client_refresh": { + "type": "string" + }, + "result": { + "items": {}, + "type": "array" + }, + "timings": { + "items": { + "$ref": "#/definitions/QueryTiming" + }, + "type": "array" + }, + "types": { + "items": {}, + "type": "array" + } + }, + "required": ["result"], + "type": "object" + }, + "WebTopSourcesQuery": { + "additionalProperties": false, + "properties": { + "dateRange": { + "$ref": "#/definitions/DateRange" + }, + "filters": { + "$ref": "#/definitions/WebAnalyticsFilters" + }, + "kind": { + "const": "WebTopSourcesQuery", + "type": "string" + }, + "response": { + "$ref": "#/definitions/WebTopSourcesQueryResponse" + } + }, + "required": ["kind", "filters"], + "type": "object" + }, + "WebTopSourcesQueryResponse": { + "additionalProperties": false, + "properties": { + "columns": { + "items": {}, + "type": "array" + }, + "is_cached": { + "type": "boolean" + }, + "last_refresh": { + "type": "string" + }, + "next_allowed_client_refresh": { + "type": "string" + }, + "result": { + "items": {}, + "type": "array" + }, + "timings": { + "items": { + "$ref": "#/definitions/QueryTiming" + }, + "type": "array" + }, + "types": { + "items": {}, + "type": "array" + } + }, + "required": ["result"], + "type": "object" } } } diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 08a101f81eb93..1da318fb12f1b 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -60,6 +60,11 @@ export enum NodeKind { StickinessQuery = 'StickinessQuery', LifecycleQuery = 'LifecycleQuery', + // Web analytics queries + WebTopSourcesQuery = 'WebTopSourcesQuery', + WebTopPagesQuery = 'WebTopPagesQuery', + WebTopClicksQuery = 'WebTopClicksQuery', + // Time to see data TimeToSeeDataSessionsQuery = 'TimeToSeeDataSessionsQuery', TimeToSeeDataQuery = 'TimeToSeeDataQuery', @@ -80,6 +85,10 @@ export type AnyDataNode = | PersonsQuery | HogQLQuery | HogQLMetadata + | TimeToSeeDataSessionsQuery + | WebTopSourcesQuery + | WebTopClicksQuery + | WebTopPagesQuery export type QuerySchema = // Data nodes (see utils.ts) @@ -283,7 +292,16 @@ export type HasPropertiesNode = EventsNode | EventsQuery | PersonsNode export interface DataTableNode extends Node, DataTableNodeViewProps { kind: NodeKind.DataTableNode /** Source of the events */ - source: EventsNode | EventsQuery | PersonsNode | PersonsQuery | HogQLQuery | TimeToSeeDataSessionsQuery + source: + | EventsNode + | EventsQuery + | PersonsNode + | PersonsQuery + | HogQLQuery + | TimeToSeeDataSessionsQuery + | WebTopSourcesQuery + | WebTopClicksQuery + | WebTopPagesQuery /** Columns shown in the table, unless the `source` provides them. */ columns?: HogQLExpression[] @@ -516,6 +534,45 @@ export interface PersonsQuery extends DataNode { response?: PersonsQueryResponse } +export type WebAnalyticsFilters = any + +export interface WebAnalyticsQueryBase { + dateRange?: DateRange +} + +export interface WebTopSourcesQuery extends WebAnalyticsQueryBase { + kind: NodeKind.WebTopSourcesQuery + filters: WebAnalyticsFilters + response?: WebTopSourcesQueryResponse +} +export interface WebTopSourcesQueryResponse extends QueryResponse { + result: unknown[] + types?: unknown[] + columns?: unknown[] +} + +export interface WebTopClicksQuery extends WebAnalyticsQueryBase { + kind: NodeKind.WebTopClicksQuery + filters: WebAnalyticsFilters + response?: WebTopClicksQueryResponse +} +export interface WebTopClicksQueryResponse extends QueryResponse { + result: unknown[] + types?: unknown[] + columns?: unknown[] +} + +export interface WebTopPagesQuery extends WebAnalyticsQueryBase { + kind: NodeKind.WebTopPagesQuery + filters: WebAnalyticsFilters + response?: WebTopPagesQueryResponse +} +export interface WebTopPagesQueryResponse extends QueryResponse { + result: unknown[] + types?: unknown[] + columns?: unknown[] +} + export type InsightQueryNode = | TrendsQuery | FunnelsQuery diff --git a/frontend/src/queries/utils.ts b/frontend/src/queries/utils.ts index 27447651870e3..ecdef8a2b62d3 100644 --- a/frontend/src/queries/utils.ts +++ b/frontend/src/queries/utils.ts @@ -28,6 +28,9 @@ import { SavedInsightNode, PersonsQuery, HogQLMetadata, + WebTopSourcesQuery, + WebTopClicksQuery, + WebTopPagesQuery, } from '~/queries/schema' import { TaxonomicFilterGroupType, TaxonomicFilterValue } from 'lib/components/TaxonomicFilter/types' import { dayjs } from 'lib/dayjs' @@ -109,6 +112,18 @@ export function isHogQLMetadata(node?: Node | null): node is HogQLMetadata { return node?.kind === NodeKind.HogQLMetadata } +export function isWebTopSourcesQuery(node?: Node | null): node is WebTopSourcesQuery { + return node?.kind === NodeKind.WebTopSourcesQuery +} + +export function isWebTopClicksQuery(node?: Node | null): node is WebTopClicksQuery { + return node?.kind === NodeKind.WebTopClicksQuery +} + +export function isWebTopPagesQuery(node?: Node | null): node is WebTopPagesQuery { + return node?.kind === NodeKind.WebTopPagesQuery +} + export function containsHogQLQuery(node?: Node | null): boolean { if (!node) { return false diff --git a/frontend/src/scenes/appScenes.ts b/frontend/src/scenes/appScenes.ts index 6464bc2e5c79e..08e3d86ff793a 100644 --- a/frontend/src/scenes/appScenes.ts +++ b/frontend/src/scenes/appScenes.ts @@ -62,7 +62,7 @@ export const appScenes: Record any> = { [Scene.InviteSignup]: () => import('./authentication/InviteSignup'), [Scene.Ingestion]: () => import('./ingestion/IngestionWizard'), [Scene.Billing]: () => import('./billing/Billing'), - [Scene.Plugins]: () => import('./plugins/Plugins'), + [Scene.Apps]: () => import('./plugins/AppsScene'), [Scene.FrontendAppScene]: () => import('./apps/FrontendAppScene'), [Scene.AppMetrics]: () => import('./apps/AppMetricsScene'), [Scene.Login]: () => import('./authentication/Login'), diff --git a/frontend/src/scenes/events/EventDetails.tsx b/frontend/src/scenes/events/EventDetails.tsx index 708b367f26e86..9bf9efcf96648 100644 --- a/frontend/src/scenes/events/EventDetails.tsx +++ b/frontend/src/scenes/events/EventDetails.tsx @@ -9,7 +9,7 @@ import { dayjs } from 'lib/dayjs' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { pluralize } from 'lib/utils' import { LemonTableProps } from 'lib/lemon-ui/LemonTable' -import ReactJson from 'react-json-view' +import ReactJson from '@microlink/react-json-view' import { ErrorDisplay } from 'lib/components/Errors/ErrorDisplay' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' diff --git a/frontend/src/scenes/experiments/ExperimentPreview.tsx b/frontend/src/scenes/experiments/ExperimentPreview.tsx index 5be68a0c33e06..508967a52082b 100644 --- a/frontend/src/scenes/experiments/ExperimentPreview.tsx +++ b/frontend/src/scenes/experiments/ExperimentPreview.tsx @@ -49,7 +49,7 @@ export function ExperimentPreview({ isExperimentGoalModalOpen, isExperimentExposureModalOpen, experimentLoading, - experimentCountPerUserMath, + experimentMathAggregationForTrends, } = useValues(experimentLogic({ experimentId })) const { setExperiment, @@ -290,48 +290,51 @@ export function ExperimentPreview({ Change experiment goal
- {experimentInsightType === InsightType.TRENDS && !experimentCountPerUserMath && ( - <> -
- Exposure metric - - - -
- {experiment.parameters?.custom_exposure_filter ? ( - - ) : ( - - Default via $feature_flag_called events - - )} -
- - +
+ Exposure metric + - Change exposure metric - - {experiment.parameters?.custom_exposure_filter && ( + + +
+ {experiment.parameters?.custom_exposure_filter ? ( + + ) : ( + + Default via $feature_flag_called events + + )} +
+ updateExperimentExposure(null)} > - Reset exposure + Change exposure metric - )} - -
- - )} + {experiment.parameters?.custom_exposure_filter && ( + updateExperimentExposure(null)} + > + Reset exposure + + )} +
+
+ + )} )} diff --git a/frontend/src/scenes/experiments/ExperimentResult.tsx b/frontend/src/scenes/experiments/ExperimentResult.tsx index bd5aec02263bf..25777a70230c0 100644 --- a/frontend/src/scenes/experiments/ExperimentResult.tsx +++ b/frontend/src/scenes/experiments/ExperimentResult.tsx @@ -26,7 +26,7 @@ export function ExperimentResult(): JSX.Element { areTrendResultsConfusing, experimentResultCalculationError, sortedExperimentResultVariants, - experimentCountPerUserMath, + experimentMathAggregationForTrends, } = useValues(experimentLogic) return ( @@ -116,7 +116,10 @@ export function ExperimentResult(): JSX.Element { /> )} - {experimentCountPerUserMath ? 'metric' : 'count'}: + {experimentMathAggregationForTrends + ? 'metric' + : 'count'} + : {' '} diff --git a/frontend/src/scenes/experiments/experimentLogic.tsx b/frontend/src/scenes/experiments/experimentLogic.tsx index 096c9333b4af4..1e19fd66030ca 100644 --- a/frontend/src/scenes/experiments/experimentLogic.tsx +++ b/frontend/src/scenes/experiments/experimentLogic.tsx @@ -19,6 +19,7 @@ import { CountPerActorMathType, ActionFilter as ActionFilterType, TrendExperimentVariant, + PropertyMathType, } from '~/types' import type { experimentLogicType } from './experimentLogicType' import { router, urlToAction } from 'kea-router' @@ -642,11 +643,11 @@ export const experimentLogic = kea([ return experiment?.parameters?.feature_flag_variants || [] }, ], - experimentCountPerUserMath: [ + experimentMathAggregationForTrends: [ (s) => [s.experiment], - (experiment): string | undefined => { + (experiment): PropertyMathType | CountPerActorMathType | undefined => { // Find out if we're using count per actor math aggregates averages per user - const mathValue = ( + const userMathValue = ( [ ...(experiment?.filters?.events || []), ...(experiment?.filters?.actions || []), @@ -655,7 +656,21 @@ export const experimentLogic = kea([ Object.values(CountPerActorMathType).includes(entity?.math as CountPerActorMathType) )[0]?.math - return mathValue + // alternatively, if we're using property math + // remove 'sum' property math from the list of math types + // since we can handle that as a regular case + const targetValues = Object.values(PropertyMathType).filter((value) => value !== PropertyMathType.Sum) + // sync with the backend at https://github.com/PostHog/posthog/blob/master/ee/clickhouse/queries/experiments/trend_experiment_result.py#L44 + // the function uses_math_aggregation_by_user_or_property_value + + const propertyMathValue = ( + [ + ...(experiment?.filters?.events || []), + ...(experiment?.filters?.actions || []), + ] as ActionFilterType[] + ).filter((entity) => targetValues.includes(entity?.math as PropertyMathType))[0]?.math + + return (userMathValue ?? propertyMathValue) as PropertyMathType | CountPerActorMathType | undefined }, ], minimumDetectableChange: [ @@ -803,8 +818,8 @@ export const experimentLogic = kea([ }, ], countDataForVariant: [ - (s) => [s.experimentResults, s.experimentCountPerUserMath], - (experimentResults, experimentCountPerUserMath) => + (s) => [s.experimentResults, s.experimentMathAggregationForTrends], + (experimentResults, experimentMathAggregationForTrends) => (variant: string): string => { const errorResult = '--' if (!experimentResults) { @@ -819,8 +834,30 @@ export const experimentLogic = kea([ let result = variantResults.count - if (experimentCountPerUserMath) { - result = variantResults.count / variantResults.data.length + if (experimentMathAggregationForTrends) { + // TODO: Aggregate end result appropriately for nth percentile + if ( + [ + CountPerActorMathType.Average, + CountPerActorMathType.Median, + PropertyMathType.Average, + PropertyMathType.Median, + ].includes(experimentMathAggregationForTrends) + ) { + result = variantResults.count / variantResults.data.length + } else if ( + [CountPerActorMathType.Maximum, PropertyMathType.Maximum].includes( + experimentMathAggregationForTrends + ) + ) { + result = Math.max(...variantResults.data) + } else if ( + [CountPerActorMathType.Minimum, PropertyMathType.Minimum].includes( + experimentMathAggregationForTrends + ) + ) { + result = Math.min(...variantResults.data) + } } if (result % 1 !== 0) { diff --git a/frontend/src/scenes/feature-flags/FeatureFlagCodeInstructions.stories.tsx b/frontend/src/scenes/feature-flags/FeatureFlagCodeInstructions.stories.tsx index 5ac711aabde81..48605e78b7123 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlagCodeInstructions.stories.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlagCodeInstructions.stories.tsx @@ -2,7 +2,7 @@ import { Meta } from '@storybook/react' import { CodeInstructions, CodeInstructionsProps } from './FeatureFlagInstructions' import { OPTIONS } from './FeatureFlagCodeOptions' -import { FeatureFlagType } from '~/types' +import { FeatureFlagType, SDKKey } from '~/types' import { useStorybookMocks } from '~/mocks/browser' import { useAvailableFeatures } from '~/mocks/features' import { AvailableFeature } from '~/types' @@ -79,7 +79,7 @@ const meta: Meta = { component: CodeInstructions, args: { options: OPTIONS, - selectedLanguage: 'JavaScript', + selectedLanguage: SDKKey.JS_WEB, featureFlag: REGULAR_FEATURE_FLAG, showLocalEval: false, showBootstrap: false, @@ -99,11 +99,11 @@ export const CodeInstructionsOverview = (props: CodeInstructionsProps): JSX.Elem } export const CodeInstructionsReactNativeWithBootstrap = (): JSX.Element => { - return + return } export const CodeInstructionsPythonWithLocalEvaluation = (): JSX.Element => { - return + return } export const CodeInstructionsRubyWithGroupFlagLocalEvaluation = (): JSX.Element => { @@ -119,7 +119,7 @@ export const CodeInstructionsRubyWithGroupFlagLocalEvaluation = (): JSX.Element }) return ( { - return + return } export const CodeInstructionsNodeWithGroupMultivariateFlagLocalEvaluation = (): JSX.Element => { @@ -144,7 +144,7 @@ export const CodeInstructionsNodeWithGroupMultivariateFlagLocalEvaluation = (): }) return ( JSX.Element type: LibraryType + key: SDKKey } export enum LibraryType { @@ -39,72 +41,83 @@ export const OPTIONS: InstructionOption[] = [ documentationLink: `${DOC_BASE_URL}integrations/js-integration${UTM_TAGS}`, Snippet: JSSnippet, type: LibraryType.Client, + key: SDKKey.JS_WEB, }, { value: 'Android', documentationLink: `${DOC_BASE_URL}integrate/client/android${UTM_TAGS}`, Snippet: AndroidSnippet, type: LibraryType.Client, + key: SDKKey.ANDROID, }, { value: 'iOS', documentationLink: `${DOC_BASE_URL}integrate/client/ios${UTM_TAGS}`, Snippet: iOSSnippet, type: LibraryType.Client, + key: SDKKey.IOS, }, { value: 'React Native', documentationLink: `${DOC_BASE_URL}integrate/client/react-native${UTM_TAGS}`, Snippet: ReactNativeSnippet, type: LibraryType.Client, + key: SDKKey.REACT_NATIVE, }, { value: 'React', documentationLink: `${DOC_BASE_URL}libraries/react${UTM_TAGS}`, Snippet: ReactSnippet, type: LibraryType.Client, + key: SDKKey.REACT, }, { value: 'Node.js', documentationLink: `${DOC_BASE_URL}integrations/node-integration${UTM_TAGS}`, Snippet: NodeJSSnippet, type: LibraryType.Server, + key: SDKKey.NODE_JS, }, { value: 'Python', documentationLink: `${DOC_BASE_URL}integrations/python-integration${UTM_TAGS}`, Snippet: PythonSnippet, type: LibraryType.Server, + key: SDKKey.PYTHON, }, { value: 'Ruby', documentationLink: `${DOC_BASE_URL}integrations/ruby-integration${UTM_TAGS}`, Snippet: RubySnippet, type: LibraryType.Server, + key: SDKKey.RUBY, }, { value: 'API', documentationLink: `${DOC_BASE_URL}api/post-only-endpoints#example-request--response-decide-v3`, Snippet: APISnippet, type: LibraryType.Server, + key: SDKKey.API, }, { value: 'PHP', documentationLink: `${DOC_BASE_URL}integrations/php-integration${UTM_TAGS}`, Snippet: PHPSnippet, type: LibraryType.Server, + key: SDKKey.PHP, }, { value: 'Go', documentationLink: `${DOC_BASE_URL}integrations/go-integration${UTM_TAGS}`, Snippet: GolangSnippet, type: LibraryType.Server, + key: SDKKey.GO, }, ] -export const LOCAL_EVALUATION_LIBRARIES: string[] = ['Node.js', 'Python', 'Ruby', 'PHP', 'Go'] +export const LOCAL_EVALUATION_LIBRARIES: string[] = [SDKKey.NODE_JS, SDKKey.PYTHON, SDKKey.RUBY, SDKKey.PHP, SDKKey.GO] -export const PAYLOAD_LIBRARIES: string[] = ['JavaScript', 'Node.js', 'Python', 'Ruby', 'React'] +export const PAYLOAD_LIBRARIES: string[] = [SDKKey.JS_WEB, SDKKey.NODE_JS, SDKKey.PYTHON, SDKKey.RUBY, SDKKey.REACT] export const BOOTSTRAPPING_OPTIONS: InstructionOption[] = [ { @@ -112,11 +125,13 @@ export const BOOTSTRAPPING_OPTIONS: InstructionOption[] = [ documentationLink: `${DOC_BASE_URL}integrations/js-integration${UTM_TAGS}${BOOTSTRAPPING_ANCHOR}`, Snippet: JSBootstrappingSnippet, type: LibraryType.Client, + key: SDKKey.JS_WEB, }, { value: 'React Native', documentationLink: `${DOC_BASE_URL}integrate/client/react-native${UTM_TAGS}${BOOTSTRAPPING_ANCHOR}`, Snippet: JSBootstrappingSnippet, type: LibraryType.Client, + key: SDKKey.REACT_NATIVE, }, ] diff --git a/frontend/src/scenes/feature-flags/FeatureFlagInstructions.tsx b/frontend/src/scenes/feature-flags/FeatureFlagInstructions.tsx index fa8e8334f5a7d..2c32d7f615bf3 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlagInstructions.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlagInstructions.tsx @@ -38,6 +38,8 @@ export interface CodeInstructionsProps { dataAttr?: string showLocalEval?: boolean showBootstrap?: boolean + showAdvancedOptions?: boolean + showFooter?: boolean } export function CodeInstructions({ @@ -47,6 +49,8 @@ export function CodeInstructions({ dataAttr = '', showLocalEval = false, showBootstrap = false, + showAdvancedOptions = true, + showFooter = true, }: CodeInstructionsProps): JSX.Element { const [defaultSelectedOption] = options const [selectedOption, setSelectedOption] = useState(defaultSelectedOption) @@ -84,7 +88,7 @@ export function CodeInstructions({ } const selectOption = (selectedValue: string): void => { - const option = options.find((option) => option.value === selectedValue) + const option = options.find((option) => option.key === selectedValue) if (option) { setSelectedOption(option) @@ -101,7 +105,7 @@ export function CodeInstructions({ setShowLocalEvalCode(false) } - const bootstrapOption = BOOTSTRAPPING_OPTIONS.find((bootstrapOption) => bootstrapOption.value === selectedValue) + const bootstrapOption = BOOTSTRAPPING_OPTIONS.find((bootstrapOption) => bootstrapOption.key === selectedValue) if (bootstrapOption) { setBootstrapOption(bootstrapOption) } else { @@ -113,7 +117,7 @@ export function CodeInstructions({ selectOption(selectedLanguage) } else { // When flag definition changes, de-select any options that can't be selected anymore - selectOption(selectedOption.value) + selectOption(selectedOption.key) } if ( @@ -144,105 +148,107 @@ export function CodeInstructions({ return (
-
-
- option.type == LibraryType.Client).map( - (option) => ({ - value: option.value, - label: option.value, - 'data-attr': `feature-flag-instructions-select-option-${option.value}`, - }) - ), - }, - { - title: 'Server libraries', - options: OPTIONS.filter((option) => option.type == LibraryType.Server).map( - (option) => ({ - value: option.value, - label: option.value, - 'data-attr': `feature-flag-instructions-select-option-${option.value}`, - }) - ), - }, - ]} - onChange={(val) => { - if (val) { - selectOption(val) - reportFlagsCodeExampleLanguage(val) - } - }} - value={selectedOption.value} - /> -
- ` ${payloadOption}` - )}`} - > -
- { - setShowPayloadCode(!showPayloadCode) - reportFlagsCodeExampleInteraction('payloads') + {showAdvancedOptions && ( +
+
+ option.type == LibraryType.Client).map( + (option) => ({ + value: option.key, + label: option.value, + 'data-attr': `feature-flag-instructions-select-option-${option.key}`, + }) + ), + }, + { + title: 'Server libraries', + options: OPTIONS.filter((option) => option.type == LibraryType.Server).map( + (option) => ({ + value: option.key, + label: option.value, + 'data-attr': `feature-flag-instructions-select-option-${option.key}`, + }) + ), + }, + ]} + onChange={(val) => { + if (val) { + selectOption(val) + reportFlagsCodeExampleLanguage(val) + } }} - data-attr="flags-code-example-payloads-option" - checked={showPayloadCode} - disabled={!PAYLOAD_LIBRARIES.includes(selectedOption.value)} + value={selectedOption.key} /> -
- - <> ` ${payloadOption}` + )}`} >
{ - setShowBootstrapCode(!showBootstrapCode) - reportFlagsCodeExampleInteraction('bootstrap') + setShowPayloadCode(!showPayloadCode) + reportFlagsCodeExampleInteraction('payloads') }} - disabled={ - !BOOTSTRAPPING_OPTIONS.map((bo) => bo.value).includes(selectedOption.value) || - !!featureFlag?.ensure_experience_continuity - } + data-attr="flags-code-example-payloads-option" + checked={showPayloadCode} + disabled={!PAYLOAD_LIBRARIES.includes(selectedOption.key)} />
- +
+ { + setShowBootstrapCode(!showBootstrapCode) + reportFlagsCodeExampleInteraction('bootstrap') + }} + disabled={ + !BOOTSTRAPPING_OPTIONS.map((bo) => bo.key).includes(selectedOption.key) || + !!featureFlag?.ensure_experience_continuity + } + /> + +
+
+ -
- { - setShowLocalEvalCode(!showLocalEvalCode) - reportFlagsCodeExampleInteraction('local evaluation') - }} - disabled={ - !LOCAL_EVALUATION_LIBRARIES.includes(selectedOption.value) || - !!featureFlag?.ensure_experience_continuity - } - /> - -
-
- -
+ > +
+ { + setShowLocalEvalCode(!showLocalEvalCode) + reportFlagsCodeExampleInteraction('local evaluation') + }} + disabled={ + !LOCAL_EVALUATION_LIBRARIES.includes(selectedOption.key) || + !!featureFlag?.ensure_experience_continuity + } + /> + +
+ + +
+ )}
{showLocalEvalCode && ( <> @@ -277,7 +283,7 @@ export function CodeInstructions({ )} - + {showFooter && }
diff --git a/frontend/src/scenes/feature-flags/FeatureFlags.tsx b/frontend/src/scenes/feature-flags/FeatureFlags.tsx index 9904106941ac5..80d139cbd7c9b 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlags.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlags.tsx @@ -279,7 +279,7 @@ export function OverViewTab({ { label: 'Multiple variants', value: 'multivariant' }, { label: 'Experiment', value: 'experiment' }, ]} - value="all" + value={filters.type ?? 'all'} /> )} @@ -304,7 +304,7 @@ export function OverViewTab({ { label: 'Enabled', value: 'true' }, { label: 'Disabled', value: 'false' }, ]} - value="all" + value={filters.active ?? 'all'} /> Created by @@ -323,7 +323,7 @@ export function OverViewTab({ } }} options={uniqueCreators} - value="any" + value={filters.created_by ?? 'any'} />
diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.ts index aeb4b9471f764..5f33ae64bd556 100644 --- a/frontend/src/scenes/feature-flags/featureFlagLogic.ts +++ b/frontend/src/scenes/feature-flags/featureFlagLogic.ts @@ -42,7 +42,7 @@ import { userLogic } from 'scenes/userLogic' import { newDashboardLogic } from 'scenes/dashboard/newDashboardLogic' import { dashboardsLogic } from 'scenes/dashboard/dashboards/dashboardsLogic' import { NEW_EARLY_ACCESS_FEATURE } from 'scenes/early-access-features/earlyAccessFeatureLogic' -import { NEW_SURVEY, NewSurvey } from 'scenes/surveys/surveyLogic' +import { NEW_SURVEY, NewSurvey } from 'scenes/surveys/constants' const getDefaultRollbackCondition = (): FeatureFlagRollbackConditions => ({ operator: 'gt', diff --git a/frontend/src/scenes/ingestion/IngestionWizard.tsx b/frontend/src/scenes/ingestion/IngestionWizard.tsx index 06b67601e7d34..9c5e22cb38794 100644 --- a/frontend/src/scenes/ingestion/IngestionWizard.tsx +++ b/frontend/src/scenes/ingestion/IngestionWizard.tsx @@ -23,10 +23,15 @@ import { InviteTeamPanel } from './panels/InviteTeamPanel' import { TeamInvitedPanel } from './panels/TeamInvitedPanel' import { NoDemoIngestionPanel } from './panels/NoDemoIngestionPanel' import { SuperpowersPanel } from 'scenes/ingestion/panels/SuperpowersPanel' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' +import { router } from 'kea-router' +import { urls } from 'scenes/urls' export function IngestionWizard(): JSX.Element { const { currentView, platform } = useValues(ingestionLogic) const { reportIngestionLandingSeen } = useActions(eventUsageLogic) + const { featureFlags } = useValues(featureFlagLogic) useEffect(() => { if (!platform) { @@ -34,6 +39,10 @@ export function IngestionWizard(): JSX.Element { } }, [platform]) + if (featureFlags[FEATURE_FLAGS.PRODUCT_SPECIFIC_ONBOARDING] === 'test') { + router.actions.replace(urls.products()) + } + return ( {currentView === INGESTION_VIEWS.BILLING && } diff --git a/frontend/src/scenes/insights/views/BoldNumber/BoldNumber.tsx b/frontend/src/scenes/insights/views/BoldNumber/BoldNumber.tsx index 841d5f94f8ac2..adec75ac6dca7 100644 --- a/frontend/src/scenes/insights/views/BoldNumber/BoldNumber.tsx +++ b/frontend/src/scenes/insights/views/BoldNumber/BoldNumber.tsx @@ -1,7 +1,7 @@ import { useValues } from 'kea' -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useLayoutEffect, useRef, useState } from 'react' import ReactDOM from 'react-dom' -import { Textfit } from 'react-textfit' +import Textfit from './Textfit' import clsx from 'clsx' import { insightLogic } from '../../insightLogic' @@ -81,7 +81,6 @@ function useBoldNumberTooltip({ export function BoldNumber({ showPersonsModal = true }: ChartParams): JSX.Element { const { insightProps } = useValues(insightLogic) const { insightData, trendsFilter } = useValues(insightVizDataLogic(insightProps)) - const [textFitTimer, setTextFitTimer] = useState(null) const [isTooltipShown, setIsTooltipShown] = useState(false) const valueRef = useBoldNumberTooltip({ showPersonsModal, isTooltipShown }) @@ -89,28 +88,9 @@ export function BoldNumber({ showPersonsModal = true }: ChartParams): JSX.Elemen const showComparison = !!trendsFilter?.compare && insightData?.result?.length > 1 const resultSeries = insightData?.result?.[0] as TrendResult | undefined - useEffect(() => { - // sometimes text fit can get stuck and leave text too small - // force a resize after a small delay - const timer = setTimeout(() => window.dispatchEvent(new CustomEvent('resize')), 300) - setTextFitTimer(timer) - return () => clearTimeout(timer) - }, []) - return resultSeries ? (
- { - // if fontsize has calculated then no need for a resize event - if (textFitTimer) { - clearTimeout(textFitTimer) - } - }} - style={{ lineHeight: 1 }} - > +
{ + const style = window.getComputedStyle(el, null) + // Hidden iframe in Firefox returns null, https://github.com/malte-wessel/react-textfit/pull/34 + if (!style) { + return el.clientWidth + } + + return ( + el.clientWidth - + parseInt(style.getPropertyValue('padding-left'), 10) - + parseInt(style.getPropertyValue('padding-right'), 10) + ) +} + +const assertElementFitsWidth = (el: HTMLDivElement, width: number): boolean => el.scrollWidth - 1 <= width + +const Textfit = ({ min, max, children }: { min: number; max: number; children: React.ReactNode }): JSX.Element => { + const parentRef = useRef(null) + const childRef = useRef(null) + + const [fontSize, setFontSize] = useState() + + let resizeTimer: NodeJS.Timeout + + const handleWindowResize = (): void => { + clearTimeout(resizeTimer) + resizeTimer = setTimeout(() => { + const el = parentRef.current + const wrapper = childRef.current + + if (el && wrapper) { + const originalWidth = innerWidth(el) + + let mid + let low = min + let high = max + + while (low <= high) { + mid = Math.floor((low + high) / 2) + setFontSize(mid) + + if (assertElementFitsWidth(wrapper, originalWidth)) { + low = mid + 1 + } else { + high = mid - 1 + } + } + mid = Math.min(low, high) + + // Ensure we hit the user-supplied limits + mid = Math.max(mid, min) + mid = Math.min(mid, max) + + setFontSize(mid) + } + }, 10) + } + + useEffect(() => { + window.addEventListener('resize', handleWindowResize) + return () => window.removeEventListener('resize', handleWindowResize) + }, []) + + useEffect(() => handleWindowResize(), [parentRef, childRef]) + + return ( + // eslint-disable-next-line react/forbid-dom-props +
+ {/* eslint-disable-next-line react/forbid-dom-props */} +
+ {children} +
+
+ ) +} + +export default Textfit diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodeSurvey.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodeSurvey.tsx index 700e1ca60a190..f58e709422962 100644 --- a/frontend/src/scenes/notebooks/Nodes/NotebookNodeSurvey.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodeSurvey.tsx @@ -8,7 +8,8 @@ import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' import { notebookNodeLogic } from './notebookNodeLogic' import { JSONContent, NotebookNodeViewProps } from '../Notebook/utils' import { buildFlagContent } from './NotebookNodeFlag' -import { defaultSurveyAppearance, surveyLogic } from 'scenes/surveys/surveyLogic' +import { surveyLogic } from 'scenes/surveys/surveyLogic' +import { defaultSurveyAppearance } from 'scenes/surveys/constants' import { StatusTag } from 'scenes/surveys/Surveys' import { SurveyResult } from 'scenes/surveys/SurveyView' import { SurveyAppearance } from 'scenes/surveys/SurveyAppearance' diff --git a/frontend/src/scenes/onboarding/Onboarding.tsx b/frontend/src/scenes/onboarding/Onboarding.tsx index 3cf1c4989e4c1..c3520d90bea01 100644 --- a/frontend/src/scenes/onboarding/Onboarding.tsx +++ b/frontend/src/scenes/onboarding/Onboarding.tsx @@ -96,7 +96,11 @@ const SessionReplayOnboarding = (): JSX.Element => { const FeatureFlagsOnboarding = (): JSX.Element => { return ( - + ) } diff --git a/frontend/src/scenes/onboarding/OnboardingOtherProductsStep.tsx b/frontend/src/scenes/onboarding/OnboardingOtherProductsStep.tsx index 56102c51a7646..49e7c1fdde7c3 100644 --- a/frontend/src/scenes/onboarding/OnboardingOtherProductsStep.tsx +++ b/frontend/src/scenes/onboarding/OnboardingOtherProductsStep.tsx @@ -2,7 +2,6 @@ import { LemonButton, LemonCard } from '@posthog/lemon-ui' import { OnboardingStep } from './OnboardingStep' import { onboardingLogic } from './onboardingLogic' import { useActions, useValues } from 'kea' -import { urls } from 'scenes/urls' export const OnboardingOtherProductsStep = (): JSX.Element => { const { product, suggestedProducts } = useValues(onboardingLogic) @@ -39,10 +38,7 @@ export const OnboardingOtherProductsStep = (): JSX.Element => {
- completeOnboarding(urls.onboarding(suggestedProduct.type))} - > + completeOnboarding(suggestedProduct.type)}> Get started
diff --git a/frontend/src/scenes/onboarding/OnboardingProductIntro.tsx b/frontend/src/scenes/onboarding/OnboardingProductIntro.tsx index 4cdb535e242f7..3119e70f76106 100644 --- a/frontend/src/scenes/onboarding/OnboardingProductIntro.tsx +++ b/frontend/src/scenes/onboarding/OnboardingProductIntro.tsx @@ -8,6 +8,7 @@ import { LemonCard } from 'lib/lemon-ui/LemonCard/LemonCard' import { ProductPricingModal } from 'scenes/billing/ProductPricingModal' import { IconArrowLeft, IconCheckCircleOutline, IconOpenInNew } from 'lib/lemon-ui/icons' import { urls } from 'scenes/urls' +import { PlanComparisonModal } from 'scenes/billing/PlanComparison' export const OnboardingProductIntro = ({ product, @@ -16,8 +17,10 @@ export const OnboardingProductIntro = ({ product: BillingProductV2Type onStart?: () => void }): JSX.Element => { - const { currentAndUpgradePlans, isPricingModalOpen } = useValues(billingProductLogic({ product })) - const { toggleIsPricingModalOpen } = useActions(billingProductLogic({ product })) + const { currentAndUpgradePlans, isPricingModalOpen, isPlanComparisonModalOpen } = useValues( + billingProductLogic({ product }) + ) + const { toggleIsPricingModalOpen, toggleIsPlanComparisonModalOpen } = useActions(billingProductLogic({ product })) const { setCurrentOnboardingStepNumber } = useActions(onboardingLogic) const { currentOnboardingStepNumber } = useValues(onboardingLogic) @@ -35,6 +38,7 @@ export const OnboardingProductIntro = ({ const upgradePlan = currentAndUpgradePlans?.upgradePlan const plan = upgradePlan ? upgradePlan : currentAndUpgradePlans?.currentPlan + const freePlan = currentAndUpgradePlans?.downgradePlan || currentAndUpgradePlans?.currentPlan return (
@@ -94,7 +98,7 @@ export const OnboardingProductIntro = ({ ))}
-
+

Pricing

{plan?.tiers?.[0].unit_amount_usd && parseInt(plan?.tiers?.[0].unit_amount_usd) === 0 && ( @@ -114,14 +118,34 @@ export const OnboardingProductIntro = ({ after {convertLargeNumberToWords(plan?.tiers?.[1].up_to, null)}/mo.

)} -
    +
      {pricingBenefits.map((benefit, i) => (
    • - + {benefit}
    • ))}
    + {!product.subscribed && freePlan.free_allocation && ( +

    + Or stick with our generous free plan and get{' '} + {convertLargeNumberToWords(freePlan.free_allocation, null)} {product.unit}s free every + month, forever.{' '} + { + toggleIsPlanComparisonModalOpen() + }} + > + View plan comparison. + + toggleIsPlanComparisonModalOpen()} + /> +

    + )}

    Resources

    diff --git a/frontend/src/scenes/onboarding/onboardingLogic.tsx b/frontend/src/scenes/onboarding/onboardingLogic.tsx index 3f38d75747981..2e980cf9f3574 100644 --- a/frontend/src/scenes/onboarding/onboardingLogic.tsx +++ b/frontend/src/scenes/onboarding/onboardingLogic.tsx @@ -2,9 +2,12 @@ import { kea } from 'kea' import { BillingProductV2Type, ProductKey } from '~/types' import { urls } from 'scenes/urls' -import type { onboardingLogicType } from './onboardingLogicType' import { billingLogic } from 'scenes/billing/billingLogic' import { teamLogic } from 'scenes/teamLogic' +import { combineUrl, router } from 'kea-router' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' + +import type { onboardingLogicType } from './onboardingLogicType' export interface OnboardingLogicProps { productKey: ProductKey | null @@ -30,18 +33,31 @@ const onboardingStepMap: OnboardingStepMap = { export type AllOnboardingSteps = JSX.Element[] +export const getProductUri = (productKey: ProductKey): string => { + switch (productKey) { + case 'product_analytics': + return combineUrl(urls.events(), { onboarding_completed: true }).url + case 'session_replay': + return urls.replay() + case 'feature_flags': + return urls.featureFlags() + default: + return urls.default() + } +} + export const onboardingLogic = kea({ props: {} as OnboardingLogicProps, path: ['scenes', 'onboarding', 'onboardingLogic'], connect: { values: [billingLogic, ['billing'], teamLogic, ['currentTeam']], - actions: [billingLogic, ['loadBillingSuccess'], teamLogic, ['updateCurrentTeam']], + actions: [billingLogic, ['loadBillingSuccess'], teamLogic, ['updateCurrentTeamSuccess']], }, actions: { setProduct: (product: BillingProductV2Type | null) => ({ product }), setProductKey: (productKey: string | null) => ({ productKey }), setCurrentOnboardingStepNumber: (currentOnboardingStepNumber: number) => ({ currentOnboardingStepNumber }), - completeOnboarding: (redirectUri?: string) => ({ redirectUri }), + completeOnboarding: (nextProductKey?: string) => ({ nextProductKey }), setAllOnboardingSteps: (allOnboardingSteps: AllOnboardingSteps) => ({ allOnboardingSteps }), setStepKey: (stepKey: string) => ({ stepKey }), setSubscribedDuringOnboarding: (subscribedDuringOnboarding: boolean) => ({ subscribedDuringOnboarding }), @@ -81,16 +97,7 @@ export const onboardingLogic = kea({ urls.default() as string, { setProductKey: (_, { productKey }) => { - switch (productKey) { - case 'product_analytics': - return urls.default() - case 'session_replay': - return urls.replay() - case 'feature_flags': - return urls.featureFlags() - default: - return urls.default() - } + return productKey ? getProductUri(productKey as ProductKey) : urls.default() }, }, ], @@ -144,17 +151,29 @@ export const onboardingLogic = kea({ actions.setProduct(values.billing?.products.find((p) => p.type === values.productKey) || null) } }, - completeOnboarding: ({ redirectUri }) => { + setSubscribedDuringOnboarding: ({ subscribedDuringOnboarding }) => { + if (subscribedDuringOnboarding) { + // we might not have the product key yet + // if not we'll just use the current url to determine the product key + const productKey = values.productKey || (window.location.pathname.split('/')[2] as ProductKey) + eventUsageLogic.actions.reportSubscribedDuringOnboarding(productKey) + } + }, + completeOnboarding: ({ nextProductKey }) => { if (values.productKey) { - // update the current team has_completed_onboarding_for field, only writing over the current product - actions.updateCurrentTeam({ + const product = values.productKey + eventUsageLogic.actions.reportOnboardingCompleted(product) + if (nextProductKey) { + actions.setProductKey(nextProductKey) + router.actions.push(urls.onboarding(nextProductKey)) + } + teamLogic.actions.updateCurrentTeam({ has_completed_onboarding_for: { ...values.currentTeam?.has_completed_onboarding_for, - [values.productKey]: true, + [product]: true, }, }) } - window.location.href = redirectUri || values.onCompleteOnbardingRedirectUrl }, setAllOnboardingSteps: ({ allOnboardingSteps }) => { // once we have the onboarding steps we need to make sure the step key is valid, @@ -221,6 +240,11 @@ export const onboardingLogic = kea({ return [`/onboarding/${values.productKey}`] } }, + updateCurrentTeamSuccess(val) { + if (values.productKey && val.payload?.has_completed_onboarding_for?.[values.productKey]) { + return [values.onCompleteOnbardingRedirectUrl] + } + }, }), urlToAction: ({ actions, values }) => ({ '/onboarding/:productKey': ({ productKey }, { success, upgraded, step }) => { diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/FeatureFlagsSDKInstructions.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/FeatureFlagsSDKInstructions.tsx index 6374992792b3e..729a335fa3004 100644 --- a/frontend/src/scenes/onboarding/sdks/feature-flags/FeatureFlagsSDKInstructions.tsx +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/FeatureFlagsSDKInstructions.tsx @@ -1,8 +1,31 @@ import { SDKInstructionsMap, SDKKey } from '~/types' -import { JSWebInstructions, NextJSInstructions, ReactInstructions } from '.' +import { + FeatureFlagsJSWebInstructions, + FeatureFlagsNextJSInstructions, + FeatureFlagsAPIInstructions, + FeatureFlagsAndroidInstructions, + FeatureFlagsGoInstructions, + FeatureFlagsIOSInstructions, + FeatureFlagsNodeInstructions, + FeatureFlagsPHPInstructions, + FeatureFlagsPythonInstructions, + FeatureFlagsRNInstructions, + FeatureFlagsRubyInstructions, + FeatureFlagsReactInstructions, +} from '.' export const FeatureFlagsSDKInstructions: SDKInstructionsMap = { - [SDKKey.JS_WEB]: JSWebInstructions, - [SDKKey.NEXT_JS]: NextJSInstructions, - [SDKKey.REACT]: ReactInstructions, + [SDKKey.JS_WEB]: FeatureFlagsJSWebInstructions, + [SDKKey.REACT]: FeatureFlagsReactInstructions, + [SDKKey.NEXT_JS]: FeatureFlagsNextJSInstructions, + [SDKKey.IOS]: FeatureFlagsIOSInstructions, + [SDKKey.REACT_NATIVE]: FeatureFlagsRNInstructions, + [SDKKey.ANDROID]: FeatureFlagsAndroidInstructions, + [SDKKey.NODE_JS]: FeatureFlagsNodeInstructions, + [SDKKey.PYTHON]: FeatureFlagsPythonInstructions, + [SDKKey.RUBY]: FeatureFlagsRubyInstructions, + [SDKKey.PHP]: FeatureFlagsPHPInstructions, + [SDKKey.GO]: FeatureFlagsGoInstructions, + [SDKKey.API]: FeatureFlagsAPIInstructions, + // add flutter, rust, gatsby, nuxt, vue, svelte, and others here } diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/android.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/android.tsx new file mode 100644 index 0000000000000..0c9a64d274a8d --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/android.tsx @@ -0,0 +1,12 @@ +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKKey } from '~/types' +import { SDKInstallAndroidInstructions } from '../sdk-install-instructions' + +export function FeatureFlagsAndroidInstructions(): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/api.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/api.tsx new file mode 100644 index 0000000000000..5402e66f53b48 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/api.tsx @@ -0,0 +1,10 @@ +import { SDKKey } from '~/types' +import { FlagImplementationSnippet } from './flagImplementationSnippet' + +export function FeatureFlagsAPIInstructions(): JSX.Element { + return ( + <> + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/flagImplementationSnippet.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/flagImplementationSnippet.tsx new file mode 100644 index 0000000000000..ded0cf3f69bc4 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/flagImplementationSnippet.tsx @@ -0,0 +1,17 @@ +import { OPTIONS } from 'scenes/feature-flags/FeatureFlagCodeOptions' +import { CodeInstructions } from 'scenes/feature-flags/FeatureFlagInstructions' +import { SDKKey } from '~/types' + +export const FlagImplementationSnippet = ({ sdkKey }: { sdkKey: SDKKey }): JSX.Element => { + return ( + <> +

    Basic implementation

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/go.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/go.tsx new file mode 100644 index 0000000000000..cdb750a2396f8 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/go.tsx @@ -0,0 +1,12 @@ +import { SDKKey } from '~/types' +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKInstallGoInstructions } from '../sdk-install-instructions' + +export function FeatureFlagsGoInstructions(): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/index.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/index.tsx index 27d9e5388d04d..11e1743082019 100644 --- a/frontend/src/scenes/onboarding/sdks/feature-flags/index.tsx +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/index.tsx @@ -1,3 +1,12 @@ +export * from './android' +export * from './go' +export * from './nodejs' +export * from './ios' +export * from './php' +export * from './python' +export * from './react-native' +export * from './ruby' +export * from './api' export * from './js-web' -export * from './next-js' export * from './react' +export * from './next-js' diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/ios.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/ios.tsx new file mode 100644 index 0000000000000..250c98fd4d3fd --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/ios.tsx @@ -0,0 +1,12 @@ +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKKey } from '~/types' +import { SDKInstallIOSInstructions } from '../sdk-install-instructions' + +export function FeatureFlagsIOSInstructions(): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/js-web.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/js-web.tsx index 8ef2865c3b834..78a2fa373faa6 100644 --- a/frontend/src/scenes/onboarding/sdks/feature-flags/js-web.tsx +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/js-web.tsx @@ -1,42 +1,14 @@ -import { JSSnippet } from 'lib/components/JSSnippet' import { LemonDivider } from 'lib/lemon-ui/LemonDivider' -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' -import { JSInstallSnippet, SessionReplayFinalSteps } from '../shared-snippets' +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKKey } from '~/types' +import { SDKInstallJSWebInstructions } from '../sdk-install-instructions' -function JSSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {[ - "import posthog from 'posthog-js'", - '', - `posthog.init('${currentTeam?.api_token}', { api_host: '${window.location.origin}' })`, - ].join('\n')} - - ) -} - -export function JSWebInstructions(): JSX.Element { +export function FeatureFlagsJSWebInstructions(): JSX.Element { return ( <> -

    Option 1. Code snippet

    -

    - Just add this snippet to your website within the <head> tag and we'll automatically - capture page views, sessions and all relevant interactions within your website. -

    - - -

    Option 2. Javascript Library

    -

    Install the package

    - -

    Initialize

    - + -

    Final steps

    - + ) } diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/next-js.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/next-js.tsx index cda978ee12166..7b1b37f16b2a1 100644 --- a/frontend/src/scenes/onboarding/sdks/feature-flags/next-js.tsx +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/next-js.tsx @@ -1,98 +1,20 @@ -import { Link } from 'lib/lemon-ui/Link' -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' -import { JSInstallSnippet, SessionReplayFinalSteps } from '../shared-snippets' +import { SDKKey } from '~/types' +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKInstallNextJSInstructions } from '../sdk-install-instructions/next-js' +import { NodeInstallSnippet, NodeSetupSnippet } from '../sdk-install-instructions' -function NextEnvVarsSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {[ - `NEXT_PUBLIC_POSTHOG_KEY=${currentTeam?.api_token}`, - `NEXT_PUBLIC_POSTHOG_HOST=${window.location.origin}`, - ].join('\n')} - - ) -} - -function NextPagesRouterCodeSnippet(): JSX.Element { - return ( - - {`// pages/_app.js -... -import posthog from 'posthog-js' // Import PostHog - -if (typeof window !== 'undefined') { // checks that we are client-side - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { - api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com', - loaded: (posthog) => { - if (process.env.NODE_ENV === 'development') posthog.debug() // debug mode in development - }, - }) -} - -export default function App({ Component, pageProps }) { - const router = useRouter() - ...`} - - ) -} - -function NextAppRouterCodeSnippet(): JSX.Element { - return ( - - {`// app/providers.js -'use client' -... -import posthog from 'posthog-js' - -if (typeof window !== 'undefined') { - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { - api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, - }) -} -...`} - - ) -} - -export function NextJSInstructions(): JSX.Element { +export function FeatureFlagsNextJSInstructions(): JSX.Element { return ( <> -

    Install posthog-js using your package manager

    - -

    Add environment variables

    -

    - Add your environment variables to your .env.local file and to your hosting provider (e.g. Vercel, - Netlify, AWS). You can find your project API key in your project settings. -

    -

    - These values need to start with NEXT_PUBLIC_ to be accessible on the - client-side. -

    - - -

    Initialize

    -

    With App router

    -

    - If your Next.js app to uses the app router, you can - integrate PostHog by creating a providers file in your app folder. This is because the posthog-js - library needs to be initialized on the client-side using the Next.js{' '} - - 'use client' directive - - . -

    - -

    With Pages router

    -

    - If your Next.js app uses the pages router, you can - integrate PostHog at the root of your app (pages/_app.js). -

    - - + +

    Client-side rendering

    + +

    Server-side rendering

    +

    Install

    + +

    Configure

    + + ) } diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/nodejs.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/nodejs.tsx new file mode 100644 index 0000000000000..576e6cd9091d2 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/nodejs.tsx @@ -0,0 +1,12 @@ +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKKey } from '~/types' +import { SDKInstallNodeInstructions } from '../sdk-install-instructions' + +export function FeatureFlagsNodeInstructions(): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/php.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/php.tsx new file mode 100644 index 0000000000000..68a97ef96d9c4 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/php.tsx @@ -0,0 +1,12 @@ +import { SDKKey } from '~/types' +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKInstallPHPInstructions } from '../sdk-install-instructions' + +export function FeatureFlagsPHPInstructions(): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/python.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/python.tsx new file mode 100644 index 0000000000000..55962b40f52ee --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/python.tsx @@ -0,0 +1,12 @@ +import { SDKKey } from '~/types' +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKInstallPythonInstructions } from '../sdk-install-instructions' + +export function FeatureFlagsPythonInstructions(): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/react-native.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/react-native.tsx new file mode 100644 index 0000000000000..f045c817abcb8 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/react-native.tsx @@ -0,0 +1,12 @@ +import { SDKInstallRNInstructions } from '../sdk-install-instructions' +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKKey } from '~/types' + +export function FeatureFlagsRNInstructions(): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/react.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/react.tsx index 86fdfc0f527c7..35ff77019b763 100644 --- a/frontend/src/scenes/onboarding/sdks/feature-flags/react.tsx +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/react.tsx @@ -1,64 +1,12 @@ -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' -import { JSInstallSnippet, SessionReplayFinalSteps } from '../shared-snippets' +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKKey } from '~/types' +import { SDKInstallReactInstructions } from '../sdk-install-instructions/react' -function ReactEnvVarsSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {[ - `REACT_APP_POSTHOG_PUBLIC_KEY=${currentTeam?.api_token}`, - `REACT_APP_PUBLIC_POSTHOG_HOST=${window.location.origin}`, - ].join('\n')} - - ) -} - -function ReactSetupSnippet(): JSX.Element { - return ( - - {`// src/index.js -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; - -import { PostHogProvider} from 'posthog-js/react' - -const options = { - api_host: process.env.REACT_APP_PUBLIC_POSTHOG_HOST, -} - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - - - -);`} - - ) -} - -export function ReactInstructions(): JSX.Element { +export function FeatureFlagsReactInstructions(): JSX.Element { return ( <> -

    Install the package

    - -

    Add environment variables

    - -

    Initialize

    -

    - Integrate PostHog at the root of your app (src/index.js for the default{' '} - create-react-app). -

    - - + + ) } diff --git a/frontend/src/scenes/onboarding/sdks/feature-flags/ruby.tsx b/frontend/src/scenes/onboarding/sdks/feature-flags/ruby.tsx new file mode 100644 index 0000000000000..388d934ede926 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/feature-flags/ruby.tsx @@ -0,0 +1,12 @@ +import { FlagImplementationSnippet } from './flagImplementationSnippet' +import { SDKKey } from '~/types' +import { SDKInstallRubyInstructions } from '../sdk-install-instructions' + +export function FeatureFlagsRubyInstructions(): JSX.Element { + return ( + <> + + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/android.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/android.tsx index 9b69ee13ab740..71435dd4fdee1 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/android.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/android.tsx @@ -1,43 +1,5 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function AndroidInstallSnippet(): JSX.Element { - return ( - - {`dependencies { - implementation 'com.posthog.android:posthog:1.+' -}`} - - ) -} - -function AndroidSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {`public class SampleApp extends Application { - private static final String POSTHOG_API_KEY = "${currentTeam?.api_token}"; - private static final String POSTHOG_HOST = "${window.location.origin}"; - - @Override - public void onCreate() { - // Create a PostHog client with the given context, API key and host - PostHog posthog = new PostHog.Builder(this, POSTHOG_API_KEY, POSTHOG_HOST) - .captureApplicationLifecycleEvents() // Record certain application events automatically! - .recordScreenViews() // Record screen views automatically! - .build(); - - // Set the initialized instance as a globally accessible instance - PostHog.setSingletonInstance(posthog); - - // Now any time you call PostHog.with, the custom instance will be returned - PostHog posthog = PostHog.with(this); - }`} - - ) -} +import { SDKInstallAndroidInstructions } from '../sdk-install-instructions' function AndroidCaptureSnippet(): JSX.Element { return PostHog.with(this).capture("test-event"); @@ -46,10 +8,7 @@ function AndroidCaptureSnippet(): JSX.Element { export function ProductAnalyticsAndroidInstructions(): JSX.Element { return ( <> -

    Install

    - -

    Configure

    - +

    Send an Event

    diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/elixir.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/elixir.tsx index 9878919b282cc..04c9f0d60c71b 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/elixir.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/elixir.tsx @@ -1,33 +1,9 @@ -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function ElixirInstallSnippet(): JSX.Element { - return ( - - {'def deps do\n [\n {:posthog, "~> 0.1"}\n ]\nend'} - - ) -} - -function ElixirSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - const url = window.location.origin - - return ( - - {'config :posthog,\n api_url: "' + url + '",\n api_key: "' + currentTeam?.api_token + '"'} - - ) -} +import { SDKInstallElixirInstructions } from '../sdk-install-instructions' export function ProductAnalyticsElixirInstructions(): JSX.Element { return ( <> -

    Install

    - -

    Configure

    - + ) } diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/flutter.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/flutter.tsx index 06c31e0b2ca83..01c793bfc8d74 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/flutter.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/flutter.tsx @@ -1,10 +1,5 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function FlutterInstallSnippet(): JSX.Element { - return {'posthog_flutter: # insert version number'} -} +import { SDKInstallFlutterInstructions } from '../sdk-install-instructions' function FlutterCaptureSnippet(): JSX.Element { return ( @@ -16,47 +11,10 @@ function FlutterCaptureSnippet(): JSX.Element { ) } -function FlutterAndroidSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - const url = window.location.origin - - return ( - - {'\n\t\n\t\t[...]\n\t\n\t\n\t\n\t\n\t\n'} - - ) -} - -function FlutterIOSSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - const url = window.location.origin - - return ( - - {'\n\t[...]\n\tcom.posthog.posthog.API_KEY\n\t' + - currentTeam?.api_token + - '\n\tcom.posthog.posthog.POSTHOG_HOST\n\t' + - url + - '\n\tcom.posthog.posthog.TRACK_APPLICATION_LIFECYCLE_EVENTS\n\t\n\t[...]\n'} - - ) -} - export function ProductAnalyticsFlutterInstructions(): JSX.Element { return ( <> -

    Install

    - -

    Android Setup

    -

    {'Add these values in AndroidManifest.xml'}

    - -

    iOS Setup

    -

    {'Add these values in Info.plist'}

    - +

    Send an Event

    diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/go.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/go.tsx index 726f5f1d80eba..7d7d14f0cd818 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/go.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/go.tsx @@ -1,27 +1,5 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function GoInstallSnippet(): JSX.Element { - return {'go get "github.com/posthog/posthog-go"'} -} - -function GoSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {`package main -import ( - "github.com/posthog/posthog-go" -) -func main() { - client, _ := posthog.NewWithConfig("${currentTeam?.api_token}", posthog.Config{Endpoint: "${window.location.origin}"}) - defer client.Close() -}`} - - ) -} +import { SDKInstallGoInstructions } from '../sdk-install-instructions' function GoCaptureSnippet(): JSX.Element { return ( @@ -34,10 +12,7 @@ function GoCaptureSnippet(): JSX.Element { export function ProductAnalyticsGoInstructions(): JSX.Element { return ( <> -

    Install

    - -

    Configure

    - +

    Send an Event

    diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/ios.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/ios.tsx index e337f685bad31..79ae931729710 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/ios.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/ios.tsx @@ -1,34 +1,5 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function IOSInstallSnippet(): JSX.Element { - return ( - - {'pod "PostHog", "~> 1.0" # Cocoapods \n# OR \ngithub "posthog/posthog-ios" # Carthage'} - - ) -} - -function IOS_OBJ_C_SetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {`#import \n#import \n\nPHGPostHogConfiguration *configuration = [PHGPostHogConfiguration configurationWithApiKey:@"${currentTeam?.api_token}" host:@"${window.location.origin}"];\n\nconfiguration.captureApplicationLifecycleEvents = YES; // Record certain application events automatically!\nconfiguration.recordScreenViews = YES; // Record screen views automatically!\n\n[PHGPostHog setupWithConfiguration:configuration];`} - - ) -} - -function IOS_SWIFT_SetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {`import PostHog\n\nlet configuration = PHGPostHogConfiguration(apiKey: "${currentTeam?.api_token}", host: "${window.location.origin}")\n\nconfiguration.captureApplicationLifecycleEvents = true; // Record certain application events automatically!\nconfiguration.recordScreenViews = true; // Record screen views automatically!\n\nPHGPostHog.setup(with: configuration)\nlet posthog = PHGPostHog.shared()`} - - ) -} +import { SDKInstallIOSInstructions } from '../sdk-install-instructions' function IOS_OBJ_C_CaptureSnippet(): JSX.Element { return ( @@ -45,12 +16,7 @@ function IOS_SWIFT_CaptureSnippet(): JSX.Element { export function ProductAnalyticsIOSInstructions(): JSX.Element { return ( <> -

    Install

    - -

    Configure Swift

    - -

    Or configure Objective-C

    - +

    Send an event with swift

    Send an event with Objective-C

    diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/js-web.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/js-web.tsx index ee13b1cee920e..fc2eb0f53c67d 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/js-web.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/js-web.tsx @@ -1,31 +1,6 @@ -import { Link } from 'lib/lemon-ui/Link' -import { JSSnippet } from 'lib/components/JSSnippet' -import { LemonDivider } from 'lib/lemon-ui/LemonDivider' import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function JSInstallSnippet(): JSX.Element { - return ( - - {['npm install posthog-js', '# OR', 'yarn add posthog-js', '# OR', 'pnpm add posthog-js'].join('\n')} - - ) -} - -function JSSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {[ - "import posthog from 'posthog-js'", - '', - `posthog.init('${currentTeam?.api_token}', { api_host: '${window.location.origin}' })`, - ].join('\n')} - - ) -} +import { SDKInstallJSWebInstructions } from '../sdk-install-instructions' +import { LemonDivider } from '@posthog/lemon-ui' function JSEventSnippet(): JSX.Element { return ( @@ -36,67 +11,8 @@ function JSEventSnippet(): JSX.Element { export function JSWebInstructions(): JSX.Element { return ( <> -
    -

    Option 1. Code snippet

    -
    - Recommended -
    -
    -

    - Just add this snippet to your website and we'll automatically capture page views, sessions and all - relevant interactions within your website.{' '} - - Learn more - - . -

    -

    Install the snippet

    -

    - Insert this snippet in your website within the <head> tag. -

    -

    Send events

    -

    Visit your site and click around to generate some initial events.

    + -
    -

    Option 2. Javascript Library

    -
    -

    - Use this option if you want more granular control of how PostHog runs in your website and the events you - capture. Recommended for teams with more stable products and more defined analytics requirements.{' '} - - Learn more - - . -

    -

    Install the package

    - -

    - Configure & initialize (see more{' '} - - configuration options - - ) -

    -

    Send your first event

    diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/nodejs.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/nodejs.tsx index 24872d5b9c1a3..6a6050ca44f49 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/nodejs.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/nodejs.tsx @@ -1,33 +1,5 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function NodeInstallSnippet(): JSX.Element { - return ( - - {`npm install posthog-node -# OR -yarn add posthog-node -# OR -pnpm add posthog-node`} - - ) -} - -function NodeSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {`import { PostHog } from 'posthog-node' - -const client = new PostHog( - '${currentTeam?.api_token}', - { host: '${window.location.origin}' } -)`} - - ) -} +import { SDKInstallNodeInstructions } from '../sdk-install-instructions' function NodeCaptureSnippet(): JSX.Element { return ( @@ -47,10 +19,7 @@ client.flush()`} export function ProductAnalyticsNodeInstructions(): JSX.Element { return ( <> -

    Install

    - -

    Configure

    - +

    Send an Event

    diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/php.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/php.tsx index 2f218e31d1510..2704a4c285e2b 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/php.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/php.tsx @@ -1,34 +1,5 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function PHPConfigSnippet(): JSX.Element { - return ( - - {`{ - "require": { - "posthog/posthog-php": "1.0.*" - } -}`} - - ) -} - -function PHPInstallSnippet(): JSX.Element { - return {'php composer.phar install'} -} - -function PHPSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {`PostHog::init('${currentTeam?.api_token}', - array('host' => '${window.location.origin}') -);`} - - ) -} +import { SDKInstallPHPInstructions } from '../sdk-install-instructions' function PHPCaptureSnippet(): JSX.Element { return ( @@ -41,12 +12,7 @@ function PHPCaptureSnippet(): JSX.Element { export function ProductAnalyticsPHPInstructions(): JSX.Element { return ( <> -

    Dependency Setup

    - -

    Install

    - -

    Configure

    - +

    Send an Event

    diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/python.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/python.tsx index 4892dee3ac6e8..486326bf34669 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/python.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/python.tsx @@ -1,24 +1,5 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function PythonInstallSnippet(): JSX.Element { - return {'pip install posthog'} -} - -function PythonSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {`from posthog import Posthog - -posthog = Posthog(project_api_key='${currentTeam?.api_token}', host='${window.location.origin}') - - `} - - ) -} +import { SDKInstallPythonInstructions } from '../sdk-install-instructions' function PythonCaptureSnippet(): JSX.Element { return {"posthog.capture('test-id', 'test-event')"} @@ -27,10 +8,7 @@ function PythonCaptureSnippet(): JSX.Element { export function ProductAnalyticsPythonInstructions(): JSX.Element { return ( <> -

    Install

    - -

    Configure

    - +

    Send an Event

    diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/react-native.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/react-native.tsx index 3c80a3d512caf..0492b8c210960 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/react-native.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/react-native.tsx @@ -1,52 +1,10 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' -import { Link } from '@posthog/lemon-ui' +import { SDKInstallRNInstructions } from '../sdk-install-instructions' export function ProductAnalyticsRNInstructions(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - const url = window.location.origin - return ( <> -

    Install

    - - {`# Expo apps -expo install posthog-react-native expo-file-system expo-application expo-device expo-localization - -# Standard React Native apps -yarn add posthog-react-native @react-native-async-storage/async-storage react-native-device-info -# or -npm i -s posthog-react-native @react-native-async-storage/async-storage react-native-device-info - -# for iOS -cd ios -pod install`} - -

    Configure

    -

    - PostHog is most easily used via the PostHogProvider component but if you need to - instantiate it directly,{' '} - - check out the docs - {' '} - which explain how to do this correctly. -

    - - {`// App.(js|ts) -import { PostHogProvider } from 'posthog-react-native' -... - -export function MyApp() { - return ( - - - - ) -}`} - +

    Send an Event

    {`// With hooks import { usePostHog } from 'posthog-react-native' diff --git a/frontend/src/scenes/onboarding/sdks/product-analytics/ruby.tsx b/frontend/src/scenes/onboarding/sdks/product-analytics/ruby.tsx index 0d9ee8dbd6da2..905897614ebcd 100644 --- a/frontend/src/scenes/onboarding/sdks/product-analytics/ruby.tsx +++ b/frontend/src/scenes/onboarding/sdks/product-analytics/ruby.tsx @@ -1,24 +1,5 @@ import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' - -function RubyInstallSnippet(): JSX.Element { - return {'gem "posthog-ruby"'} -} - -function RubySetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {`posthog = PostHog::Client.new({ - api_key: "${currentTeam?.api_token}", - host: "${window.location.origin}", - on_error: Proc.new { |status, msg| print msg } -})`} - - ) -} +import { SDKInstallRubyInstructions } from '../sdk-install-instructions' function RubyCaptureSnippet(): JSX.Element { return ( @@ -31,10 +12,7 @@ function RubyCaptureSnippet(): JSX.Element { export function ProductAnalyticsRubyInstructions(): JSX.Element { return ( <> -

    Install

    - -

    Configure

    - +

    Send an Event

    diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/android.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/android.tsx new file mode 100644 index 0000000000000..01a4b7d11d934 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/android.tsx @@ -0,0 +1,51 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +function AndroidInstallSnippet(): JSX.Element { + return ( + + {`dependencies { + implementation 'com.posthog.android:posthog:1.+' +}`} + + ) +} + +function AndroidSetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {`public class SampleApp extends Application { + private static final String POSTHOG_API_KEY = "${currentTeam?.api_token}"; + private static final String POSTHOG_HOST = "${window.location.origin}"; + + @Override + public void onCreate() { + // Create a PostHog client with the given context, API key and host + PostHog posthog = new PostHog.Builder(this, POSTHOG_API_KEY, POSTHOG_HOST) + .captureApplicationLifecycleEvents() // Record certain application events automatically! + .recordScreenViews() // Record screen views automatically! + .build(); + + // Set the initialized instance as a globally accessible instance + PostHog.setSingletonInstance(posthog); + + // Now any time you call PostHog.with, the custom instance will be returned + PostHog posthog = PostHog.with(this); + }`} + + ) +} + +export function SDKInstallAndroidInstructions(): JSX.Element { + return ( + <> +

    Install

    + +

    Configure

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/elixir.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/elixir.tsx new file mode 100644 index 0000000000000..2378c5ef93d0b --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/elixir.tsx @@ -0,0 +1,33 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +function ElixirInstallSnippet(): JSX.Element { + return ( + + {'def deps do\n [\n {:posthog, "~> 0.1"}\n ]\nend'} + + ) +} + +function ElixirSetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + const url = window.location.origin + + return ( + + {'config :posthog,\n api_url: "' + url + '",\n api_key: "' + currentTeam?.api_token + '"'} + + ) +} + +export function SDKInstallElixirInstructions(): JSX.Element { + return ( + <> +

    Install

    + +

    Configure

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/flutter.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/flutter.tsx new file mode 100644 index 0000000000000..e37b2b1038388 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/flutter.tsx @@ -0,0 +1,52 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +function FlutterInstallSnippet(): JSX.Element { + return {'posthog_flutter: # insert version number'} +} + +function FlutterAndroidSetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + const url = window.location.origin + + return ( + + {'\n\t\n\t\t[...]\n\t\n\t\n\t\n\t\n\t\n'} + + ) +} + +function FlutterIOSSetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + const url = window.location.origin + + return ( + + {'\n\t[...]\n\tcom.posthog.posthog.API_KEY\n\t' + + currentTeam?.api_token + + '\n\tcom.posthog.posthog.POSTHOG_HOST\n\t' + + url + + '\n\tcom.posthog.posthog.TRACK_APPLICATION_LIFECYCLE_EVENTS\n\t\n\t[...]\n'} + + ) +} + +export function SDKInstallFlutterInstructions(): JSX.Element { + return ( + <> +

    Install

    + +

    Android Setup

    +

    {'Add these values in AndroidManifest.xml'}

    + +

    iOS Setup

    +

    {'Add these values in Info.plist'}

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/go.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/go.tsx new file mode 100644 index 0000000000000..87bf25b337c40 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/go.tsx @@ -0,0 +1,35 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +function GoInstallSnippet(): JSX.Element { + return {'go get "github.com/posthog/posthog-go"'} +} + +function GoSetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {`package main +import ( + "github.com/posthog/posthog-go" +) +func main() { + client, _ := posthog.NewWithConfig("${currentTeam?.api_token}", posthog.Config{Endpoint: "${window.location.origin}"}) + defer client.Close() +}`} + + ) +} + +export function SDKInstallGoInstructions(): JSX.Element { + return ( + <> +

    Install

    + +

    Configure

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/index.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/index.tsx new file mode 100644 index 0000000000000..cc0382dd22581 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/index.tsx @@ -0,0 +1,11 @@ +export * from './android' +export * from './go' +export * from './nodejs' +export * from './ios' +export * from './php' +export * from './python' +export * from './react-native' +export * from './ruby' +export * from './elixir' +export * from './flutter' +export * from './js-web' diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ios.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ios.tsx new file mode 100644 index 0000000000000..314f4c0305343 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ios.tsx @@ -0,0 +1,44 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +function IOSInstallSnippet(): JSX.Element { + return ( + + {'pod "PostHog", "~> 1.1" # Cocoapods \n# OR \ngithub "posthog/posthog-ios" # Carthage'} + + ) +} + +function IOS_OBJ_C_SetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {`#import \n#import \n\nPHGPostHogConfiguration *configuration = [PHGPostHogConfiguration configurationWithApiKey:@"${currentTeam?.api_token}" host:@"${window.location.origin}"];\n\nconfiguration.captureApplicationLifecycleEvents = YES; // Record certain application events automatically!\nconfiguration.recordScreenViews = YES; // Record screen views automatically!\n\n[PHGPostHog setupWithConfiguration:configuration];`} + + ) +} + +function IOS_SWIFT_SetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {`import PostHog\n\nlet configuration = PHGPostHogConfiguration(apiKey: "${currentTeam?.api_token}", host: "${window.location.origin}")\n\nconfiguration.captureApplicationLifecycleEvents = true; // Record certain application events automatically!\nconfiguration.recordScreenViews = true; // Record screen views automatically!\n\nPHGPostHog.setup(with: configuration)\nlet posthog = PHGPostHog.shared()`} + + ) +} + +export function SDKInstallIOSInstructions(): JSX.Element { + return ( + <> +

    Install

    + +

    Configure Swift

    + +

    Or configure Objective-C

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/js-web.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/js-web.tsx new file mode 100644 index 0000000000000..88b5e8acc8adc --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/js-web.tsx @@ -0,0 +1,46 @@ +import { JSSnippet } from 'lib/components/JSSnippet' +import { LemonDivider } from 'lib/lemon-ui/LemonDivider' +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +export function JSInstallSnippet(): JSX.Element { + return ( + + {['npm install posthog-js', '# OR', 'yarn add posthog-js', '# OR', 'pnpm add posthog-js'].join('\n')} + + ) +} + +export function JSSetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {[ + "import posthog from 'posthog-js'", + '', + `posthog.init('${currentTeam?.api_token}', { api_host: '${window.location.origin}' })`, + ].join('\n')} + + ) +} + +export function SDKInstallJSWebInstructions(): JSX.Element { + return ( + <> +

    Option 1. Code snippet

    +

    + Just add this snippet to your website within the <head> tag and you'll be ready to + start using PostHog.{' '} +

    + + +

    Option 2. Javascript Library

    +

    Install the package

    + +

    Initialize

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/next-js.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/next-js.tsx new file mode 100644 index 0000000000000..a66c40f7c0b7c --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/next-js.tsx @@ -0,0 +1,97 @@ +import { Link } from 'lib/lemon-ui/Link' +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' +import { JSInstallSnippet } from './js-web' + +function NextEnvVarsSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {[ + `NEXT_PUBLIC_POSTHOG_KEY=${currentTeam?.api_token}`, + `NEXT_PUBLIC_POSTHOG_HOST=${window.location.origin}`, + ].join('\n')} + + ) +} + +function NextPagesRouterCodeSnippet(): JSX.Element { + return ( + + {`// pages/_app.js +... +import posthog from 'posthog-js' // Import PostHog + +if (typeof window !== 'undefined') { // checks that we are client-side + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com', + loaded: (posthog) => { + if (process.env.NODE_ENV === 'development') posthog.debug() // debug mode in development + }, + }) +} + +export default function App({ Component, pageProps }) { + const router = useRouter() + ...`} + + ) +} + +function NextAppRouterCodeSnippet(): JSX.Element { + return ( + + {`// app/providers.js +'use client' +... +import posthog from 'posthog-js' + +if (typeof window !== 'undefined') { + posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { + api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, + }) +} +...`} + + ) +} + +export function SDKInstallNextJSInstructions(): JSX.Element { + return ( + <> +

    Install posthog-js using your package manager

    + +

    Add environment variables

    +

    + Add your environment variables to your .env.local file and to your hosting provider (e.g. Vercel, + Netlify, AWS). You can find your project API key in your project settings. +

    +

    + These values need to start with NEXT_PUBLIC_ to be accessible on the + client-side. +

    + + +

    Initialize

    +

    With App router

    +

    + If your Next.js app to uses the app router, you can + integrate PostHog by creating a providers file in your app folder. This is because the posthog-js + library needs to be initialized on the client-side using the Next.js{' '} + + 'use client' directive + + . +

    + +

    With Pages router

    +

    + If your Next.js app uses the pages router, you can + integrate PostHog at the root of your app (pages/_app.js). +

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/nodejs.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/nodejs.tsx new file mode 100644 index 0000000000000..bab12bd12c45e --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/nodejs.tsx @@ -0,0 +1,41 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +export function NodeInstallSnippet(): JSX.Element { + return ( + + {`npm install posthog-node +# OR +yarn add posthog-node +# OR +pnpm add posthog-node`} + + ) +} + +export function NodeSetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {`import { PostHog } from 'posthog-node' + +const client = new PostHog( + '${currentTeam?.api_token}', + { host: '${window.location.origin}' } +)`} + + ) +} + +export function SDKInstallNodeInstructions(): JSX.Element { + return ( + <> +

    Install

    + +

    Configure

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/php.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/php.tsx new file mode 100644 index 0000000000000..136dee636404a --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/php.tsx @@ -0,0 +1,44 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +function PHPConfigSnippet(): JSX.Element { + return ( + + {`{ + "require": { + "posthog/posthog-php": "1.0.*" + } +}`} + + ) +} + +function PHPInstallSnippet(): JSX.Element { + return {'php composer.phar install'} +} + +function PHPSetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {`PostHog::init('${currentTeam?.api_token}', + array('host' => '${window.location.origin}') +);`} + + ) +} + +export function SDKInstallPHPInstructions(): JSX.Element { + return ( + <> +

    Dependency Setup

    + +

    Install

    + +

    Configure

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/python.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/python.tsx new file mode 100644 index 0000000000000..54ece50952ec3 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/python.tsx @@ -0,0 +1,32 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +function PythonInstallSnippet(): JSX.Element { + return {'pip install posthog'} +} + +function PythonSetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {`from posthog import Posthog + +posthog = Posthog(project_api_key='${currentTeam?.api_token}', host='${window.location.origin}') + + `} + + ) +} + +export function SDKInstallPythonInstructions(): JSX.Element { + return ( + <> +

    Install

    + +

    Configure

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/react-native.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/react-native.tsx new file mode 100644 index 0000000000000..298cb434f6751 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/react-native.tsx @@ -0,0 +1,52 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' +import { Link } from '@posthog/lemon-ui' + +export function SDKInstallRNInstructions(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + const url = window.location.origin + + return ( + <> +

    Install

    + + {`# Expo apps +expo install posthog-react-native expo-file-system expo-application expo-device expo-localization + +# Standard React Native apps +yarn add posthog-react-native @react-native-async-storage/async-storage react-native-device-info +# or +npm i -s posthog-react-native @react-native-async-storage/async-storage react-native-device-info + +# for iOS +cd ios +pod install`} + +

    Configure

    +

    + PostHog is most easily used via the PostHogProvider component but if you need to + instantiate it directly,{' '} + + check out the docs + {' '} + which explain how to do this correctly. +

    + + {`// App.(js|ts) +import { PostHogProvider } from 'posthog-react-native' +... + +export function MyApp() { + return ( + + + + ) +}`} + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/react.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/react.tsx new file mode 100644 index 0000000000000..0f19b890c3e90 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/react.tsx @@ -0,0 +1,63 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' +import { JSInstallSnippet } from './js-web' + +function ReactEnvVarsSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {[ + `REACT_APP_POSTHOG_PUBLIC_KEY=${currentTeam?.api_token}`, + `REACT_APP_PUBLIC_POSTHOG_HOST=${window.location.origin}`, + ].join('\n')} + + ) +} + +function ReactSetupSnippet(): JSX.Element { + return ( + + {`// src/index.js +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +import { PostHogProvider} from 'posthog-js/react' + +const options = { + api_host: process.env.REACT_APP_PUBLIC_POSTHOG_HOST, +} + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + + + +);`} + + ) +} + +export function SDKInstallReactInstructions(): JSX.Element { + return ( + <> +

    Install the package

    + +

    Add environment variables

    + +

    Initialize

    +

    + Integrate PostHog at the root of your app (src/index.js for the default{' '} + create-react-app). +

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ruby.tsx b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ruby.tsx new file mode 100644 index 0000000000000..bd5521f351983 --- /dev/null +++ b/frontend/src/scenes/onboarding/sdks/sdk-install-instructions/ruby.tsx @@ -0,0 +1,32 @@ +import { CodeSnippet, Language } from 'lib/components/CodeSnippet' +import { useValues } from 'kea' +import { teamLogic } from 'scenes/teamLogic' + +function RubyInstallSnippet(): JSX.Element { + return {'gem "posthog-ruby"'} +} + +function RubySetupSnippet(): JSX.Element { + const { currentTeam } = useValues(teamLogic) + + return ( + + {`posthog = PostHog::Client.new({ + api_key: "${currentTeam?.api_token}", + host: "${window.location.origin}", + on_error: Proc.new { |status, msg| print msg } +})`} + + ) +} + +export function SDKInstallRubyInstructions(): JSX.Element { + return ( + <> +

    Install

    + +

    Configure

    + + + ) +} diff --git a/frontend/src/scenes/onboarding/sdks/sdksLogic.tsx b/frontend/src/scenes/onboarding/sdks/sdksLogic.tsx index b1f525c5fe9d5..80cc121d8bd7d 100644 --- a/frontend/src/scenes/onboarding/sdks/sdksLogic.tsx +++ b/frontend/src/scenes/onboarding/sdks/sdksLogic.tsx @@ -76,11 +76,11 @@ export const sdksLogic = kea({ }, selectors: { showSourceOptionsSelect: [ - (selectors) => [selectors.sourceOptions, selectors.sdks], - (sourceOptions: LemonSelectOptions, sdks: SDK[]): boolean => { + (selectors) => [selectors.sourceOptions, selectors.availableSDKInstructionsMap], + (sourceOptions: LemonSelectOptions, availableSDKInstructionsMap: SDKInstructionsMap): boolean => { // more than two source options since one will almost always be "recommended" // more than 5 sdks since with fewer you don't really need to filter - return sdks.length > 5 && sourceOptions.length > 2 + return Object.keys(availableSDKInstructionsMap).length > 5 && sourceOptions.length > 2 }, ], }, diff --git a/frontend/src/scenes/onboarding/sdks/session-replay/js-web.tsx b/frontend/src/scenes/onboarding/sdks/session-replay/js-web.tsx index 8ef2865c3b834..fc799bbd8a65a 100644 --- a/frontend/src/scenes/onboarding/sdks/session-replay/js-web.tsx +++ b/frontend/src/scenes/onboarding/sdks/session-replay/js-web.tsx @@ -1,39 +1,11 @@ -import { JSSnippet } from 'lib/components/JSSnippet' import { LemonDivider } from 'lib/lemon-ui/LemonDivider' -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' -import { JSInstallSnippet, SessionReplayFinalSteps } from '../shared-snippets' - -function JSSetupSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {[ - "import posthog from 'posthog-js'", - '', - `posthog.init('${currentTeam?.api_token}', { api_host: '${window.location.origin}' })`, - ].join('\n')} - - ) -} +import { SessionReplayFinalSteps } from '../shared-snippets' +import { SDKInstallJSWebInstructions } from '../sdk-install-instructions' export function JSWebInstructions(): JSX.Element { return ( <> -

    Option 1. Code snippet

    -

    - Just add this snippet to your website within the <head> tag and we'll automatically - capture page views, sessions and all relevant interactions within your website. -

    - - -

    Option 2. Javascript Library

    -

    Install the package

    - -

    Initialize

    - +

    Final steps

    diff --git a/frontend/src/scenes/onboarding/sdks/session-replay/next-js.tsx b/frontend/src/scenes/onboarding/sdks/session-replay/next-js.tsx index cda978ee12166..dadd37388cb0a 100644 --- a/frontend/src/scenes/onboarding/sdks/session-replay/next-js.tsx +++ b/frontend/src/scenes/onboarding/sdks/session-replay/next-js.tsx @@ -1,97 +1,10 @@ -import { Link } from 'lib/lemon-ui/Link' -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' -import { JSInstallSnippet, SessionReplayFinalSteps } from '../shared-snippets' - -function NextEnvVarsSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {[ - `NEXT_PUBLIC_POSTHOG_KEY=${currentTeam?.api_token}`, - `NEXT_PUBLIC_POSTHOG_HOST=${window.location.origin}`, - ].join('\n')} - - ) -} - -function NextPagesRouterCodeSnippet(): JSX.Element { - return ( - - {`// pages/_app.js -... -import posthog from 'posthog-js' // Import PostHog - -if (typeof window !== 'undefined') { // checks that we are client-side - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { - api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://app.posthog.com', - loaded: (posthog) => { - if (process.env.NODE_ENV === 'development') posthog.debug() // debug mode in development - }, - }) -} - -export default function App({ Component, pageProps }) { - const router = useRouter() - ...`} - - ) -} - -function NextAppRouterCodeSnippet(): JSX.Element { - return ( - - {`// app/providers.js -'use client' -... -import posthog from 'posthog-js' - -if (typeof window !== 'undefined') { - posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { - api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST, - }) -} -...`} - - ) -} +import { SessionReplayFinalSteps } from '../shared-snippets' +import { SDKInstallNextJSInstructions } from '../sdk-install-instructions/next-js' export function NextJSInstructions(): JSX.Element { return ( <> -

    Install posthog-js using your package manager

    - -

    Add environment variables

    -

    - Add your environment variables to your .env.local file and to your hosting provider (e.g. Vercel, - Netlify, AWS). You can find your project API key in your project settings. -

    -

    - These values need to start with NEXT_PUBLIC_ to be accessible on the - client-side. -

    - - -

    Initialize

    -

    With App router

    -

    - If your Next.js app to uses the app router, you can - integrate PostHog by creating a providers file in your app folder. This is because the posthog-js - library needs to be initialized on the client-side using the Next.js{' '} - - 'use client' directive - - . -

    - -

    With Pages router

    -

    - If your Next.js app uses the pages router, you can - integrate PostHog at the root of your app (pages/_app.js). -

    - + ) diff --git a/frontend/src/scenes/onboarding/sdks/session-replay/react.tsx b/frontend/src/scenes/onboarding/sdks/session-replay/react.tsx index 86fdfc0f527c7..361884112a15b 100644 --- a/frontend/src/scenes/onboarding/sdks/session-replay/react.tsx +++ b/frontend/src/scenes/onboarding/sdks/session-replay/react.tsx @@ -1,63 +1,10 @@ -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { useValues } from 'kea' -import { teamLogic } from 'scenes/teamLogic' -import { JSInstallSnippet, SessionReplayFinalSteps } from '../shared-snippets' - -function ReactEnvVarsSnippet(): JSX.Element { - const { currentTeam } = useValues(teamLogic) - - return ( - - {[ - `REACT_APP_POSTHOG_PUBLIC_KEY=${currentTeam?.api_token}`, - `REACT_APP_PUBLIC_POSTHOG_HOST=${window.location.origin}`, - ].join('\n')} - - ) -} - -function ReactSetupSnippet(): JSX.Element { - return ( - - {`// src/index.js -import React from 'react'; -import ReactDOM from 'react-dom/client'; -import App from './App'; - -import { PostHogProvider} from 'posthog-js/react' - -const options = { - api_host: process.env.REACT_APP_PUBLIC_POSTHOG_HOST, -} - -const root = ReactDOM.createRoot(document.getElementById('root')); -root.render( - - - - - -);`} - - ) -} +import { SessionReplayFinalSteps } from '../shared-snippets' +import { SDKInstallReactInstructions } from '../sdk-install-instructions/react' export function ReactInstructions(): JSX.Element { return ( <> -

    Install the package

    - -

    Add environment variables

    - -

    Initialize

    -

    - Integrate PostHog at the root of your app (src/index.js for the default{' '} - create-react-app). -

    - + ) diff --git a/frontend/src/scenes/onboarding/sdks/shared-snippets.tsx b/frontend/src/scenes/onboarding/sdks/shared-snippets.tsx index f8c2fe58417c1..215f9a07693d2 100644 --- a/frontend/src/scenes/onboarding/sdks/shared-snippets.tsx +++ b/frontend/src/scenes/onboarding/sdks/shared-snippets.tsx @@ -1,14 +1,5 @@ -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' import { Link } from 'lib/lemon-ui/Link' -export function JSInstallSnippet(): JSX.Element { - return ( - - {['npm install posthog-js', '# OR', 'yarn add posthog-js', '# OR', 'pnpm add posthog-js'].join('\n')} - - ) -} - export function SessionReplayFinalSteps(): JSX.Element { return ( <> diff --git a/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.tsx b/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.tsx index a35a6afd0c0b3..f8072bdc57d32 100644 --- a/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.tsx +++ b/frontend/src/scenes/organization/Settings/VerifiedDomains/SSOSelect.tsx @@ -5,7 +5,7 @@ import { SSO_PROVIDER_NAMES } from 'lib/constants' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { SSOProvider } from '~/types' -interface SSOSelectInterface { +export interface SSOSelectInterface { value: SSOProvider | '' loading: boolean onChange: (value: SSOProvider | '') => void diff --git a/frontend/src/scenes/plugins/AppsScene.tsx b/frontend/src/scenes/plugins/AppsScene.tsx index 8e93613699a19..664d7c643c42a 100644 --- a/frontend/src/scenes/plugins/AppsScene.tsx +++ b/frontend/src/scenes/plugins/AppsScene.tsx @@ -1,4 +1,3 @@ -import './Plugins.scss' import { useEffect } from 'react' import { useActions, useValues } from 'kea' import { pluginsLogic } from './pluginsLogic' @@ -15,6 +14,8 @@ import { PluginTab } from './types' import { LemonButton } from '@posthog/lemon-ui' import { urls } from 'scenes/urls' +import './Plugins.scss' + export const scene: SceneExport = { component: AppsScene, logic: pluginsLogic, diff --git a/frontend/src/scenes/plugins/Plugins.scss b/frontend/src/scenes/plugins/Plugins.scss index 6e50e49acdab6..75bcec7ae3fdf 100644 --- a/frontend/src/scenes/plugins/Plugins.scss +++ b/frontend/src/scenes/plugins/Plugins.scss @@ -1,88 +1,21 @@ -.plugins-scene { - padding-bottom: 60px; -} - -.plugins-installed-tab-section-header:hover, -.plugins-repository-tab-section-header:hover { - cursor: pointer; -} - -.plugins-repository-tab-section-header { - margin-left: 5px; -} - -.plugin-capabilities-tag { +.Plugin__CapabilitiesTag { cursor: default; } -.plugins-scene-plugin-card { - > .ant-card-body { - padding: 15px; - } - a.plugin-title-link { - color: var(--color-text); - &:hover { - text-decoration: underline; - } - } - .plugin-image { - width: 60px; - height: 60px; - display: flex; - justify-content: center; - align-items: center; - margin-left: auto; - margin-right: auto; - border: none; - padding: 4px; - } - .order-handle { - .ant-tag { - margin-right: 0; - cursor: move; - } - .arrow { - color: #888; - height: 19px; - } - cursor: move; - text-align: center; - height: 100%; - margin-top: -10px; - margin-bottom: -10px; - } - .hide-over-500 { - display: none; - } - @media (max-width: 500px) { - .show-over-500 { - display: none; - } - .hide-over-500 { - display: inline-block; - } - button.ant-btn.padding-under-500 { - padding: 4px 8px; - } - .hide-plugin-image-below-500 { - display: none; - } - } -} -.plugins-popconfirm { +.Plugins__Popconfirm { z-index: var(--z-plugins-popconfirm); } -.plugin-job-json-editor { +.Plugin__JobJsonEditor { border: 1px solid #efefef; } -.plugin-run-job-button { +.Plugin__RunJobButton { color: var(--success); cursor: pointer; } -.plugin-run-job-button-disabled { +.Plugin__RunJobButton--disabled { color: #9a9a9a; cursor: default; } diff --git a/frontend/src/scenes/plugins/Plugins.stories.tsx b/frontend/src/scenes/plugins/Plugins.stories.tsx index 56c3dcc028615..2344623bb0fcc 100644 --- a/frontend/src/scenes/plugins/Plugins.stories.tsx +++ b/frontend/src/scenes/plugins/Plugins.stories.tsx @@ -3,7 +3,6 @@ import { App } from 'scenes/App' import { useEffect } from 'react' import { router } from 'kea-router' import { urls } from 'scenes/urls' -import { PluginTab } from 'scenes/plugins/types' import { useAvailableFeatures } from '~/mocks/features' import { AvailableFeature } from '~/types' @@ -21,7 +20,7 @@ export default meta export const Installed: Story = () => { useAvailableFeatures([AvailableFeature.APP_METRICS]) useEffect(() => { - router.actions.push(urls.projectApps(PluginTab.Installed)) + router.actions.push(urls.projectApps()) }) return } diff --git a/frontend/src/scenes/plugins/Plugins.tsx b/frontend/src/scenes/plugins/Plugins.tsx deleted file mode 100644 index a6ba5c8bf6fd5..0000000000000 --- a/frontend/src/scenes/plugins/Plugins.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import './Plugins.scss' -import { useEffect } from 'react' -import { PluginDrawer } from 'scenes/plugins/edit/PluginDrawer' -import { RepositoryTab } from 'scenes/plugins/tabs/repository/RepositoryTab' -import { InstalledTab } from 'scenes/plugins/tabs/installed/InstalledTab' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from './pluginsLogic' -import { PageHeader } from 'lib/components/PageHeader' -import { PluginTab } from 'scenes/plugins/types' -import { AdvancedTab } from 'scenes/plugins/tabs/advanced/AdvancedTab' -import { canGloballyManagePlugins, canInstallPlugins, canViewPlugins } from './access' -import { userLogic } from 'scenes/userLogic' -import { SceneExport } from 'scenes/sceneTypes' -import { ActivityLog } from 'lib/components/ActivityLog/ActivityLog' -import { ActivityScope } from 'lib/components/ActivityLog/humanizeActivity' -import { LemonTag } from '@posthog/lemon-ui' -import { LemonTabs } from 'lib/lemon-ui/LemonTabs' -import { AppsScene } from './AppsScene' -import { FEATURE_FLAGS } from 'lib/constants' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' - -export const scene: SceneExport = { - component: Plugins, - logic: pluginsLogic, -} - -const BetaTag = (): JSX.Element => ( - - BETA - -) - -export function Plugins(): JSX.Element | null { - const { user } = useValues(userLogic) - const { pluginTab } = useValues(pluginsLogic) - const { setPluginTab } = useActions(pluginsLogic) - - useEffect(() => { - if (!canViewPlugins(user?.organization)) { - window.location.href = '/' - } - }, [user]) - - const { featureFlags } = useValues(featureFlagLogic) - const newUI = featureFlags[FEATURE_FLAGS.APPS_AND_EXPORTS_UI] - - useEffect(() => { - const newUiTabs = [PluginTab.Apps, PluginTab.BatchExports] - - if (newUI && !newUiTabs.includes(pluginTab)) { - setPluginTab(PluginTab.Apps) - } else if (!newUI && newUiTabs.includes(pluginTab)) { - setPluginTab(PluginTab.Installed) - } - }, [newUI]) - - if (featureFlags[FEATURE_FLAGS.APPS_AND_EXPORTS_UI]) { - return - } - - if (!user || !canViewPlugins(user?.organization)) { - return null - } - - return ( -
    - - Apps enable you to extend PostHog's core data processing functionality. -
    - Make use of verified apps from the{' '} - - App Library - {' '} - – or{' '} - - build your own - - . - - } - tabbedPage - /> - setPluginTab(newKey)} - tabs={[ - { key: PluginTab.Installed, label: 'Installed', content: }, - canGloballyManagePlugins(user.organization) && { - key: PluginTab.Repository, - label: 'Repository', - content: , - }, - { - key: PluginTab.History, - label: ( - <> - History - - - ), - content: , - }, - canInstallPlugins(user.organization) && { - key: PluginTab.Advanced, - label: 'Advanced', - content: , - }, - ]} - /> - -
    - ) -} diff --git a/frontend/src/scenes/plugins/PluginsSearch.tsx b/frontend/src/scenes/plugins/PluginsSearch.tsx index d8d875d337c5c..0cb80c2bf9679 100644 --- a/frontend/src/scenes/plugins/PluginsSearch.tsx +++ b/frontend/src/scenes/plugins/PluginsSearch.tsx @@ -3,7 +3,7 @@ import { pluginsLogic } from 'scenes/plugins/pluginsLogic' import { LemonInput } from '@posthog/lemon-ui' export function PluginsSearch(): JSX.Element { - const { searchTerm, rearranging } = useValues(pluginsLogic) + const { searchTerm } = useValues(pluginsLogic) const { setSearchTerm } = useActions(pluginsLogic) return ( ) } diff --git a/frontend/src/scenes/plugins/access.ts b/frontend/src/scenes/plugins/access.ts index 25ccd0a4b0c85..e5c087aa025ce 100644 --- a/frontend/src/scenes/plugins/access.ts +++ b/frontend/src/scenes/plugins/access.ts @@ -21,13 +21,6 @@ export function canInstallPlugins( return organization.plugins_access_level >= PluginsAccessLevel.Install } -export function canConfigurePlugins(organization: OrganizationType | null | undefined): boolean { - if (!organization) { - return false - } - return organization.plugins_access_level >= PluginsAccessLevel.Config -} - export function canViewPlugins(organization: OrganizationType | null | undefined): boolean { if (!organization) { return false diff --git a/frontend/src/scenes/plugins/edit/PluginDrawer.tsx b/frontend/src/scenes/plugins/edit/PluginDrawer.tsx index 656810ada00f7..32bdf9cd709f2 100644 --- a/frontend/src/scenes/plugins/edit/PluginDrawer.tsx +++ b/frontend/src/scenes/plugins/edit/PluginDrawer.tsx @@ -155,7 +155,7 @@ export function PluginDrawer(): JSX.Element { onConfirm={() => uninstallPlugin(editingPlugin.name)} okText="Uninstall" cancelText="Cancel" - className="plugins-popconfirm" + className="Plugins__Popconfirm" >
diff --git a/frontend/src/scenes/plugins/edit/interface-jobs/PluginJobConfiguration.tsx b/frontend/src/scenes/plugins/edit/interface-jobs/PluginJobConfiguration.tsx index 689b72136b9d6..f60b11353b7e8 100644 --- a/frontend/src/scenes/plugins/edit/interface-jobs/PluginJobConfiguration.tsx +++ b/frontend/src/scenes/plugins/edit/interface-jobs/PluginJobConfiguration.tsx @@ -39,11 +39,11 @@ export function PluginJobConfiguration(props: InterfaceJobsProps): JSX.Element { {jobHasEmptyPayload ? ( ) : ( )} @@ -109,7 +109,7 @@ function FieldInput({ return ( - await copyToClipboard(url.substring(5))} style={style}> - {title || 'Local App'} - - - ) -} diff --git a/frontend/src/scenes/plugins/plugin/PluginCard.tsx b/frontend/src/scenes/plugins/plugin/PluginCard.tsx deleted file mode 100644 index 3600490c7b564..0000000000000 --- a/frontend/src/scenes/plugins/plugin/PluginCard.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import { Button, Tag } from 'antd' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginConfigType, PluginErrorType } from '~/types' -import { - CheckOutlined, - CloudDownloadOutlined, - LoadingOutlined, - UnorderedListOutlined, - SettingOutlined, - WarningOutlined, - InfoCircleOutlined, - DownOutlined, - GlobalOutlined, - ClockCircleOutlined, - LineChartOutlined, - UpOutlined, -} from '@ant-design/icons' -import { PluginImage } from './PluginImage' -import { PluginError } from './PluginError' -import { LocalPluginTag } from './LocalPluginTag' -import { PluginInstallationType, PluginTypeWithConfig } from 'scenes/plugins/types' -import { SourcePluginTag } from './SourcePluginTag' -import { UpdateAvailable } from 'scenes/plugins/plugin/UpdateAvailable' -import { userLogic } from 'scenes/userLogic' -import { endWithPunctation } from 'lib/utils' -import { canInstallPlugins } from '../access' -import { PluginUpdateButton } from './PluginUpdateButton' -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { LemonSwitch, Link } from '@posthog/lemon-ui' -import { organizationLogic } from 'scenes/organizationLogic' -import { PluginsAccessLevel } from 'lib/constants' -import { urls } from 'scenes/urls' -import { SuccessRateBadge } from './SuccessRateBadge' -import clsx from 'clsx' -import { CommunityTag } from 'lib/CommunityTag' - -export function PluginAboutButton({ url, disabled = false }: { url: string; disabled?: boolean }): JSX.Element { - return ( - - - - - - ) -} - -interface PluginCardProps { - plugin: Partial - pluginConfig?: PluginConfigType - error?: PluginErrorType - maintainer?: string - showUpdateButton?: boolean - order?: number - maxOrder?: number - rearranging?: boolean - DragColumn?: React.ComponentClass | React.FC - unorderedPlugin?: boolean -} - -export function PluginCard({ - plugin, - error, - maintainer, - showUpdateButton, - order, - maxOrder, - rearranging, - DragColumn = ({ children }) =>
{children}
, - unorderedPlugin = false, -}: PluginCardProps): JSX.Element { - const { - name, - description, - url, - plugin_type: pluginType, - pluginConfig, - tag, - latest_tag: latestTag, - id: pluginId, - updateStatus, - hasMoved, - is_global, - organization_id, - organization_name, - } = plugin - - const { editPlugin, toggleEnabled, installPlugin, resetPluginConfigError, rearrange, showPluginLogs } = - useActions(pluginsLogic) - const { loading, installingPluginUrl, checkingForUpdates, pluginUrlToMaintainer, showAppMetricsForPlugin } = - useValues(pluginsLogic) - const { currentOrganization } = useValues(organizationLogic) - const { user } = useValues(userLogic) - - const hasSpecifiedMaintainer = maintainer || (plugin.url && pluginUrlToMaintainer[plugin.url]) - const pluginMaintainer = maintainer || pluginUrlToMaintainer[plugin.url || ''] - - return ( -
-
-
- {typeof order === 'number' && typeof maxOrder === 'number' ? ( - -
- -
-
- - {order} - -
-
- -
-
- ) : null} - {unorderedPlugin ? ( - - - - - ) : null} - {pluginConfig && - (pluginConfig.id ? ( - toggleEnabled({ id: pluginConfig.id, enabled: !pluginConfig.enabled })} - /> - ) : ( - - - - ))} -
- -
-
-
- - {showAppMetricsForPlugin(plugin) && pluginConfig?.id && ( - - )} - {name} - - {hasSpecifiedMaintainer && } - {pluginConfig?.error ? ( - resetPluginConfigError(pluginConfig?.id || 0)} - /> - ) : error ? ( - - ) : null} - {is_global && - !!currentOrganization && - currentOrganization.plugins_access_level >= PluginsAccessLevel.Install && ( - - }> - Global - - - )} - {canInstallPlugins(user?.organization, organization_id) && ( - <> - {url?.startsWith('file:') ? : null} - {updateStatus?.error ? ( - - Error checking for updates - - ) : checkingForUpdates && - !updateStatus && - pluginType !== PluginInstallationType.Source && - !url?.startsWith('file:') ? ( - - Checking for updates… - - ) : url && latestTag && tag ? ( - tag === latestTag ? ( - - Up to date - - ) : ( - - ) - ) : null} - {pluginType === PluginInstallationType.Source ? : null} - - )} -
-
{endWithPunctation(description)}
-
-
- {url && } - {showUpdateButton && pluginId ? ( - - ) : pluginId ? ( - <> - {showAppMetricsForPlugin(plugin) && pluginConfig?.id ? ( - - - - ) : null} - {pluginConfig?.id ? ( - - - - ) : null} - - - - - - - - ) : !pluginId ? ( - - ) : null} -
-
-
-
- ) -} diff --git a/frontend/src/scenes/plugins/plugin/PluginError.tsx b/frontend/src/scenes/plugins/plugin/PluginError.tsx deleted file mode 100644 index ee4031ca8c0c1..0000000000000 --- a/frontend/src/scenes/plugins/plugin/PluginError.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { PluginErrorType } from '~/types' -import { Tag } from 'antd' -import { CodeSnippet, Language } from 'lib/components/CodeSnippet' -import { LemonButton, LemonDropdown } from '@posthog/lemon-ui' -import { TZLabel } from '@posthog/apps-common' -import { IconClose } from 'lib/lemon-ui/icons' - -export function PluginError({ error, reset }: { error: PluginErrorType; reset?: () => void }): JSX.Element | null { - if (!error) { - return null - } - return ( - -
- - {error.name ? {error.name} : ''} - - - {reset ? ( - }> - Clear - - ) : null} -
- {error.stack ? ( - - {error.stack} - - ) : null} - {error.event ? ( - - {JSON.stringify(error.event, null, 2)} - - ) : null} - - } - placement="top" - showArrow - > - - ERROR - -
- ) -} diff --git a/frontend/src/scenes/plugins/plugin/PluginLoading.tsx b/frontend/src/scenes/plugins/plugin/PluginLoading.tsx deleted file mode 100644 index 7926f9c3e2fae..0000000000000 --- a/frontend/src/scenes/plugins/plugin/PluginLoading.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Skeleton } from 'antd' - -export function PluginLoading(): JSX.Element { - return ( -
- {[1, 2, 3].map((i) => ( -
-
-
-
- -
-
- -
-
- - - - - - -
-
-
-
- ))} -
- ) -} diff --git a/frontend/src/scenes/plugins/plugin/PluginUpdateButton.tsx b/frontend/src/scenes/plugins/plugin/PluginUpdateButton.tsx deleted file mode 100644 index 04bc0ecc41422..0000000000000 --- a/frontend/src/scenes/plugins/plugin/PluginUpdateButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Button } from 'antd' -import { useActions, useValues } from 'kea' -import { PluginUpdateStatusType } from '../types' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { CheckOutlined, CloudDownloadOutlined } from '@ant-design/icons' - -interface PluginUpdateButtonProps { - updateStatus: PluginUpdateStatusType | undefined - pluginId: number - rearranging: boolean | undefined -} - -export const PluginUpdateButton = ({ updateStatus, pluginId, rearranging }: PluginUpdateButtonProps): JSX.Element => { - const { editPlugin, updatePlugin } = useActions(pluginsLogic) - const { pluginsUpdating } = useValues(pluginsLogic) - return ( - - ) -} diff --git a/frontend/src/scenes/plugins/plugin/SourcePluginTag.tsx b/frontend/src/scenes/plugins/plugin/SourcePluginTag.tsx deleted file mode 100644 index 6e44232a23cf7..0000000000000 --- a/frontend/src/scenes/plugins/plugin/SourcePluginTag.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { Tag } from 'antd' - -export function SourcePluginTag({ - title = 'Source Code', - style, -}: { - title?: string - style?: React.CSSProperties -}): JSX.Element { - return {title} -} diff --git a/frontend/src/scenes/plugins/plugin/UpdateAvailable.tsx b/frontend/src/scenes/plugins/plugin/UpdateAvailable.tsx deleted file mode 100644 index 524754899fe93..0000000000000 --- a/frontend/src/scenes/plugins/plugin/UpdateAvailable.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { CloudDownloadOutlined } from '@ant-design/icons' -import { Tag } from 'antd' -import { Tooltip } from 'lib/lemon-ui/Tooltip' - -function SHATag({ tag }: { tag: string }): JSX.Element { - // github/gitlab sha tag - if (tag.match(/^[a-f0-9]{40}$/)) { - return {tag.substring(0, 7)} - } - return {tag} -} - -export function UpdateAvailable({ url, tag, latestTag }: { url: string; tag: string; latestTag: string }): JSX.Element { - let compareUrl: string = '' - - if (url.match(/^https:\/\/(www.|)github.com\//)) { - compareUrl = `${url}/compare/${tag}...${latestTag}` - } - if (url.match(/^https:\/\/(www.|)gitlab.com\//)) { - compareUrl = `${url}/-/compare/${tag}...${latestTag}` - } - - return ( - - Installed: -
- Latest: - {compareUrl ?
Click to see the diff
: null} -
- } - > - {compareUrl ? ( - - - Update available! - - - ) : ( - - Update available! - - )} - - ) -} diff --git a/frontend/src/scenes/plugins/pluginsLogic.ts b/frontend/src/scenes/plugins/pluginsLogic.ts index a457e46aa5ffa..c04800830cede 100644 --- a/frontend/src/scenes/plugins/pluginsLogic.ts +++ b/frontend/src/scenes/plugins/pluginsLogic.ts @@ -24,17 +24,9 @@ import { lemonToast } from 'lib/lemon-ui/lemonToast' export type PluginForm = FormInstance -export enum PluginSection { - Upgrade = 'upgrade', - Installed = 'installed', - Enabled = 'enabled', - Disabled = 'disabled', -} - export interface PluginSelectionType { name: string url?: string - tab: PluginTab } const PAGINATION_DEFAULT_MAX_PAGES = 10 @@ -78,7 +70,6 @@ export const pluginsLogic = kea([ setSourcePluginName: (sourcePluginName: string) => ({ sourcePluginName }), setPluginTab: (tab: PluginTab) => ({ tab }), setEditingSource: (editingSource: boolean) => ({ editingSource }), - resetPluginConfigError: (id: number) => ({ id }), checkForUpdates: (checkAll: boolean, initialUpdateStatus: Record = {}) => ({ checkAll, initialUpdateStatus, @@ -90,20 +81,15 @@ export const pluginsLogic = kea([ pluginUpdated: (id: number) => ({ id }), patchPlugin: (id: number, pluginChanges: Partial = {}) => ({ id, pluginChanges }), generateApiKeysIfNeeded: (form: PluginForm) => ({ form }), - rearrange: true, setTemporaryOrder: (temporaryOrder: Record, movedPluginId: number) => ({ temporaryOrder, movedPluginId, }), - makePluginOrderSaveable: true, savePluginOrders: (newOrders: Record) => ({ newOrders }), cancelRearranging: true, showPluginLogs: (id: number) => ({ id }), hidePluginLogs: true, - processSearchInput: (term: string) => ({ term }), setSearchTerm: (term: string | null) => ({ term }), - setPluginConfigPollTimeout: (timeout: number | null) => ({ timeout }), - toggleSectionOpen: (section: PluginSection) => ({ section }), syncFrontendAppState: (id: number) => ({ id }), openAdvancedInstallModal: true, closeAdvancedInstallModal: true, @@ -231,13 +217,6 @@ export const pluginsLogic = kea([ }) return { ...pluginConfigs, [response.plugin]: response } }, - resetPluginConfigError: async ({ id }) => { - const { pluginConfigs } = values - const response = await api.update(`api/plugin_config/${id}`, { - error: null, - }) - return { ...pluginConfigs, [response.plugin]: response } - }, savePluginOrders: async ({ newOrders }) => { const { pluginConfigs } = values const response: PluginConfigType[] = await api.update(`api/plugin_config/rearrange`, { @@ -347,10 +326,9 @@ export const pluginsLogic = kea([ }, }, pluginTab: [ - PluginTab.Installed as PluginTab, + PluginTab.Apps as PluginTab, { setPluginTab: (_, { tab }) => tab, - installPluginSuccess: (state) => (state === PluginTab.Apps ? state : PluginTab.Installed), }, ], updateStatus: [ @@ -380,26 +358,9 @@ export const pluginsLogic = kea([ checkedForUpdates: () => false, }, ], - pluginOrderSaveable: [ - false, - { - makePluginOrderSaveable: () => true, - cancelRearranging: () => false, - savePluginOrdersSuccess: () => false, - }, - ], - rearranging: [ - false, - { - rearrange: () => true, - cancelRearranging: () => false, - savePluginOrdersSuccess: () => false, - }, - ], temporaryOrder: [ {} as Record, { - rearrange: () => ({}), setTemporaryOrder: (_, { temporaryOrder }) => temporaryOrder, cancelRearranging: () => ({}), savePluginOrdersSuccess: () => ({}), @@ -408,7 +369,6 @@ export const pluginsLogic = kea([ movedPlugins: [ {} as Record, { - rearrange: () => ({}), setTemporaryOrder: (state, { movedPluginId }) => ({ ...state, [movedPluginId]: true }), cancelRearranging: () => ({}), savePluginOrdersSuccess: () => ({}), @@ -433,17 +393,6 @@ export const pluginsLogic = kea([ setSearchTerm: (_, { term }) => term, }, ], - sectionsOpen: [ - [PluginSection.Enabled, PluginSection.Disabled] as PluginSection[], - { - toggleSectionOpen: (currentOpenSections, { section }) => { - if (currentOpenSections.includes(section)) { - return currentOpenSections.filter((s) => section !== s) - } - return [...currentOpenSections, section] - }, - }, - ], advancedInstallModalOpen: [ false, { @@ -669,14 +618,14 @@ export const pluginsLogic = kea([ (repository, plugins) => { const allPossiblePlugins: PluginSelectionType[] = [] for (const plugin of Object.values(plugins) as PluginType[]) { - allPossiblePlugins.push({ name: plugin.name, url: plugin.url, tab: PluginTab.Installed }) + allPossiblePlugins.push({ name: plugin.name, url: plugin.url }) } const installedUrls = new Set(Object.values(plugins).map((plugin) => plugin.url)) for (const plugin of Object.values(repository) as PluginRepositoryEntry[]) { if (!installedUrls.has(plugin.url)) { - allPossiblePlugins.push({ name: plugin.name, url: plugin.url, tab: PluginTab.Repository }) + allPossiblePlugins.push({ name: plugin.name, url: plugin.url }) } } return allPossiblePlugins @@ -691,9 +640,6 @@ export const pluginsLogic = kea([ }), listeners(({ actions, values }) => ({ - toggleEnabledSuccess: ({ payload: { id } }) => { - actions.syncFrontendAppState(id) - }, // Load or unload an app, as directed by its enabled state in pluginsLogic syncFrontendAppState: ({ id }) => { const pluginConfig = values.getPluginConfig(id) @@ -720,7 +666,6 @@ export const pluginsLogic = kea([ } actions.checkedForUpdates() - actions.toggleSectionOpen(PluginSection.Upgrade) }, loadPluginsSuccess() { const initialUpdateStatus: Record = {} @@ -731,12 +676,6 @@ export const pluginsLogic = kea([ } if (canInstallPlugins(userLogic.values.user?.organization)) { actions.checkForUpdates(false, initialUpdateStatus) - if ( - Object.keys(values.plugins).length === 0 && - canGloballyManagePlugins(userLogic.values.user?.organization) - ) { - actions.setPluginTab(PluginTab.Repository) - } } }, generateApiKeysIfNeeded: async ({ form }, breakpoint) => { @@ -794,8 +733,8 @@ export const pluginsLogic = kea([ } let replace = false // set a page in history - if (!searchParams['tab'] && values.pluginTab === PluginTab.Installed) { - // we are on the Installed page, and have clicked the Installed tab, don't set history + if (!searchParams['tab'] && values.pluginTab === PluginTab.Apps) { + // we are on the Apps page, and have clicked the Apps tab, don't set history replace = true } searchParams['tab'] = values.pluginTab @@ -829,7 +768,7 @@ export const pluginsLogic = kea([ if (tab) { actions.setPluginTab(tab as PluginTab) } - if (name && [PluginTab.Repository, PluginTab.Installed].includes(values.pluginTab)) { + if (name && values.pluginTab === PluginTab.Apps) { actions.setSearchTerm(name) } runActions(null, false, null) diff --git a/frontend/src/scenes/plugins/tabs/advanced/AdvancedTab.tsx b/frontend/src/scenes/plugins/tabs/advanced/AdvancedTab.tsx deleted file mode 100644 index 7245e12b85033..0000000000000 --- a/frontend/src/scenes/plugins/tabs/advanced/AdvancedTab.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { Alert } from 'antd' -import { PluginTab } from 'scenes/plugins/types' -import { Subtitle } from 'lib/components/PageHeader' -import { SourcePlugin } from 'scenes/plugins/tabs/advanced/SourcePlugin' -import { CustomPlugin } from 'scenes/plugins/tabs/advanced/CustomPlugin' -import { LocalPlugin } from 'scenes/plugins/tabs/advanced/LocalPlugin' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' - -export function AdvancedTab(): JSX.Element { - const { preflight } = useValues(preflightLogic) - const { setPluginTab } = useActions(pluginsLogic) - - return ( - <> - - Create and install your own apps or apps from third-parties. If you're looking for - officially supported apps, try the{' '} - { - e.preventDefault() - setPluginTab(PluginTab.Repository) - }} - > - App Repository - - . - - } - type="warning" - showIcon - /> -
- - - - {preflight && !preflight.cloud && } -
- - ) -} diff --git a/frontend/src/scenes/plugins/tabs/advanced/CustomPlugin.tsx b/frontend/src/scenes/plugins/tabs/advanced/CustomPlugin.tsx deleted file mode 100644 index 7c535e1edfa01..0000000000000 --- a/frontend/src/scenes/plugins/tabs/advanced/CustomPlugin.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginInstallationType } from 'scenes/plugins/types' -import Title from 'antd/lib/typography/Title' -import Paragraph from 'antd/lib/typography/Paragraph' -import { LemonButton, LemonInput } from '@posthog/lemon-ui' - -export function CustomPlugin(): JSX.Element { - const { customPluginUrl, pluginError, loading } = useValues(pluginsLogic) - const { setCustomPluginUrl, installPlugin } = useActions(pluginsLogic) - - return ( -
- Install from GitHub, GitLab or npm - - To install a third-party or custom app, paste its URL below. For{' '} - - GitHub - - {', '} - - GitLab - - {' and '} - - npm - {' '} - private repositories, append ?private_token=TOKEN to the end of the URL. -
- Warning: Only install apps from trusted sources. -
- -
- setCustomPluginUrl(value)} - placeholder="https://github.com/user/repo" - fullWidth={true} - size="small" - /> - installPlugin(customPluginUrl, PluginInstallationType.Custom)} - size="small" - status="muted" - type="secondary" - > - Fetch and install - -
- {pluginError ?

{pluginError}

: null} -
- ) -} diff --git a/frontend/src/scenes/plugins/tabs/advanced/LocalPlugin.tsx b/frontend/src/scenes/plugins/tabs/advanced/LocalPlugin.tsx deleted file mode 100644 index ec293e6f21d62..0000000000000 --- a/frontend/src/scenes/plugins/tabs/advanced/LocalPlugin.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginInstallationType } from 'scenes/plugins/types' -import Title from 'antd/lib/typography/Title' -import Paragraph from 'antd/lib/typography/Paragraph' -import { LemonButton, LemonInput } from '@posthog/lemon-ui' - -export function LocalPlugin(): JSX.Element { - const { localPluginUrl, pluginError, loading } = useValues(pluginsLogic) - const { setLocalPluginUrl, installPlugin } = useActions(pluginsLogic) - - return ( -
- Install Local App - To install a local app from this computer/server, give its full path below. - -
- setLocalPluginUrl(value)} - placeholder="/var/posthog/plugins/helloworldplugin" - fullWidth={true} - size="small" - /> - installPlugin(localPluginUrl, PluginInstallationType.Local)} - size="small" - status="muted" - type="secondary" - > - Install - -
- {pluginError ?

{pluginError}

: null} -
- ) -} diff --git a/frontend/src/scenes/plugins/tabs/advanced/SourcePlugin.tsx b/frontend/src/scenes/plugins/tabs/advanced/SourcePlugin.tsx deleted file mode 100644 index ad4adc3ac9b53..0000000000000 --- a/frontend/src/scenes/plugins/tabs/advanced/SourcePlugin.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginInstallationType } from 'scenes/plugins/types' -import Title from 'antd/lib/typography/Title' -import Paragraph from 'antd/lib/typography/Paragraph' -import { LemonButton, LemonInput } from '@posthog/lemon-ui' - -export function SourcePlugin(): JSX.Element { - const { sourcePluginName, pluginError, loading } = useValues(pluginsLogic) - const { setSourcePluginName, installPlugin } = useActions(pluginsLogic) - - return ( -
- App editor - - Write your app directly in PostHog.{' '} - - Read the documentation for more information! - - -
- setSourcePluginName(value)} - placeholder={`For example: "Hourly Weather Sync App"`} - fullWidth={true} - size="small" - /> - - installPlugin(sourcePluginName, PluginInstallationType.Source)} - size="small" - status="muted" - type="secondary" - > - Start coding - -
- {pluginError ?

{pluginError}

: null} -
- ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/InstalledPlugin.tsx b/frontend/src/scenes/plugins/tabs/installed/InstalledPlugin.tsx deleted file mode 100644 index 69ee1f4cea048..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/InstalledPlugin.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { PluginCard } from 'scenes/plugins/plugin/PluginCard' -import { PluginTypeWithConfig } from 'scenes/plugins/types' - -export function InstalledPlugin({ - plugin, - showUpdateButton, - order, - maxOrder, - rearranging, - DragColumn, - unorderedPlugin = false, -}: { - plugin: PluginTypeWithConfig - showUpdateButton?: boolean - order?: number - maxOrder?: number - rearranging?: boolean - DragColumn?: React.ComponentClass | React.FC - unorderedPlugin?: boolean -}): JSX.Element { - return ( - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/InstalledTab.tsx b/frontend/src/scenes/plugins/tabs/installed/InstalledTab.tsx deleted file mode 100644 index f3ec7c929635a..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/InstalledTab.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { LogsDrawer } from '../../plugin/LogsDrawer' -import { PluginsSearch } from '../../PluginsSearch' -import { PluginsEmptyState } from './sections/PluginsEmptyState' -import { DisabledPluginSection } from './sections/DisabledPluginsSection' -import { UpgradeSection } from './sections/UpgradeSection' -import { EnabledPluginSection } from './sections/EnabledPluginsSection' - -export function InstalledTab(): JSX.Element { - const { installedPlugins } = useValues(pluginsLogic) - - if (installedPlugins.length === 0) { - return - } - - return ( - <> -
- - - - -
- - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/sections/DisabledPluginsSection.tsx b/frontend/src/scenes/plugins/tabs/installed/sections/DisabledPluginsSection.tsx deleted file mode 100644 index 417edb5887b72..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/sections/DisabledPluginsSection.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons' -import { Row } from 'antd' -import { Subtitle } from 'lib/components/PageHeader' -import { useActions, useValues } from 'kea' -import { PluginSection, pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { InstalledPlugin } from '../InstalledPlugin' - -export function DisabledPluginSection(): JSX.Element { - const { filteredDisabledPlugins, sectionsOpen, disabledPlugins } = useValues(pluginsLogic) - const { toggleSectionOpen } = useActions(pluginsLogic) - - if (disabledPlugins.length === 0) { - return <> - } - - return ( - <> -
toggleSectionOpen(PluginSection.Disabled)} - > - - {sectionsOpen.includes(PluginSection.Disabled) ? ( - - ) : ( - - )} - {` Installed apps (${filteredDisabledPlugins.length})`} - - } - /> -
- {sectionsOpen.includes(PluginSection.Disabled) ? ( - <> - {filteredDisabledPlugins.length > 0 ? ( - - {filteredDisabledPlugins.map((plugin) => ( - - ))} - - ) : ( -

No apps match your search.

- )} - - ) : null} - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/sections/EnabledPluginsSection.tsx b/frontend/src/scenes/plugins/tabs/installed/sections/EnabledPluginsSection.tsx deleted file mode 100644 index bbf3a08a00593..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/sections/EnabledPluginsSection.tsx +++ /dev/null @@ -1,252 +0,0 @@ -import { - CaretRightOutlined, - CaretDownOutlined, - CloseOutlined, - SaveOutlined, - OrderedListOutlined, -} from '@ant-design/icons' -import { Button, Tag } from 'antd' -import { Subtitle } from 'lib/components/PageHeader' -import { useActions, useValues } from 'kea' -import { PluginSection, pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { InstalledPlugin } from '../InstalledPlugin' -import { canConfigurePlugins } from '../../../access' -import { userLogic } from 'scenes/userLogic' -import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc' -import { PluginTypeWithConfig } from 'scenes/plugins/types' -import { Tooltip } from 'lib/lemon-ui/Tooltip' - -type HandleProps = { children?: JSX.Element } - -const DragColumn = SortableHandle(({ children }: HandleProps) => ( -
{children}
-)) - -const SortablePlugin = SortableElement( - ({ - plugin, - order, - maxOrder, - rearranging, - }: { - plugin: PluginTypeWithConfig - order: number - maxOrder: number - rearranging: boolean - }) => ( - - ) -) -const SortablePlugins = SortableContainer(({ children }: { children: React.ReactNode }) => { - return
{children}
-}) - -export function EnabledPluginSection(): JSX.Element { - const { user } = useValues(userLogic) - - const { - rearrange, - setTemporaryOrder, - cancelRearranging, - savePluginOrders, - makePluginOrderSaveable, - toggleSectionOpen, - } = useActions(pluginsLogic) - - const { - enabledPlugins, - filteredEnabledPlugins, - sortableEnabledPlugins, - unsortableEnabledPlugins, - rearranging, - loading, - temporaryOrder, - pluginOrderSaveable, - searchTerm, - sectionsOpen, - } = useValues(pluginsLogic) - - const canRearrange: boolean = canConfigurePlugins(user?.organization) && sortableEnabledPlugins.length > 1 - - const rearrangingButtons = rearranging ? ( - <> - - - - ) : ( - - {searchTerm ? ( - 'Editing the order of apps is disabled when searching.' - ) : ( - <> - Order matters because event processing with apps works like a pipe: the event is - processed by every enabled app in sequence. - - )} - - ) - } - placement="top" - > - - - ) - - const onSortEnd = ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }): void => { - if (oldIndex === newIndex) { - return - } - - const move = (arr: PluginTypeWithConfig[], from: number, to: number): { id: number; order: number }[] => { - const clone = [...arr] - Array.prototype.splice.call(clone, to, 0, Array.prototype.splice.call(clone, from, 1)[0]) - return clone.map(({ id }, order) => ({ id, order: order + 1 })) - } - - const movedPluginId: number = enabledPlugins[oldIndex]?.id - - const newTemporaryOrder: Record = {} - for (const { id, order } of move(enabledPlugins, oldIndex, newIndex)) { - newTemporaryOrder[id] = order - } - - if (!rearranging) { - rearrange() - } - - setTemporaryOrder(newTemporaryOrder, movedPluginId) - } - - const EnabledPluginsHeader = (): JSX.Element => ( -
toggleSectionOpen(PluginSection.Enabled)}> - - {sectionsOpen.includes(PluginSection.Enabled) ? : } - {` Enabled apps (${filteredEnabledPlugins.length})`} - {rearranging && sectionsOpen.includes(PluginSection.Enabled) && ( - - Reordering in progress - - )} - - } - buttons={ -
- {sectionsOpen.includes(PluginSection.Enabled) && rearrangingButtons} -
- } - /> -
- ) - - if (enabledPlugins.length === 0) { - return ( - <> - - {sectionsOpen.includes(PluginSection.Enabled) &&

No apps enabled.

} - - ) - } - - return ( - <> - - {sectionsOpen.includes(PluginSection.Enabled) && ( - <> - {sortableEnabledPlugins.length === 0 && unsortableEnabledPlugins.length === 0 && ( -

No apps match your search.

- )} - {canRearrange || rearranging ? ( - <> - {sortableEnabledPlugins.length > 0 && ( - <> - - {sortableEnabledPlugins.map((plugin, index) => ( - - ))} - - - )} - - ) : ( -
- {sortableEnabledPlugins.length > 0 && ( - <> - {sortableEnabledPlugins.map((plugin, index) => ( - - ))} - - )} -
- )} - {unsortableEnabledPlugins.map((plugin) => ( - - ))} - - )} - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/sections/PluginsEmptyState.tsx b/frontend/src/scenes/plugins/tabs/installed/sections/PluginsEmptyState.tsx deleted file mode 100644 index 7521076e30d29..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/sections/PluginsEmptyState.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { CaretRightOutlined } from '@ant-design/icons' -import { Empty, Skeleton } from 'antd' -import { Subtitle } from 'lib/components/PageHeader' -import { PluginLoading } from 'scenes/plugins/plugin/PluginLoading' -import { useActions, useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginTab } from 'scenes/plugins/types' -import { canGloballyManagePlugins } from 'scenes/plugins/access' -import { userLogic } from 'scenes/userLogic' -import { LemonButton } from '@posthog/lemon-ui' - -export function PluginsEmptyState(): JSX.Element { - const { setPluginTab } = useActions(pluginsLogic) - const { loading } = useValues(pluginsLogic) - const { user } = useValues(userLogic) - - return ( - <> - {loading ? ( - <> - - {' '} - {'Enabled apps'}{' '} - - } - buttons={} - /> - - - ) : ( - <> - -
- You haven't installed any apps yet}> - {canGloballyManagePlugins(user?.organization) && ( - setPluginTab(PluginTab.Repository)} - status="muted" - type="secondary" - > - Open the App Repository - - )} - -
- - )} - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/installed/sections/UpgradeSection.tsx b/frontend/src/scenes/plugins/tabs/installed/sections/UpgradeSection.tsx deleted file mode 100644 index 2bdf4ff3af7cd..0000000000000 --- a/frontend/src/scenes/plugins/tabs/installed/sections/UpgradeSection.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { CaretRightOutlined, CaretDownOutlined, SyncOutlined, CloudDownloadOutlined } from '@ant-design/icons' -import { Button, Row } from 'antd' -import { Subtitle } from 'lib/components/PageHeader' -import { useActions, useValues } from 'kea' -import { PluginSection, pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { InstalledPlugin } from '../InstalledPlugin' -import { canInstallPlugins } from 'scenes/plugins/access' -import { userLogic } from 'scenes/userLogic' - -export function UpgradeSection(): JSX.Element { - const { checkForUpdates, toggleSectionOpen } = useActions(pluginsLogic) - const { sectionsOpen } = useValues(pluginsLogic) - const { user } = useValues(userLogic) - - const { - filteredPluginsNeedingUpdates, - pluginsNeedingUpdates, - checkingForUpdates, - installedPluginUrls, - updateStatus, - rearranging, - hasUpdatablePlugins, - } = useValues(pluginsLogic) - - const upgradeButton = canInstallPlugins(user?.organization) && hasUpdatablePlugins && ( - - ) - - return ( - <> -
toggleSectionOpen(PluginSection.Upgrade)} - > - - {sectionsOpen.includes(PluginSection.Upgrade) ? ( - - ) : ( - - )} - {` Apps to update (${filteredPluginsNeedingUpdates.length})`} - - } - buttons={!rearranging && upgradeButton} - /> -
- {sectionsOpen.includes(PluginSection.Upgrade) ? ( - <> - {pluginsNeedingUpdates.length > 0 ? ( - - {filteredPluginsNeedingUpdates.length > 0 ? ( - <> - {filteredPluginsNeedingUpdates.map((plugin) => ( - - ))} - - ) : ( -

No apps match your search.

- )} -
- ) : ( -

All your apps are up to date. Great work!

- )} - - ) : null} - - ) -} diff --git a/frontend/src/scenes/plugins/tabs/repository/RepositoryTab.tsx b/frontend/src/scenes/plugins/tabs/repository/RepositoryTab.tsx deleted file mode 100644 index dc6164ad9680a..0000000000000 --- a/frontend/src/scenes/plugins/tabs/repository/RepositoryTab.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useState } from 'react' -import { Row } from 'antd' -import { useValues } from 'kea' -import { pluginsLogic } from 'scenes/plugins/pluginsLogic' -import { PluginCard } from 'scenes/plugins/plugin/PluginCard' -import { Subtitle } from 'lib/components/PageHeader' -import { PluginLoading } from 'scenes/plugins/plugin/PluginLoading' -import { PluginsSearch } from '../../PluginsSearch' -import { CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons' - -export enum RepositorySection { - Official = 'official', - Community = 'community', -} - -export function RepositoryTab(): JSX.Element { - return ( -
- - - -
- ) -} - -export function RepositoryApps(): JSX.Element { - const { repositoryLoading, filteredUninstalledPlugins } = useValues(pluginsLogic) - const [repositorySectionsOpen, setRepositorySectionsOpen] = useState([ - RepositorySection.Official, - RepositorySection.Community, - ]) - - const officialPlugins = filteredUninstalledPlugins.filter((plugin) => plugin.maintainer === 'official') - const communityPlugins = filteredUninstalledPlugins.filter((plugin) => plugin.maintainer === 'community') - - const toggleRepositorySectionOpen = (section: RepositorySection): void => { - if (repositorySectionsOpen.includes(section)) { - setRepositorySectionsOpen(repositorySectionsOpen.filter((s) => section !== s)) - return - } - setRepositorySectionsOpen([...repositorySectionsOpen, section]) - } - - return ( -
- {(!repositoryLoading || filteredUninstalledPlugins.length > 0) && ( - <> -
-
toggleRepositorySectionOpen(RepositorySection.Official)} - > - - {repositorySectionsOpen.includes(RepositorySection.Official) ? ( - - ) : ( - - )} - {` Official apps (${officialPlugins.length})`} - - } - /> -
- {repositorySectionsOpen.includes(RepositorySection.Official) && ( - <> -
- {officialPlugins.length > 0 - ? 'Official apps are built and maintained by the PostHog team.' - : 'You have already installed all official apps!'} -
-
- {officialPlugins.map((plugin) => { - return ( - - ) - })} - - )} -
-
-
toggleRepositorySectionOpen(RepositorySection.Community)} - > - - {repositorySectionsOpen.includes(RepositorySection.Community) ? ( - - ) : ( - - )} - {` Community apps (${communityPlugins.length})`} - - } - /> -
- {repositorySectionsOpen.includes(RepositorySection.Community) && ( - <> -
- {communityPlugins.length > 0 ? ( - - Community apps are not built nor maintained by the PostHog team.{' '} - - Anyone, including you, can build an app. - - - ) : ( - 'You have already installed all community apps!' - )} -
-
- {communityPlugins.map((plugin) => { - return ( - - ) - })} - - )} -
- - )} - {repositoryLoading && filteredUninstalledPlugins.length === 0 && ( - - - - )} -
- ) -} diff --git a/frontend/src/scenes/plugins/types.ts b/frontend/src/scenes/plugins/types.ts index 8a52eabc69a36..8eab513b7f398 100644 --- a/frontend/src/scenes/plugins/types.ts +++ b/frontend/src/scenes/plugins/types.ts @@ -36,11 +36,6 @@ export enum PluginInstallationType { } export enum PluginTab { - Installed = 'installed', - Repository = 'repository', - Advanced = 'advanced', - - // New values Apps = 'apps', BatchExports = 'batch_exports', History = 'history', diff --git a/frontend/src/scenes/products/Products.tsx b/frontend/src/scenes/products/Products.tsx index 703e904d00a88..922665ecdf730 100644 --- a/frontend/src/scenes/products/Products.tsx +++ b/frontend/src/scenes/products/Products.tsx @@ -1,8 +1,8 @@ import { LemonButton } from '@posthog/lemon-ui' import { IconBarChart } from 'lib/lemon-ui/icons' import { SceneExport } from 'scenes/sceneTypes' -import { BillingProductV2Type } from '~/types' -import { useValues } from 'kea' +import { BillingProductV2Type, ProductKey } from '~/types' +import { useActions, useValues } from 'kea' import { teamLogic } from 'scenes/teamLogic' import { useEffect } from 'react' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' @@ -11,6 +11,9 @@ import { urls } from 'scenes/urls' import { billingLogic } from 'scenes/billing/billingLogic' import { Spinner } from 'lib/lemon-ui/Spinner' import { LemonCard } from 'lib/lemon-ui/LemonCard/LemonCard' +import { router } from 'kea-router' +import { getProductUri } from 'scenes/onboarding/onboardingLogic' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' export const scene: SceneExport = { component: Products, @@ -20,25 +23,42 @@ export const scene: SceneExport = { function OnboardingCompletedButton({ productUrl, onboardingUrl, + productKey, }: { productUrl: string onboardingUrl: string + productKey: ProductKey }): JSX.Element { + const { reportOnboardingProductSelected } = useActions(eventUsageLogic) return ( <> Go to product - + { + reportOnboardingProductSelected(productKey) + router.actions.push(onboardingUrl) + }} + > Set up again ) } -function OnboardingNotCompletedButton({ url }: { url: string }): JSX.Element { +function OnboardingNotCompletedButton({ url, productKey }: { url: string; productKey: ProductKey }): JSX.Element { + const { reportOnboardingProductSelected } = useActions(eventUsageLogic) return ( - + { + reportOnboardingProductSelected(productKey) + router.actions.push(url) + }} + > Get started ) @@ -64,9 +84,16 @@ function ProductCard({ product }: { product: BillingProductV2Type }): JSX.Elemen

{product.description}

{onboardingCompleted ? ( - + ) : ( - + )}
@@ -77,6 +104,7 @@ export function Products(): JSX.Element { const { featureFlags } = useValues(featureFlagLogic) const { billing } = useValues(billingLogic) const { currentTeam } = useValues(teamLogic) + const { updateCurrentTeam } = useActions(teamLogic) const isFirstProduct = Object.keys(currentTeam?.has_completed_onboarding_for || {}).length === 0 const products = billing?.products || [] @@ -105,7 +133,19 @@ export function Products(): JSX.Element { ))}
- + { + updateCurrentTeam({ + has_completed_onboarding_for: { + ...currentTeam?.has_completed_onboarding_for, + skipped_onboarding: true, + }, + }) + router.actions.replace(urls.default()) + }} + size="small" + > None of these
diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx index 57a24e5cbc2c2..be77b9fc743a6 100644 --- a/frontend/src/scenes/saved-insights/SavedInsights.tsx +++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx @@ -248,6 +248,24 @@ export const QUERY_TYPES_METADATA: Record = { icon: IconPerson, inMenu: false, }, + [NodeKind.WebTopSourcesQuery]: { + name: 'Top Sources', + description: 'View top sources for a website', + icon: InsightsTrendsIcon, + inMenu: true, + }, + [NodeKind.WebTopPagesQuery]: { + name: 'Top Pages', + description: 'View top pages for a website', + icon: InsightsTrendsIcon, + inMenu: true, + }, + [NodeKind.WebTopClicksQuery]: { + name: 'Top Clicks', + description: 'View top clicks for a website', + icon: InsightsTrendsIcon, + inMenu: true, + }, } export const INSIGHT_TYPE_OPTIONS: LemonSelectOptions = [ diff --git a/frontend/src/scenes/sceneLogic.ts b/frontend/src/scenes/sceneLogic.ts index 336a3f83697f6..904ed37e578c5 100644 --- a/frontend/src/scenes/sceneLogic.ts +++ b/frontend/src/scenes/sceneLogic.ts @@ -13,6 +13,8 @@ import { LoadedScene, Params, Scene, SceneConfig, SceneExport, SceneParams } fro import { emptySceneParams, preloadedScenes, redirects, routes, sceneConfigurations } from 'scenes/scenes' import { organizationLogic } from './organizationLogic' import { appContextLogic } from './appContextLogic' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { FEATURE_FLAGS } from 'lib/constants' /** Mapping of some scenes that aren't directly accessible from the sidebar to ones that are - for the sidebar. */ const sceneNavAlias: Partial> = { @@ -36,7 +38,7 @@ const sceneNavAlias: Partial> = { [Scene.DataWarehousePosthog]: Scene.DataWarehouse, [Scene.DataWarehouseExternal]: Scene.DataWarehouse, [Scene.DataWarehouseSavedQueries]: Scene.DataWarehouse, - [Scene.AppMetrics]: Scene.Plugins, + [Scene.AppMetrics]: Scene.Apps, [Scene.ReplaySingle]: Scene.Replay, [Scene.ReplayPlaylist]: Scene.ReplayPlaylist, } @@ -278,13 +280,28 @@ export const sceneLogic = kea({ } else if ( teamLogic.values.currentTeam && !teamLogic.values.currentTeam.is_demo && - !teamLogic.values.currentTeam.completed_snippet_onboarding && !location.pathname.startsWith('/ingestion') && + !location.pathname.startsWith('/onboarding') && + !location.pathname.startsWith('/products') && !location.pathname.startsWith('/project/settings') ) { - console.warn('Ingestion tutorial not completed, redirecting to it') - router.actions.replace(urls.ingestion()) - return + if ( + featureFlagLogic.values.featureFlags[FEATURE_FLAGS.PRODUCT_SPECIFIC_ONBOARDING] === + 'test' && + !Object.keys(teamLogic.values.currentTeam.has_completed_onboarding_for || {}).length + ) { + console.warn('No onboarding completed, redirecting to products') + router.actions.replace(urls.products()) + return + } else if ( + featureFlagLogic.values.featureFlags[FEATURE_FLAGS.PRODUCT_SPECIFIC_ONBOARDING] !== + 'test' && + !teamLogic.values.currentTeam.completed_snippet_onboarding + ) { + console.warn('Ingestion tutorial not completed, redirecting to it') + router.actions.replace(urls.ingestion()) + return + } } } } diff --git a/frontend/src/scenes/sceneTypes.ts b/frontend/src/scenes/sceneTypes.ts index a1493f859b5a6..60ab9f17c9f01 100644 --- a/frontend/src/scenes/sceneTypes.ts +++ b/frontend/src/scenes/sceneTypes.ts @@ -58,7 +58,7 @@ export enum Scene { MySettings = 'MySettings', Annotations = 'Annotations', Billing = 'Billing', - Plugins = 'Plugins', + Apps = 'Apps', FrontendAppScene = 'FrontendAppScene', AppMetrics = 'AppMetrics', SavedInsights = 'SavedInsights', diff --git a/frontend/src/scenes/scenes.ts b/frontend/src/scenes/scenes.ts index 6b9808a1be848..3394fff423995 100644 --- a/frontend/src/scenes/scenes.ts +++ b/frontend/src/scenes/scenes.ts @@ -192,7 +192,7 @@ export const sceneConfigurations: Partial> = { projectBased: true, name: 'Annotations', }, - [Scene.Plugins]: { + [Scene.Apps]: { projectBased: true, name: 'Apps', }, @@ -440,10 +440,10 @@ export const routes: Record = { [urls.annotation(':id')]: Scene.Annotations, [urls.projectHomepage()]: Scene.ProjectHomepage, [urls.projectSettings()]: Scene.ProjectSettings, - [urls.projectApps()]: Scene.Plugins, - [urls.projectApp(':id')]: Scene.Plugins, - [urls.projectAppLogs(':id')]: Scene.Plugins, - [urls.projectAppSource(':id')]: Scene.Plugins, + [urls.projectApps()]: Scene.Apps, + [urls.projectApp(':id')]: Scene.Apps, + [urls.projectAppLogs(':id')]: Scene.Apps, + [urls.projectAppSource(':id')]: Scene.Apps, [urls.frontendApp(':id')]: Scene.FrontendAppScene, [urls.appMetrics(':pluginConfigId')]: Scene.AppMetrics, [urls.appHistoricalExports(':pluginConfigId')]: Scene.AppMetrics, diff --git a/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.tsx b/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.tsx index 2ef95db9eb3e7..4c50914345916 100644 --- a/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.tsx +++ b/frontend/src/scenes/session-recordings/player/PlayerFrameOverlay.tsx @@ -10,6 +10,7 @@ import './PlayerFrameOverlay.scss' import { PlayerUpNext } from './PlayerUpNext' import { useState } from 'react' import clsx from 'clsx' +import { getCurrentExporterData } from '~/exporter/exporterViewLogic' export interface PlayerFrameOverlayProps extends SessionRecordingPlayerLogicProps { nextSessionRecording?: Partial @@ -23,6 +24,7 @@ const PlayerFrameOverlayContent = ({ let content = null const pausedState = currentPlayerState === SessionPlayerState.PAUSE || currentPlayerState === SessionPlayerState.READY + const isInExportContext = !!getCurrentExporterData() if (currentPlayerState === SessionPlayerState.ERROR) { content = ( @@ -68,7 +70,10 @@ const PlayerFrameOverlayContent = ({ } return content ? (
{content} diff --git a/frontend/src/scenes/surveys/Survey.tsx b/frontend/src/scenes/surveys/Survey.tsx index 0dd3b5a4667f7..65f9af689976d 100644 --- a/frontend/src/scenes/surveys/Survey.tsx +++ b/frontend/src/scenes/surveys/Survey.tsx @@ -1,5 +1,5 @@ import { SceneExport } from 'scenes/sceneTypes' -import { NewSurvey, defaultSurveyAppearance, defaultSurveyFieldValues, surveyLogic } from './surveyLogic' +import { surveyLogic } from './surveyLogic' import { BindLogic, useActions, useValues } from 'kea' import { Form, Group } from 'kea-forms' import { PageHeader } from 'lib/components/PageHeader' @@ -31,6 +31,7 @@ import { SurveyAppearance } from './SurveyAppearance' import { SurveyAPIEditor } from './SurveyAPIEditor' import { featureFlagLogic as enabledFeaturesLogic } from 'lib/logic/featureFlagLogic' import { featureFlagLogic } from 'scenes/feature-flags/featureFlagLogic' +import { defaultSurveyFieldValues, defaultSurveyAppearance, SurveyQuestionLabel, NewSurvey } from './constants' import { FEATURE_FLAGS } from 'lib/constants' import { FeatureFlagReleaseConditions } from 'scenes/feature-flags/FeatureFlagReleaseConditions' @@ -139,17 +140,26 @@ export function SurveyForm({ id }: { id: string }): JSX.Element { ) }} options={[ - { label: 'Open text', value: SurveyQuestionType.Open }, - { label: 'Link', value: SurveyQuestionType.Link }, - { label: 'Rating', value: SurveyQuestionType.Rating }, + { + label: SurveyQuestionLabel[SurveyQuestionType.Open], + value: SurveyQuestionType.Open, + }, + { + label: SurveyQuestionLabel[SurveyQuestionType.Link], + value: SurveyQuestionType.Link, + }, + { + label: SurveyQuestionLabel[SurveyQuestionType.Rating], + value: SurveyQuestionType.Rating, + }, ...(featureFlags[FEATURE_FLAGS.SURVEYS_MULTIPLE_CHOICE] ? [ { - label: 'Single choice select', + label: SurveyQuestionLabel[SurveyQuestionType.SingleChoice], value: SurveyQuestionType.SingleChoice, }, { - label: 'Multiple choice select', + label: SurveyQuestionLabel[SurveyQuestionType.MultipleChoice], value: SurveyQuestionType.MultipleChoice, }, ] diff --git a/frontend/src/scenes/surveys/SurveyAPIEditor.tsx b/frontend/src/scenes/surveys/SurveyAPIEditor.tsx index 192d35c73b2b1..02fc80e8708ba 100644 --- a/frontend/src/scenes/surveys/SurveyAPIEditor.tsx +++ b/frontend/src/scenes/surveys/SurveyAPIEditor.tsx @@ -1,5 +1,5 @@ import { Survey } from '~/types' -import { NewSurvey } from './surveyLogic' +import { NewSurvey } from './constants' import { CodeSnippet, Language } from 'lib/components/CodeSnippet' export function SurveyAPIEditor({ survey }: { survey: Survey | NewSurvey }): JSX.Element { diff --git a/frontend/src/scenes/surveys/SurveyAppearance.scss b/frontend/src/scenes/surveys/SurveyAppearance.scss index d7735f2536ef4..8d95b73c4341d 100644 --- a/frontend/src/scenes/surveys/SurveyAppearance.scss +++ b/frontend/src/scenes/surveys/SurveyAppearance.scss @@ -1,233 +1,245 @@ .survey-form { + margin: 0px; color: black; font-weight: normal; font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; text-align: left; - z-index: 99999; //changeable: zIndex + width: 320px; + border-bottom: 0px; flex-direction: column; - background: white; //changeable: backgroundColor - border: 1px solid #f0f0f0; - border-radius: 8px; - padding-top: 5px; - max-width: 320px; //changeable: maxWidth box-shadow: -6px 0 16px -8px rgb(0 0 0 / 8%), -9px 0 28px 0 rgb(0 0 0 / 5%), -12px 0 48px 16px rgb(0 0 0 / 3%); + border-radius: 10px; + line-height: 1.4; + position: relative; + box-sizing: border-box; +} +.form-submit[disabled] { + opacity: 0.6; + filter: grayscale(100%); + cursor: not-allowed; +} +.survey-form textarea { + color: #2d2d2d; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif, + 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; + background: white; + color: black; + outline: none; + padding-left: 10px; + padding-right: 10px; + padding-top: 10px; + border-radius: 6px; + margin-top: 14px; +} +.form-submit { + box-sizing: border-box; + margin: 0; + font-family: inherit; + overflow: visible; + text-transform: none; + position: relative; + display: inline-block; + font-weight: 700; + white-space: nowrap; + text-align: center; + border: 1.5px solid transparent; + cursor: pointer; + user-select: none; + touch-action: manipulation; + padding: 12px; + font-size: 14px; + border-radius: 6px; + outline: 0; + text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12); + box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045); + width: 100%; +} +.form-cancel { + float: right; + border: none; + background: none; + cursor: pointer; +} +.cancel-btn-wrapper { + position: absolute; + width: 35px; + height: 35px; + border-radius: 100%; + top: 0; + right: 0; + transform: translate(50%, -50%); + background: white; + display: flex; + justify-content: center; + align-items: center; +} +.bolded { + font-weight: 600; +} +.buttons { + display: flex; + justify-content: center; +} +.footer-branding { + font-size: 11px; + margin-top: 10px; + text-align: center; + display: flex; + justify-content: center; + gap: 4px; + align-items: center; + font-weight: 500; + text-decoration: none; + color: inherit !important; +} +.survey-box { + padding: 20px 25px 10px; + display: flex; + flex-direction: column; +} +.survey-question { + font-weight: 500; + font-size: 14px; +} +.question-textarea-wrapper { + display: flex; + flex-direction: column; +} +.description { + font-size: 13px; + margin-top: 5px; + opacity: 0.6; +} +.ratings-number { + font-size: 14px; + padding: 8px 0px; + border: none; +} +.ratings-number:hover { + cursor: pointer; +} +.rating-options { + margin-top: 14px; +} +.rating-options-buttons { + display: grid; + border-radius: 6px; + overflow: hidden; +} +.rating-options-buttons > .ratings-number { + border-right: 1px solid; +} +.rating-options-buttons > .ratings-number:last-of-type { + border-right: 0px !important; +} +.rating-options-emoji { + display: flex; + justify-content: space-between; +} +.ratings-emoji { + font-size: 16px; + background-color: transparent; + border: none; + padding: 0px; +} +.ratings-emoji:hover { + cursor: pointer; +} +.rating-text { + display: flex; + flex-direction: row; + font-size: 11px; + justify-content: space-between; + margin-top: 6px; + opacity: 0.6; +} +.multiple-choice-options { + margin-top: 13px; + font-size: 14px; +} +.multiple-choice-options .choice-option { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + cursor: pointer; + margin-bottom: 5px; + position: relative; +} +.multiple-choice-options > .choice-option:last-of-type { + margin-bottom: 0px; +} - .button { - width: 64px; - height: 64px; - border-radius: 100%; - text-align: center; - line-height: 60px; - font-size: 32px; - border: none; - cursor: pointer; - } - .button:hover { - filter: brightness(1.2); - } - .form-submit[disabled] { - opacity: 0.6; - filter: grayscale(100%); - cursor: not-allowed; - } - - .survey-box { - padding: 0.5rem 1rem; - display: flex; - flex-direction: column; - } - - .survey-question { - padding-top: 4px; - padding-bottom: 4px; - font-size: 16px; - font-weight: 500; - color: black; //changeable: textColor - } - - .question-textarea-wrapper { - display: flex; - flex-direction: column; - padding-bottom: 4px; - } - - .survey-textarea { - color: #2d2d2d; - font-size: 14px; - font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; - background: white; - color: black; - border: 1px solid; - padding-left: 10px; - padding-right: 10px; - padding-top: 10px; - border-radius: 6px; - margin: 0.5rem; - } - - .buttons { - display: flex; - justify-content: center; - } - - .form-submit { - box-sizing: border-box; - margin: 0; - font-family: inherit; - overflow: visible; - text-transform: none; - line-height: 1.5715; - position: relative; - display: inline-block; - font-weight: 400; - white-space: nowrap; - text-align: center; - border: 1px solid transparent; - cursor: pointer; - transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); - user-select: none; - touch-action: manipulation; - height: 32px; - padding: 4px 15px; - font-size: 14px; - border-radius: 4px; - outline: 0; - background: #2c2c2c; // changeable: submitButtonColor - color: #e5e7e0; - text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12); - box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045); - } - - .form-submit:hover { - filter: brightness(1.2); - } - - .form-cancel { - background: white; // changeable: backgroundColor - float: right; - border: none; - cursor: pointer; - } - - .bottom-section { - padding-bottom: 0.5rem; - } - - .description { - font-size: 14px; - margin-top: 0.5rem; - margin-bottom: 0.5rem; - color: #4b4b52; //changeable: descriptionTextColor - } - .rating-options { - margin-top: 0.5rem; - } - .ratings-number { - background-color: #e0e2e8; - font-size: 14px; - border-radius: 6px; - border: 1px solid #e0e2e8; - padding: 8px; - } - .ratings-number:hover { - cursor: pointer; - filter: brightness(0.75); - } - .rating-options-buttons { - display: flex; - justify-content: space-evenly; - } - .max-numbers { - min-width: 280px; - } - .rating-options-emoji { - display: flex; - justify-content: space-evenly; - } - .ratings-emoji { - font-size: 16px; - background-color: transparent; - border: none; - } - .ratings-emoji:hover { - cursor: pointer; - fill: coral; //changeable: ratingButtonHoverColor - } - .rating-text { - display: flex; - flex-direction: row; - font-size: 12px; - justify-content: space-between; - margin-top: 0.5rem; - margin-bottom: 0.5rem; - color: #4b4b52; - } - .rating-section { - margin-bottom: 0.5rem; - } - .multiple-choice-options { - margin-bottom: 0.5rem; - margin-top: 0.5rem; - font-size: 14px; - } - .multiple-choice-options .choice-option { - display: flex; - align-items: center; - gap: 4px; - background: #00000003; - font-size: 14px; - padding: 10px 20px 10px 15px; - border: 1px solid #0000000d; - border-radius: 4px; - cursor: pointer; - margin-bottom: 6px; - } - .multiple-choice-options .choice-option:hover { - background: #0000000a; - } - .multiple-choice-options input { - cursor: pointer; - } - .multiple-choice-options label { - width: 100%; - cursor: pointer; - } +.multiple-choice-options input { + cursor: pointer; + position: absolute; + opacity: 0; + width: 100%; + height: 100%; + inset: 0; +} +.choice-check { + position: absolute; + right: 10px; + background: white; +} +.choice-check svg { + display: none; +} +.multiple-choice-options .choice-option:hover .choice-check svg { + display: inline-block; + opacity: 0.25; +} +.multiple-choice-options input:checked + label + .choice-check svg { + display: inline-block; + opacity: 100% !important; +} +.multiple-choice-options input[type='checkbox']:checked + label { + font-weight: bold; +} +.multiple-choice-options input:checked + label { + border: 1.5px solid rgba(0, 0, 0); +} +.multiple-choice-options label { + width: 100%; + cursor: pointer; + padding: 10px; + border: 1.5px solid rgba(0, 0, 0, 0.25); + border-radius: 4px; + background: white; } .thank-you-message { - border-radius: 8px; - z-index: 99999; + position: relative; + bottom: 0px; box-shadow: -6px 0 16px -8px rgb(0 0 0 / 8%), -9px 0 28px 0 rgb(0 0 0 / 5%), -12px 0 48px 16px rgb(0 0 0 / 3%); font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; - .thank-you-message-container { - background: white; - border: 1px solid #f0f0f0; - border-radius: 8px; - padding: 12px 18px; - text-align: center; - max-width: 320px; - min-width: 150px; - } - .thank-you-message-header { - font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', 'Roboto', Helvetica, Arial, sans-serif, - 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; - font-weight: 600; - } - .thank-you-message { - color: black; - } - .thank-you-message-body { - padding-bottom: 8px; - font-size: 14px; - color: #4b4b52; - } -} - -.footer-branding { - color: #6a6b69; - font-size: 10.5px; - padding-top: 0.5rem; + border-radius: 10px; + padding: 20px 25px 10px; text-align: center; + width: 320px; + min-width: 150px; + line-height: 1.4; + box-sizing: border-box; +} +.thank-you-message-body { + margin-top: 6px; + font-size: 14px; +} +.thank-you-message-header { + margin: 10px 0px 0px; + font-weight: bold; + font-size: 19px; +} +.thank-you-message-container .form-submit { + margin-top: 20px; + margin-bottom: 10px; +} +.thank-you-message-countdown { + margin-left: 6px; +} +.bottom-section { + margin-top: 14px; } diff --git a/frontend/src/scenes/surveys/SurveyAppearance.tsx b/frontend/src/scenes/surveys/SurveyAppearance.tsx index d46e8e02daaee..c8643df296bef 100644 --- a/frontend/src/scenes/surveys/SurveyAppearance.tsx +++ b/frontend/src/scenes/surveys/SurveyAppearance.tsx @@ -1,5 +1,5 @@ import './SurveyAppearance.scss' -import { LemonCheckbox, LemonInput } from '@posthog/lemon-ui' +import { LemonButton, LemonCheckbox, LemonInput } from '@posthog/lemon-ui' import { SurveyAppearance as SurveyAppearanceType, SurveyQuestion, @@ -7,9 +7,12 @@ import { SurveyQuestionType, MultipleSurveyQuestion, } from '~/types' -import { defaultSurveyAppearance } from './surveyLogic' +import { defaultSurveyAppearance } from './constants' import { + cancel, + check, dissatisfiedEmoji, + getTextColor, neutralEmoji, posthogLogoSVG, satisfiedEmoji, @@ -18,8 +21,9 @@ import { } from './SurveyAppearanceUtils' import { surveysLogic } from './surveysLogic' import { useValues } from 'kea' -import { IconClose } from 'lib/lemon-ui/icons' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { FEATURE_FLAGS } from 'lib/constants' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' interface SurveyAppearanceProps { type: SurveyQuestionType @@ -31,6 +35,46 @@ interface SurveyAppearanceProps { readOnly?: boolean onAppearanceChange: (appearance: SurveyAppearanceType) => void } + +const Button = ({ + link, + type, + onSubmit, + appearance, + children, +}: { + link?: string | null + type?: SurveyQuestionType + onSubmit: () => void + appearance: SurveyAppearanceType + children: React.ReactNode +}): JSX.Element => { + const [textColor, setTextColor] = useState('black') + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { + const textColor = getTextColor(ref.current) + setTextColor(textColor) + } + }, [appearance.submitButtonColor]) + + return ( + + ) +} + export function SurveyAppearance({ type, question, @@ -42,16 +86,15 @@ export function SurveyAppearance({ onAppearanceChange, }: SurveyAppearanceProps): JSX.Element { const { whitelabelAvailable } = useValues(surveysLogic) + const { featureFlags } = useValues(featureFlagLogic) const [showThankYou, setShowThankYou] = useState(false) const [hideSubmittedSurvey, setHideSubmittedSurvey] = useState(false) useEffect(() => { if (appearance.displayThankYouMessage && showThankYou) { setHideSubmittedSurvey(true) - setTimeout(() => { - setShowThankYou(false) - setHideSubmittedSurvey(false) - }, 2000) + } else { + setHideSubmittedSurvey(false) } }, [showThankYou]) @@ -92,7 +135,7 @@ export function SurveyAppearance({ )} )} - {showThankYou && } + {showThankYou && } {!readOnly && (
Background color
@@ -100,16 +143,30 @@ export function SurveyAppearance({ value={appearance?.backgroundColor} onChange={(backgroundColor) => onAppearanceChange({ ...appearance, backgroundColor })} /> -
Question text color
- onAppearanceChange({ ...appearance, textColor })} - /> -
Description text color
+
Border color
onAppearanceChange({ ...appearance, descriptionTextColor })} + value={appearance?.borderColor} + onChange={(borderColor) => onAppearanceChange({ ...appearance, borderColor })} /> + {featureFlags[FEATURE_FLAGS.SURVEYS_POSITIONS] && ( + <> +
Position
+
+ {['left', 'center', 'right'].map((position) => { + return ( + onAppearanceChange({ ...appearance, position })} + active={appearance.position === position} + > + {position} + + ) + })} +
+ + )} {surveyQuestionItem.type === SurveyQuestionType.Rating && ( <>
Rating button color
@@ -119,37 +176,31 @@ export function SurveyAppearance({ onAppearanceChange({ ...appearance, ratingButtonColor }) } /> - {surveyQuestionItem.display === 'emoji' && ( - <> -
Rating button hover color
- - onAppearanceChange({ ...appearance, ratingButtonHoverColor }) - } - /> - - )} - - )} - {[ - SurveyQuestionType.Open, - SurveyQuestionType.Link, - SurveyQuestionType.SingleChoice, - SurveyQuestionType.MultipleChoice, - ].includes(type) && ( - <> -
Button color
+
Rating button active color
- onAppearanceChange({ ...appearance, submitButtonColor }) + value={appearance?.ratingButtonActiveColor} + onChange={(ratingButtonActiveColor) => + onAppearanceChange({ ...appearance, ratingButtonActiveColor }) } /> -
Button text
+ + )} +
Button color
+ onAppearanceChange({ ...appearance, submitButtonColor })} + /> +
Button text
+ onAppearanceChange({ ...appearance, submitButtonText })} + /> + {surveyQuestionItem.type === SurveyQuestionType.Open && ( + <> +
Placeholder
onAppearanceChange({ ...appearance, submitButtonText })} + value={appearance?.placeholder || defaultSurveyAppearance.placeholder} + onChange={(placeholder) => onAppearanceChange({ ...appearance, placeholder })} /> )} @@ -188,54 +239,163 @@ function BaseAppearance({ description?: string | null link?: string | null }): JSX.Element { + const [textColor, setTextColor] = useState('black') + const ref = useRef(null) + + useEffect(() => { + if (ref.current) { + const textColor = getTextColor(ref.current) + setTextColor(textColor) + } + }, [appearance.backgroundColor]) + return ( -
+
-
-
-
- {question} -
- {description && ( -
- {description} -
- )} +
{question}
+ {description &&
{description}
} {type === SurveyQuestionType.Open && ( -