Skip to content

Commit

Permalink
feat(Experiments): Allow users to edit and preview no-code experiment…
Browse files Browse the repository at this point in the history
…s from experiment page. (#25877)

This PR instead adds a custom implementation section to the experiment page that allows a user to bring up the toolbar with the experiment pre-selected. It also adds a preview variant button to each variant of the web experiment.

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Juraj Majerik <[email protected]>
Co-authored-by: Ross <[email protected]>
  • Loading branch information
4 people authored Nov 4, 2024
1 parent 456466b commit 69d2f2d
Show file tree
Hide file tree
Showing 31 changed files with 537 additions and 106 deletions.
14 changes: 14 additions & 0 deletions ee/clickhouse/views/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ class Meta:
"created_by",
"created_at",
"updated_at",
"type",
"metrics",
]
read_only_fields = [
Expand Down Expand Up @@ -338,6 +339,19 @@ def create(self, validated_data: dict, *args: Any, **kwargs: Any) -> Experiment:
team_id=self.context["team_id"], feature_flag=feature_flag, **validated_data
)

# if this is a web experiment, copy over the variant data to the experiment itself.
if validated_data.get("type", "") == "web":
web_variants = {}
ff_variants = variants or default_variants

for variant in ff_variants:
web_variants[variant.get("key")] = {
"rollout_percentage": variant.get("rollout_percentage"),
}

experiment.variants = web_variants
experiment.save()

if saved_metrics_data:
for saved_metric_data in saved_metrics_data:
saved_metric_serializer = ExperimentToSavedMetricSerializer(
Expand Down
55 changes: 55 additions & 0 deletions ee/clickhouse/views/test/test_clickhouse_experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from ee.api.test.base import APILicensedTest
from dateutil import parser

from posthog.models import WebExperiment
from posthog.models.action.action import Action
from posthog.models.cohort.cohort import Cohort
from posthog.models.experiment import Experiment
Expand Down Expand Up @@ -123,6 +125,59 @@ def test_creating_updating_basic_experiment(self):
self.assertEqual(experiment.description, "Bazinga")
self.assertEqual(experiment.end_date.strftime("%Y-%m-%dT%H:%M"), end_date)

def test_creating_updating_web_experiment(self):
ff_key = "a-b-tests"
response = self.client.post(
f"/api/projects/{self.team.id}/experiments/",
{
"name": "Test Experiment",
"type": "web",
"description": "",
"start_date": "2021-12-01T10:23",
"end_date": None,
"feature_flag_key": ff_key,
"parameters": None,
"filters": {
"events": [
{"order": 0, "id": "$pageview"},
{"order": 1, "id": "$pageleave"},
],
"properties": [],
},
},
)

self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.json()["name"], "Test Experiment")
self.assertEqual(response.json()["feature_flag_key"], ff_key)
web_experiment_id = response.json()["id"]
self.assertEqual(
WebExperiment.objects.get(pk=web_experiment_id).variants,
{"test": {"rollout_percentage": 50}, "control": {"rollout_percentage": 50}},
)

created_ff = FeatureFlag.objects.get(key=ff_key)

self.assertEqual(created_ff.key, ff_key)
self.assertEqual(created_ff.filters["multivariate"]["variants"][0]["key"], "control")
self.assertEqual(created_ff.filters["multivariate"]["variants"][1]["key"], "test")
self.assertEqual(created_ff.filters["groups"][0]["properties"], [])

id = response.json()["id"]
end_date = "2021-12-10T00:00"

# Now update
response = self.client.patch(
f"/api/projects/{self.team.id}/experiments/{id}",
{"description": "Bazinga", "end_date": end_date},
)

self.assertEqual(response.status_code, status.HTTP_200_OK)

experiment = Experiment.objects.get(pk=id)
self.assertEqual(experiment.description, "Bazinga")
self.assertEqual(experiment.end_date.strftime("%Y-%m-%dT%H:%M"), end_date)

def test_transferring_holdout_to_another_group(self):
response = self.client.post(
f"/api/projects/{self.team.id}/experiment_holdouts/",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 4 additions & 3 deletions frontend/src/layout/navigation-3000/sidebars/toolbar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { subscriptions } from 'kea-subscriptions'
import {
authorizedUrlListLogic,
AuthorizedUrlListType,
defaultAuthorizedUrlProperties,
KeyedAppUrl,
validateProposedUrl,
} from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
Expand All @@ -29,13 +30,13 @@ export const toolbarSidebarLogic = kea<toolbarSidebarLogicType>([
path(['layout', 'navigation-3000', 'sidebars', 'toolbarSidebarLogic']),
connect(() => ({
values: [
authorizedUrlListLogic({ actionId: null, type: AuthorizedUrlListType.TOOLBAR_URLS }),
authorizedUrlListLogic({ ...defaultAuthorizedUrlProperties, type: AuthorizedUrlListType.TOOLBAR_URLS }),
['urlsKeyed', 'suggestionsLoading', 'launchUrl'],
sceneLogic,
['activeScene', 'sceneParams'],
],
actions: [
authorizedUrlListLogic({ actionId: null, type: AuthorizedUrlListType.TOOLBAR_URLS }),
authorizedUrlListLogic({ ...defaultAuthorizedUrlProperties, type: AuthorizedUrlListType.TOOLBAR_URLS }),
['addUrl', 'removeUrl', 'updateUrl'],
],
})),
Expand All @@ -49,7 +50,7 @@ export const toolbarSidebarLogic = kea<toolbarSidebarLogicType>([
loading: suggestionsLoading,
onAdd: async (url) => {
await authorizedUrlListLogic({
actionId: null,
...defaultAuthorizedUrlProperties,
type: AuthorizedUrlListType.TOOLBAR_URLS,
}).asyncActions.addUrl(url)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { LemonInput } from 'lib/lemon-ui/LemonInput/LemonInput'
import { LemonTag } from 'lib/lemon-ui/LemonTag/LemonTag'
import { Spinner } from 'lib/lemon-ui/Spinner/Spinner'

import { ExperimentIdType } from '~/types'

import { authorizedUrlListLogic, AuthorizedUrlListType } from './authorizedUrlListLogic'

function EmptyState({
Expand Down Expand Up @@ -45,14 +47,19 @@ function EmptyState({
) : null
}

function AuthorizedUrlForm({ actionId, type }: AuthorizedUrlListProps): JSX.Element {
const logic = authorizedUrlListLogic({ actionId: actionId ?? null, type })
function AuthorizedUrlForm({ actionId, experimentId, query, type }: AuthorizedUrlListProps): JSX.Element {
const logic = authorizedUrlListLogic({
actionId: actionId ?? null,
experimentId: experimentId ?? null,
query: query,
type,
})
const { isProposedUrlSubmitting } = useValues(logic)
const { cancelProposingUrl } = useActions(logic)
return (
<Form
logic={authorizedUrlListLogic}
props={{ actionId, type }}
props={{ actionId, type, experimentId, query }}
formKey="proposedUrl"
enableFormOnSubmit
className="w-full space-y-2"
Expand All @@ -78,15 +85,24 @@ function AuthorizedUrlForm({ actionId, type }: AuthorizedUrlListProps): JSX.Elem

export interface AuthorizedUrlListProps {
actionId?: number
experimentId?: ExperimentIdType
query?: string | null
type: AuthorizedUrlListType
}

export function AuthorizedUrlList({
actionId,
experimentId,
query,
type,
addText = 'Add',
}: AuthorizedUrlListProps & { addText?: string }): JSX.Element {
const logic = authorizedUrlListLogic({ actionId: actionId ?? null, type })
const logic = authorizedUrlListLogic({
experimentId: experimentId ?? null,
actionId: actionId ?? null,
type,
query,
})
const {
urlsKeyed,
suggestionsLoading,
Expand Down Expand Up @@ -119,7 +135,12 @@ export function AuthorizedUrlList({
<div className="space-y-2">
{isAddUrlFormVisible && (
<div className="border rounded p-2 bg-bg-light">
<AuthorizedUrlForm type={type} actionId={actionId} />
<AuthorizedUrlForm
type={type}
actionId={actionId}
experimentId={experimentId}
query={query}
/>
</div>
)}
<EmptyState
Expand Down Expand Up @@ -162,7 +183,7 @@ export function AuthorizedUrlList({
type === AuthorizedUrlListType.TOOLBAR_URLS
? launchUrl(keyedURL.url)
: // other urls are simply opened directly
keyedURL.url
keyedURL.url + query
}
targetBlank
tooltip={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ describe('the authorized urls list logic', () => {
logic = authorizedUrlListLogic({
type: AuthorizedUrlListType.TOOLBAR_URLS,
actionId: null,
experimentId: null,
query: null,
})
logic.mount()
})
Expand Down Expand Up @@ -119,6 +121,8 @@ describe('the authorized urls list logic', () => {
logic = authorizedUrlListLogic({
type: AuthorizedUrlListType.RECORDING_DOMAINS,
actionId: null,
experimentId: null,
query: null,
})
logic.mount()
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { urls } from 'scenes/urls'

import { HogQLQuery, NodeKind } from '~/queries/schema'
import { hogql } from '~/queries/utils'
import { ToolbarParams, ToolbarUserIntent } from '~/types'
import { ExperimentIdType, ToolbarParams, ToolbarUserIntent } from '~/types'

import type { authorizedUrlListLogicType } from './authorizedUrlListLogicType'

Expand All @@ -35,6 +35,7 @@ export interface ProposeNewUrlFormType {
export enum AuthorizedUrlListType {
TOOLBAR_URLS = 'TOOLBAR_URLS',
RECORDING_DOMAINS = 'RECORDING_DOMAINS',
WEB_EXPERIMENTS = 'WEB_EXPERIMENTS',
}

/**
Expand Down Expand Up @@ -90,19 +91,22 @@ export const validateProposedUrl = (
/** defaultIntent: whether to launch with empty intent (i.e. toolbar mode is default) */
export function appEditorUrl(
appUrl: string,
options?: { actionId?: number | null; userIntent?: ToolbarUserIntent }
options?: { actionId?: number | null; experimentId?: ExperimentIdType; userIntent?: ToolbarUserIntent }
): string {
// See https://github.com/PostHog/posthog-js/blob/f7119c/src/extensions/toolbar.ts#L52 for where these params
// are passed. `appUrl` is an extra `redirect_to_site` param.
const params: ToolbarParams & { appUrl: string } = {
userIntent: options?.userIntent ?? (options?.actionId ? 'edit-action' : 'add-action'),
userIntent:
options?.userIntent ??
(options?.actionId ? 'edit-action' : options?.experimentId ? 'edit-experiment' : 'add-action'),
// Make sure to pass the app url, otherwise the api_host will be used by
// the toolbar, which isn't correct when used behind a reverse proxy as
// we require e.g. SSO login to the app, which will not work when placed
// behind a proxy unless we register each domain with the OAuth2 client.
apiURL: apiHostOrigin(),
appUrl,
...(options?.actionId ? { actionId: options.actionId } : {}),
...(options?.experimentId ? { experimentId: options.experimentId } : {}),
}
return '/api/user/redirect_to_site/' + encodeParams(params, '?')
}
Expand Down Expand Up @@ -164,11 +168,20 @@ export interface KeyedAppUrl {

export interface AuthorizedUrlListLogicProps {
actionId: number | null
experimentId: ExperimentIdType | null
type: AuthorizedUrlListType
query: string | null | undefined
}

export const defaultAuthorizedUrlProperties = {
actionId: null,
experimentId: null,
query: null,
}

export const authorizedUrlListLogic = kea<authorizedUrlListLogicType>([
path((key) => ['lib', 'components', 'AuthorizedUrlList', 'authorizedUrlListLogic', key]),
key((props) => `${props.type}-${props.actionId}`),
key((props) => (props.experimentId ? `${props.type}-${props.experimentId}` : `${props.type}-${props.actionId}`)),
props({} as AuthorizedUrlListLogicProps),
connect({
values: [teamLogic, ['currentTeam', 'currentTeamId']],
Expand Down Expand Up @@ -372,11 +385,18 @@ export const authorizedUrlListLogic = kea<authorizedUrlListLogicType>([
},
],
launchUrl: [
(_, p) => [p.actionId],
(actionId) => (url: string) =>
appEditorUrl(url, {
(_, p) => [p.actionId, p.experimentId],
(actionId, experimentId) => (url: string) => {
if (experimentId) {
return appEditorUrl(url, {
experimentId,
})
}

return appEditorUrl(url, {
actionId,
}),
})
},
],
isAddUrlFormVisible: [(s) => [s.editUrlIndex], (editUrlIndex) => editUrlIndex === -1],
onlyAllowDomains: [(_, p) => [p.type], (type) => type === AuthorizedUrlListType.RECORDING_DOMAINS],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { actions, afterMount, beforeUnmount, connect, kea, listeners, path, props, reducers, selectors } from 'kea'
import { authorizedUrlListLogic, AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
import {
authorizedUrlListLogic,
AuthorizedUrlListType,
defaultAuthorizedUrlProperties,
} from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic'
import { CommonFilters, HeatmapFilters, HeatmapFixedPositionMode } from 'lib/components/heatmaps/types'
import {
calculateViewportRange,
Expand Down Expand Up @@ -45,13 +49,13 @@ export const iframedToolbarBrowserLogic = kea<iframedToolbarBrowserLogicType>([

connect({
values: [
authorizedUrlListLogic({ actionId: null, type: AuthorizedUrlListType.TOOLBAR_URLS }),
authorizedUrlListLogic({ ...defaultAuthorizedUrlProperties, type: AuthorizedUrlListType.TOOLBAR_URLS }),
['urlsKeyed', 'checkUrlIsAuthorized'],
teamLogic,
['currentTeam'],
],
actions: [
authorizedUrlListLogic({ actionId: null, type: AuthorizedUrlListType.TOOLBAR_URLS }),
authorizedUrlListLogic({ ...defaultAuthorizedUrlProperties, type: AuthorizedUrlListType.TOOLBAR_URLS }),
['addUrl'],
teamLogic,
['updateCurrentTeamSuccess'],
Expand Down
9 changes: 5 additions & 4 deletions frontend/src/lib/utils/eventUsageLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
DashboardType,
EntityType,
Experiment,
ExperimentIdType,
FilterLogicalOperator,
FunnelCorrelation,
HelpType,
Expand Down Expand Up @@ -480,10 +481,10 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
}),
reportExperimentInsightLoadFailed: true,
reportExperimentVariantShipped: (experiment: Experiment) => ({ experiment }),
reportExperimentVariantScreenshotUploaded: (experimentId: number | 'new') => ({ experimentId }),
reportExperimentResultsLoadingTimeout: (experimentId: number | 'new') => ({ experimentId }),
reportExperimentReleaseConditionsViewed: (experimentId: number | 'new') => ({ experimentId }),
reportExperimentReleaseConditionsUpdated: (experimentId: number | 'new') => ({ experimentId }),
reportExperimentVariantScreenshotUploaded: (experimentId: ExperimentIdType) => ({ experimentId }),
reportExperimentResultsLoadingTimeout: (experimentId: ExperimentIdType) => ({ experimentId }),
reportExperimentReleaseConditionsViewed: (experimentId: ExperimentIdType) => ({ experimentId }),
reportExperimentReleaseConditionsUpdated: (experimentId: ExperimentIdType) => ({ experimentId }),

// Definition Popover
reportDataManagementDefinitionHovered: (type: TaxonomicFilterGroupType) => ({ type }),
Expand Down
Loading

0 comments on commit 69d2f2d

Please sign in to comment.