feat(experiments): add variant screenshots (#25397)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 177 KiB |
Before Width: | Height: | Size: 171 KiB After Width: | Height: | Size: 180 KiB |
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 133 KiB |
Before Width: | Height: | Size: 129 KiB After Width: | Height: | Size: 134 KiB |
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 187 KiB |
Before Width: | Height: | Size: 172 KiB After Width: | Height: | Size: 188 KiB |
@ -8,6 +8,7 @@ import { MultivariateFlagVariant } from '~/types'
|
||||
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { VariantTag } from './components'
|
||||
import { VariantScreenshot } from './VariantScreenshot'
|
||||
|
||||
export function DistributionTable(): JSX.Element {
|
||||
const { experimentId, experiment, experimentResults } = useValues(experimentLogic)
|
||||
@ -32,6 +33,18 @@ export function DistributionTable(): JSX.Element {
|
||||
return <div>{`${item.rollout_percentage}%`}</div>
|
||||
},
|
||||
},
|
||||
{
|
||||
className: 'w-1/3',
|
||||
key: 'variant_screenshot',
|
||||
title: 'Screenshot',
|
||||
render: function Key(_, item): JSX.Element {
|
||||
return (
|
||||
<div className="my-2">
|
||||
<VariantScreenshot variantKey={item.key} rolloutPercentage={item.rollout_percentage} />
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
|
@ -0,0 +1,114 @@
|
||||
import { IconUpload, IconX } from '@posthog/icons'
|
||||
import { LemonButton, LemonDivider, LemonFileInput, LemonModal, LemonSkeleton, lemonToast } from '@posthog/lemon-ui'
|
||||
import { useActions, useValues } from 'kea'
|
||||
import { useUploadFiles } from 'lib/hooks/useUploadFiles'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { experimentLogic } from '../experimentLogic'
|
||||
import { VariantTag } from './components'
|
||||
|
||||
export function VariantScreenshot({
|
||||
variantKey,
|
||||
rolloutPercentage,
|
||||
}: {
|
||||
variantKey: string
|
||||
rolloutPercentage: number
|
||||
}): JSX.Element {
|
||||
const { experiment } = useValues(experimentLogic)
|
||||
const { updateExperimentVariantImages } = useActions(experimentLogic)
|
||||
|
||||
const [mediaId, setMediaId] = useState(experiment.parameters?.variant_screenshot_media_ids?.[variantKey] || null)
|
||||
const [isLoadingImage, setIsLoadingImage] = useState(true)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
|
||||
const { setFilesToUpload, filesToUpload, uploading } = useUploadFiles({
|
||||
onUpload: (_, __, id) => {
|
||||
setMediaId(id)
|
||||
if (id) {
|
||||
const updatedVariantImages = {
|
||||
...experiment.parameters?.variant_screenshot_media_ids,
|
||||
[variantKey]: id,
|
||||
}
|
||||
updateExperimentVariantImages(updatedVariantImages)
|
||||
}
|
||||
},
|
||||
onError: (detail) => {
|
||||
lemonToast.error(`Error uploading image: ${detail}`)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!mediaId ? (
|
||||
<LemonFileInput
|
||||
accept="image/*"
|
||||
multiple={false}
|
||||
onChange={setFilesToUpload}
|
||||
loading={uploading}
|
||||
value={filesToUpload}
|
||||
callToAction={
|
||||
<>
|
||||
<IconUpload className="text-2xl" />
|
||||
<span>Upload a preview of this variant's UI</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<div className="text-muted inline-flex flow-row items-center gap-1 cursor-pointer">
|
||||
<div onClick={() => setIsModalOpen(true)} className="cursor-zoom-in relative">
|
||||
<div className="relative flex overflow-hidden select-none size-20 w-full rounded before:absolute before:inset-0 before:border before:rounded">
|
||||
{isLoadingImage && <LemonSkeleton className="absolute inset-0" />}
|
||||
<img
|
||||
className="size-full object-cover"
|
||||
src={mediaId.startsWith('data:') ? mediaId : `/uploaded_media/${mediaId}`}
|
||||
onError={() => setIsLoadingImage(false)}
|
||||
onLoad={() => setIsLoadingImage(false)}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute -inset-2 group">
|
||||
<LemonButton
|
||||
icon={<IconX />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMediaId(null)
|
||||
const updatedVariantImages = {
|
||||
...experiment.parameters?.variant_screenshot_media_ids,
|
||||
}
|
||||
delete updatedVariantImages[variantKey]
|
||||
updateExperimentVariantImages(updatedVariantImages)
|
||||
}}
|
||||
size="small"
|
||||
tooltip="Remove"
|
||||
tooltipPlacement="right"
|
||||
noPadding
|
||||
className="group-hover:flex hidden absolute right-0 top-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<LemonModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Screenshot</span>
|
||||
<LemonDivider className="my-0 mx-1" vertical />
|
||||
<VariantTag experimentId={experiment.id} variantKey={variantKey} />
|
||||
{rolloutPercentage !== undefined && (
|
||||
<span className="text-muted text-sm">({rolloutPercentage}% rollout)</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={mediaId?.startsWith('data:') ? mediaId : `/uploaded_media/${mediaId}`}
|
||||
alt={`Screenshot: ${variantKey}`}
|
||||
className="max-w-full max-h-[80vh] overflow-auto"
|
||||
/>
|
||||
</LemonModal>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -169,6 +169,7 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
closeShipVariantModal: true,
|
||||
setCurrentFormStep: (stepIndex: number) => ({ stepIndex }),
|
||||
moveToNextFormStep: true,
|
||||
updateExperimentVariantImages: (variantPreviewMediaIds: Record<string, string>) => ({ variantPreviewMediaIds }),
|
||||
}),
|
||||
reducers({
|
||||
experiment: [
|
||||
@ -587,15 +588,12 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
},
|
||||
updateExperimentSuccess: async ({ experiment }) => {
|
||||
actions.updateExperiments(experiment)
|
||||
|
||||
if (values.changingGoalMetric) {
|
||||
actions.loadExperimentResults()
|
||||
}
|
||||
|
||||
if (values.changingSecondaryMetrics) {
|
||||
actions.loadSecondaryMetricResults()
|
||||
}
|
||||
|
||||
if (values.experiment?.start_date) {
|
||||
actions.loadExperimentResults()
|
||||
}
|
||||
@ -717,6 +715,22 @@ export const experimentLogic = kea<experimentLogicType>([
|
||||
lemonToast.error(error)
|
||||
actions.closeShipVariantModal()
|
||||
},
|
||||
updateExperimentVariantImages: async ({ variantPreviewMediaIds }) => {
|
||||
try {
|
||||
const updatedParameters = {
|
||||
...values.experiment.parameters,
|
||||
variant_screenshot_media_ids: variantPreviewMediaIds,
|
||||
}
|
||||
await api.update(`api/projects/${values.currentTeamId}/experiments/${values.experimentId}`, {
|
||||
parameters: updatedParameters,
|
||||
})
|
||||
actions.setExperiment({
|
||||
parameters: updatedParameters,
|
||||
})
|
||||
} catch (error) {
|
||||
lemonToast.error('Failed to update experiment variant images')
|
||||
}
|
||||
},
|
||||
})),
|
||||
loaders(({ actions, props, values }) => ({
|
||||
experiment: {
|
||||
|
@ -3219,6 +3219,7 @@ export interface Experiment {
|
||||
feature_flag_variants: MultivariateFlagVariant[]
|
||||
custom_exposure_filter?: FilterType
|
||||
aggregation_group_type_index?: integer
|
||||
variant_screenshot_media_ids?: Record<string, string>
|
||||
}
|
||||
start_date?: string | null
|
||||
end_date?: string | null
|
||||
|