feat(experiments): support moving experiment start date (#21362)

Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
This commit is contained in:
Nikita Vorobev
2024-04-18 15:08:08 +03:00
committed by GitHub
parent da361c07eb
commit 2530eaa7cb
7 changed files with 206 additions and 26 deletions

View File

@@ -1,3 +1,5 @@
import { decideResponse } from '../fixtures/api/decide'
describe('Experiments', () => {
let randomNum
let experimentName
@@ -8,25 +10,13 @@ describe('Experiments', () => {
fixture: 'api/experiments/user',
})
cy.intercept('/api/projects/*/experiments?limit=1000', {
fixture: 'api/experiments/experiments',
})
cy.intercept('/api/projects/*/experiments/1234/', {
fixture: 'api/experiments/new-experiment',
})
cy.intercept('POST', '/api/projects/1/experiments/', (req) => {
req.reply({ fixture: 'api/experiments/new-experiment' })
})
randomNum = Math.floor(Math.random() * 10000000)
experimentName = `Experiment ${randomNum}`
featureFlagKey = `experiment-${randomNum}`
cy.visit('/experiments')
})
it('create experiment', () => {
cy.visit('/experiments')
cy.get('[data-attr=top-bar-name]').should('contain', 'A/B testing')
// Name, flag key, description
@@ -76,4 +66,76 @@ describe('Experiments', () => {
// Save experiment
cy.get('[data-attr="save-experiment"]').first().click()
})
const createExperimentInNewUi = () => {
cy.intercept('**/decide/*', (req) =>
req.reply(
decideResponse({
'new-experiments-ui': true,
})
)
)
cy.visit('/experiments')
// Name, flag key, description
cy.get('[data-attr=create-experiment]').first().click()
cy.get('[data-attr=experiment-name]').click().type(`${experimentName}`).should('have.value', experimentName)
cy.get('[data-attr=experiment-feature-flag-key]')
.click()
.type(`${featureFlagKey}`)
.should('have.value', featureFlagKey)
cy.get('[data-attr=experiment-description]')
.click()
.type('This is the description of the experiment')
.should('have.value', 'This is the description of the experiment')
// Edit variants
cy.get('[data-attr="add-test-variant"]').click()
cy.get('input[data-attr="experiment-variant-key"][data-key-index="1"]')
.clear()
.type('test-variant-1')
.should('have.value', 'test-variant-1')
cy.get('input[data-attr="experiment-variant-key"][data-key-index="2"]')
.clear()
.type('test-variant-2')
.should('have.value', 'test-variant-2')
// Continue creation
cy.get('[data-attr="continue-experiment-creation"]').first().click()
// Save experiment
cy.get('[data-attr="save-experiment"]').first().click()
}
it('create, launch and stop experiment with new ui', () => {
createExperimentInNewUi()
cy.get('[data-attr="experiment-status"]').contains('draft').should('be.visible')
cy.get('[data-attr="experiment-creation-date"]').contains('a few seconds ago').should('be.visible')
cy.get('[data-attr="experiment-start-date"]').should('not.exist')
cy.get('[data-attr="launch-experiment"]').first().click()
cy.get('[data-attr="experiment-creation-date"]').should('not.exist')
cy.get('[data-attr="experiment-start-date"]').contains('a few seconds ago').should('be.visible')
cy.get('[data-attr="stop-experiment"]').first().click()
cy.get('[data-attr="experiment-creation-date"]').should('not.exist')
cy.get('[data-attr="experiment-start-date"]').contains('a few seconds ago').should('be.visible')
cy.get('[data-attr="experiment-end-date"]').contains('a few seconds ago').should('be.visible')
})
it('move start date', () => {
createExperimentInNewUi()
cy.get('[data-attr="launch-experiment"]').first().click()
cy.get('[data-attr="move-experiment-start-date"]').first().click()
cy.get('[data-attr="experiment-start-date-picker"]').clear().type('2020-01-01 00:00:00')
cy.get('.ant-picker-dropdown').contains('Ok').first().click()
cy.get('[data-attr="experiment-start-date"]').contains('years ago').should('be.visible')
cy.reload()
// Check that the start date persists
cy.get('[data-attr="experiment-start-date"]').contains('years ago').should('be.visible')
})
})

View File

@@ -386,6 +386,10 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
reportExperimentCreated: (experiment: Experiment) => ({ experiment }),
reportExperimentViewed: (experiment: Experiment) => ({ experiment }),
reportExperimentLaunched: (experiment: Experiment, launchDate: Dayjs) => ({ experiment, launchDate }),
reportExperimentStartDateChange: (experiment: Experiment, newStartDate: string) => ({
experiment,
newStartDate,
}),
reportExperimentCompleted: (
experiment: Experiment,
endDate: Dayjs,
@@ -978,6 +982,14 @@ export const eventUsageLogic = kea<eventUsageLogicType>([
launch_date: launchDate.toISOString(),
})
},
reportExperimentStartDateChange: ({ experiment, newStartDate }) => {
posthog.capture('experiment start date changed', {
name: experiment.name,
id: experiment.id,
old_start_date: experiment.start_date,
new_start_date: newStartDate,
})
},
reportExperimentCompleted: ({ experiment, endDate, duration, significant }) => {
posthog.capture('experiment completed', {
name: experiment.name,

View File

@@ -31,17 +31,20 @@ const StepInfo = (): JSX.Element => {
<div className="space-y-8">
<div className="space-y-6 max-w-120">
<LemonField name="name" label="Name">
<LemonInput placeholder="Pricing page conversion" />
<LemonInput placeholder="Pricing page conversion" data-attr="experiment-name" />
</LemonField>
<LemonField
name="feature_flag_key"
label="Feature flag key"
help="Each experiment is backed by a feature flag. You'll use this key in your code."
>
<LemonInput placeholder="pricing-page-conversion" />
<LemonInput placeholder="pricing-page-conversion" data-attr="experiment-feature-flag-key" />
</LemonField>
<LemonField name="description" label="Description">
<LemonTextArea placeholder="The goal of this experiment is ..." />
<LemonTextArea
placeholder="The goal of this experiment is ..."
data-attr="experiment-description"
/>
</LemonField>
</div>
<div className="mt-10">
@@ -132,7 +135,12 @@ const StepInfo = (): JSX.Element => {
</div>
</div>
</div>
<LemonButton className="mt-2" type="primary" onClick={() => moveToNextFormStep()}>
<LemonButton
className="mt-2"
type="primary"
data-attr="continue-experiment-creation"
onClick={() => moveToNextFormStep()}
>
Continue
</LemonButton>
</div>
@@ -255,6 +263,7 @@ const StepGoal = (): JSX.Element => {
<LemonButton
className="mt-2"
type="primary"
data-attr="save-experiment"
onClick={() => {
const { exposure, sampleSize } = exposureAndSampleSize
createExperiment(true, exposure, sampleSize)

View File

@@ -0,0 +1,85 @@
import { IconPencil } from '@posthog/icons'
import { LemonButton } from '@posthog/lemon-ui'
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { DatePicker } from 'lib/components/DatePicker'
import { TZLabel } from 'lib/components/TZLabel'
import { dayjs } from 'lib/dayjs'
import { useState } from 'react'
import { experimentLogic } from '../experimentLogic'
export function ExperimentDates(): JSX.Element {
const [isStartDatePickerOpen, setIsStartDatePickerOpen] = useState(false)
const { experiment } = useValues(experimentLogic)
const { changeExperimentStartDate } = useActions(experimentLogic)
const { created_at, start_date, end_date } = experiment
if (!start_date) {
if (!created_at) {
return <></>
}
return (
<div className="block" data-attr="experiment-creation-date">
<div className="text-xs font-semibold uppercase tracking-wide">Creation date</div>
<TZLabel time={created_at} />
</div>
)
}
return (
<>
<div className="block" data-attr="experiment-start-date">
<div
className={clsx(
'text-xs font-semibold uppercase tracking-wide',
isStartDatePickerOpen && 'text-center'
)}
>
Start date
</div>
<div className="flex">
{isStartDatePickerOpen ? (
<DatePicker
showTime={true}
showSecond={false}
open={true}
value={dayjs(start_date)}
onBlur={() => setIsStartDatePickerOpen(false)}
onOk={(newStartDate: dayjs.Dayjs) => {
changeExperimentStartDate(newStartDate.toISOString())
}}
autoFocus={true}
disabledDate={(dateMarker) => {
return dateMarker.toDate() > new Date()
}}
allowClear={false}
data-attr="experiment-start-date-picker"
/>
) : (
<>
<TZLabel time={start_date} />
<LemonButton
title="Move start date"
data-attr="move-experiment-start-date"
icon={<IconPencil />}
size="small"
onClick={() => setIsStartDatePickerOpen(true)}
noPadding
className="ml-2"
/>
</>
)}
</div>
</div>
{end_date && (
<div className="block" data-attr="experiment-end-date">
<div className="text-xs font-semibold uppercase tracking-wide">End date</div>
{/* Flex class here is for the end date to have same appearance as the start date. */}
<div className="flex">
<TZLabel time={end_date} />
</div>
</div>
)}
</>
)
}

View File

@@ -5,7 +5,6 @@ import { Link, ProfilePicture, Tooltip } from '@posthog/lemon-ui'
import { useActions, useValues } from 'kea'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { EditableField } from 'lib/components/EditableField/EditableField'
import { TZLabel } from 'lib/components/TZLabel'
import { IconOpenInNew } from 'lib/lemon-ui/icons'
import { urls } from 'scenes/urls'
@@ -15,12 +14,13 @@ import { StatusTag } from '../Experiment'
import { experimentLogic } from '../experimentLogic'
import { getExperimentStatus } from '../experimentsLogic'
import { ActionBanner, ResultsTag } from './components'
import { ExperimentDates } from './ExperimentDates'
export function Info(): JSX.Element {
const { experiment } = useValues(experimentLogic)
const { updateExperiment } = useActions(experimentLogic)
const { created_by, created_at } = experiment
const { created_by } = experiment
if (!experiment.feature_flag) {
return <></>
@@ -30,7 +30,7 @@ export function Info(): JSX.Element {
<div>
<div className="flex">
<div className="w-1/2 inline-flex space-x-8">
<div className="block">
<div className="block" data-attr="experiment-status">
<div className="text-xs font-semibold uppercase tracking-wide">Status</div>
<StatusTag experiment={experiment} />
</div>
@@ -76,10 +76,7 @@ export function Info(): JSX.Element {
<div className="w-1/2 flex flex-col justify-end">
<div className="ml-auto inline-flex space-x-8">
<div className="block">
<div className="text-xs font-semibold uppercase tracking-wide">Created at</div>
{created_at && <TZLabel time={created_at} />}
</div>
<ExperimentDates />
<div className="block">
<div className="text-xs font-semibold uppercase tracking-wide">Created by</div>
{created_by && <ProfilePicture user={created_by} size="md" showName />}

View File

@@ -296,7 +296,11 @@ export function PageHeaderCustom(): JSX.Element {
<LemonButton type="secondary" className="mr-2" onClick={() => setEditExperiment(true)}>
Edit
</LemonButton>
<LemonButton type="primary" onClick={() => launchExperiment()}>
<LemonButton
type="primary"
data-attr="launch-experiment"
onClick={() => launchExperiment()}
>
Launch
</LemonButton>
</div>
@@ -337,7 +341,12 @@ export function PageHeaderCustom(): JSX.Element {
</>
<ResetButton experiment={experiment} onConfirm={resetRunningExperiment} />
{!experiment.end_date && (
<LemonButton type="secondary" status="danger" onClick={() => endExperiment()}>
<LemonButton
type="secondary"
data-attr="stop-experiment"
status="danger"
onClick={() => endExperiment()}
>
Stop
</LemonButton>
)}

View File

@@ -141,6 +141,7 @@ export const experimentLogic = kea<experimentLogicType>([
updateExperimentGoal: (filters: Partial<FilterType>) => ({ filters }),
updateExperimentExposure: (filters: Partial<FilterType> | null) => ({ filters }),
updateExperimentSecondaryMetrics: (metrics: SecondaryExperimentMetric[]) => ({ metrics }),
changeExperimentStartDate: (startDate: string) => ({ startDate }),
launchExperiment: true,
endExperiment: true,
addExperimentGroup: true,
@@ -238,6 +239,7 @@ export const experimentLogic = kea<experimentLogicType>([
{
updateExperimentGoal: () => true,
updateExperimentExposure: () => true,
changeExperimentStartDate: () => true,
loadExperimentResults: () => false,
},
],
@@ -446,6 +448,10 @@ export const experimentLogic = kea<experimentLogicType>([
actions.updateExperiment({ start_date: startDate.toISOString() })
values.experiment && eventUsageLogic.actions.reportExperimentLaunched(values.experiment, startDate)
},
changeExperimentStartDate: async ({ startDate }) => {
actions.updateExperiment({ start_date: startDate })
values.experiment && eventUsageLogic.actions.reportExperimentStartDateChange(values.experiment, startDate)
},
endExperiment: async () => {
const endDate = dayjs()
actions.updateExperiment({ end_date: endDate.toISOString() })