diff --git a/frontend/common/services/useWarehouseConnection.ts b/frontend/common/services/useWarehouseConnection.ts index 017fe642a5a3..8630dc01f7bb 100644 --- a/frontend/common/services/useWarehouseConnection.ts +++ b/frontend/common/services/useWarehouseConnection.ts @@ -32,15 +32,35 @@ export const warehouseConnectionService = service Req['getWarehouseConnections'] >({ providesTags: [{ id: 'LIST', type: 'WarehouseConnection' }], - query: ({ environmentId }) => ({ - url: `environments/${environmentId}/warehouse-connections/`, + query: ({ environmentId, exclude_event_stats }) => ({ + url: `environments/${environmentId}/warehouse-connections/${ + exclude_event_stats ? '?exclude_event_stats=true' : '' + }`, }), }), testWarehouseConnection: builder.mutation< Res['warehouseConnections'][number], Req['testWarehouseConnection'] >({ - invalidatesTags: [{ id: 'LIST', type: 'WarehouseConnection' }], + async onQueryStarted({ environmentId }, { dispatch, queryFulfilled }) { + try { + const { data } = await queryFulfilled + dispatch( + warehouseConnectionService.util.updateQueryData( + 'getWarehouseConnections', + { environmentId, exclude_event_stats: true }, + (draft) => { + const index = draft.findIndex( + (connection) => connection.id === data.id, + ) + if (index !== -1) draft[index] = data + }, + ), + ) + } catch { + return + } + }, query: ({ environmentId, id }) => ({ method: 'POST', url: `environments/${environmentId}/warehouse-connections/${id}/test-warehouse-connection/`, diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index 5d31e8488f60..c26043044721 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -24,6 +24,7 @@ import { StageActionType, StageActionBody, ChangeRequest, + ExpectedDirection, ExperimentStatus, MetricAggregation, MetricDirection, @@ -1010,7 +1011,10 @@ export type Req = { project_id: number gitlab_project_id: number }> - getWarehouseConnections: { environmentId: string } + getWarehouseConnections: { + environmentId: string + exclude_event_stats?: boolean + } createWarehouseConnection: { environmentId: string warehouse_type: string @@ -1031,7 +1035,12 @@ export type Req = { }> createExperiment: { environmentId: string - body: { name: string; hypothesis: string; feature: number } + body: { + name: string + hypothesis: string + feature: number + metrics: { metric: number; expected_direction: ExpectedDirection }[] + } } experimentAction: { environmentId: string; experimentId: number } deleteExperiment: { environmentId: string; experimentId: number } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 73b6c5607024..45da7caadbf4 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -608,6 +608,21 @@ export type Metric = { updated_at: string } +export type ExpectedDirection = + | 'increase' + | 'decrease' + | 'not_increase' + | 'not_decrease' + +export type ExperimentMetric = { + id: number + metric: number + metric_name: string + aggregation: MetricAggregation + expected_direction: ExpectedDirection + created_at: string +} + export type ExperimentFeature = { id: number name: string @@ -622,6 +637,7 @@ export type Experiment = { hypothesis: string feature: ExperimentFeature status: ExperimentStatus + metrics: ExperimentMetric[] created_at: string updated_at: string started_at: string | null diff --git a/frontend/documentation/components/CenteredModal.stories.tsx b/frontend/documentation/components/CenteredModal.stories.tsx new file mode 100644 index 000000000000..77e60aaf6603 --- /dev/null +++ b/frontend/documentation/components/CenteredModal.stories.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import CenteredModal from 'components/base/CenteredModal' +import Button from 'components/base/forms/Button' + +const meta: Meta = { + component: CenteredModal, + parameters: { + chromatic: { delay: 300 }, + docs: { + description: { + component: + 'Traditional centred modal sized to ~80% of the viewport, with a scrollable body. ' + + 'Unlike the global `openModal` drawer, it is fully declarative: the parent owns the ' + + 'open state and passes `isOpen`/`onClose`, with arbitrary children as the body.', + }, + }, + layout: 'centered', + }, + title: 'Components/Modals/CenteredModal', +} +export default meta + +type Story = StoryObj + +export const Default: Story = { + render: () => ( + {}}> +

Modal body content goes here.

+
+ ), +} + +export const WithFormContent: Story = { + render: () => ( + {}}> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ ), +} + +export const ScrollableBody: Story = { + render: () => ( + {}}> +
+ {Array.from({ length: 30 }, (_, i) => ( +

+ Long content block {i + 1} — the modal body scrolls once content + exceeds 75% of the viewport height. +

+ ))} +
+
+ ), +} + +const InteractiveExample = () => { + const [isOpen, setIsOpen] = useState(false) + return ( + <> + + setIsOpen(false)} + > +

+ Close me via the header button or by clicking the backdrop. +

+
+ + ) +} + +export const Interactive: Story = { + parameters: { chromatic: { disableSnapshot: true } }, + render: () => , +} diff --git a/frontend/web/components/base/CenteredModal/CenteredModal.scss b/frontend/web/components/base/CenteredModal/CenteredModal.scss new file mode 100644 index 000000000000..5389401edc7c --- /dev/null +++ b/frontend/web/components/base/CenteredModal/CenteredModal.scss @@ -0,0 +1,9 @@ +.centered-modal { + width: 80vw; + max-width: 80vw; + + .modal-body { + max-height: 75vh; + overflow-y: auto; + } +} diff --git a/frontend/web/components/base/CenteredModal/CenteredModal.tsx b/frontend/web/components/base/CenteredModal/CenteredModal.tsx new file mode 100644 index 000000000000..5d02a41c803a --- /dev/null +++ b/frontend/web/components/base/CenteredModal/CenteredModal.tsx @@ -0,0 +1,32 @@ +import { FC, ReactNode } from 'react' +import { Modal, ModalBody } from 'reactstrap' +import ModalHeader from 'components/modals/base/ModalHeader' +import './CenteredModal.scss' + +type CenteredModalProps = { + isOpen: boolean + title: ReactNode + onClose: () => void + children: ReactNode + className?: string +} + +const CenteredModal: FC = ({ + children, + className, + isOpen, + onClose, + title, +}) => ( + + {title} + {children} + +) + +export default CenteredModal diff --git a/frontend/web/components/base/CenteredModal/index.ts b/frontend/web/components/base/CenteredModal/index.ts new file mode 100644 index 000000000000..26d12e96c80b --- /dev/null +++ b/frontend/web/components/base/CenteredModal/index.ts @@ -0,0 +1 @@ +export { default } from './CenteredModal' diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/SelectableCard.scss b/frontend/web/components/base/SelectableCard/SelectableCard.scss similarity index 80% rename from frontend/web/components/pages/environment-settings/tabs/warehouse-tab/SelectableCard.scss rename to frontend/web/components/base/SelectableCard/SelectableCard.scss index f390b4ca83ba..cf76f233d77a 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/SelectableCard.scss +++ b/frontend/web/components/base/SelectableCard/SelectableCard.scss @@ -50,7 +50,7 @@ &__title { font-size: var(--font-body-size); - font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-medium); color: var(--color-text-default); } @@ -59,6 +59,22 @@ color: var(--color-text-secondary); } + &__tags { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 4px; + } + + &__tag { + font-size: var(--font-caption-xs-size); + font-weight: var(--font-weight-regular); + padding: 2px 8px; + border-radius: var(--radius-sm); + background: var(--color-surface-emphasis); + color: var(--color-text-secondary); + } + &__aside { display: flex; align-items: center; @@ -68,7 +84,7 @@ &__badge { font-size: var(--font-caption-xs-size); - font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-medium); padding: 4px 12px; border-radius: var(--radius-full); flex-shrink: 0; diff --git a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/SelectableCard.tsx b/frontend/web/components/base/SelectableCard/SelectableCard.tsx similarity index 84% rename from frontend/web/components/pages/environment-settings/tabs/warehouse-tab/SelectableCard.tsx rename to frontend/web/components/base/SelectableCard/SelectableCard.tsx index 48d7c6537a35..ab8f146bfecf 100644 --- a/frontend/web/components/pages/environment-settings/tabs/warehouse-tab/SelectableCard.tsx +++ b/frontend/web/components/base/SelectableCard/SelectableCard.tsx @@ -10,6 +10,7 @@ type SelectableCardProps = { title: string description: string badge?: { label: string; variant: BadgeVariant } + tags?: string[] disabled?: boolean } @@ -20,6 +21,7 @@ const SelectableCard: FC = ({ icon, onClick, selected, + tags, title, }) => { const handleKeyDown = (e: React.KeyboardEvent) => { @@ -43,6 +45,15 @@ const SelectableCard: FC = ({ {icon &&
{icon}
} {title} {description} + {!!tags?.length && ( +
+ {tags.map((tag) => ( + + {tag} + + ))} +
+ )} {badge && (
diff --git a/frontend/web/components/base/SelectableCard/index.ts b/frontend/web/components/base/SelectableCard/index.ts new file mode 100644 index 000000000000..da1acff49da5 --- /dev/null +++ b/frontend/web/components/base/SelectableCard/index.ts @@ -0,0 +1 @@ +export { default } from './SelectableCard' diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.scss b/frontend/web/components/base/grid/ContentCard/ContentCard.scss index e6c78a3a0457..c6c6afead62e 100644 --- a/frontend/web/components/base/grid/ContentCard/ContentCard.scss +++ b/frontend/web/components/base/grid/ContentCard/ContentCard.scss @@ -8,11 +8,23 @@ border-radius: var(--radius-lg); &__header { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__heading { display: flex; align-items: center; justify-content: space-between; } + &__description { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + margin: 0; + } + .input-container { display: block; } diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.tsx b/frontend/web/components/base/grid/ContentCard/ContentCard.tsx index 2cb5136a3f88..66113cc24d95 100644 --- a/frontend/web/components/base/grid/ContentCard/ContentCard.tsx +++ b/frontend/web/components/base/grid/ContentCard/ContentCard.tsx @@ -4,6 +4,7 @@ import './ContentCard.scss' type ContentCardProps = { title?: string + description?: string action?: ReactNode className?: string children: ReactNode @@ -13,14 +14,20 @@ const ContentCard: FC = ({ action, children, className, + description, title, }) => { return (
- {(title || action) && ( + {(title || action || description) && (
- {title &&

{title}

} - {action} +
+ {title &&

{title}

} + {action} +
+ {description && ( +

{description}

+ )}
)} {children} diff --git a/frontend/web/components/experiments/CreateExperimentWizard.tsx b/frontend/web/components/experiments/CreateExperimentWizard.tsx index 040be0fc5cd6..a69e80f2a0ee 100644 --- a/frontend/web/components/experiments/CreateExperimentWizard.tsx +++ b/frontend/web/components/experiments/CreateExperimentWizard.tsx @@ -1,6 +1,7 @@ import { FC, useCallback, useMemo, useState } from 'react' -import { ProjectFlag } from 'common/types/responses' +import { ExpectedDirection, Metric, ProjectFlag } from 'common/types/responses' import { useCreateExperimentMutation } from 'common/services/useExperiment' +import { METRIC_DIRECTION_TO_EXPECTED_DIRECTION } from './constants' import WizardStepper from './WizardStepper' import WizardNavButtons from './WizardNavButtons' import LivePreviewPanel from './LivePreviewPanel' @@ -10,6 +11,8 @@ import MeasurementStep from './steps/MeasurementStep' import ReviewStep from './steps/ReviewStep' const TOTAL_STEPS = 4 +const MEASUREMENT_STEP = 2 +const SHOW_LIVE_PREVIEW = false type CreateExperimentWizardProps = { environmentId: string @@ -28,6 +31,9 @@ const CreateExperimentWizard: FC = ({ const [selectedFeature, setSelectedFeature] = useState( null, ) + const [selectedMetric, setSelectedMetric] = useState(null) + const [expectedDirection, setExpectedDirection] = + useState(null) const [completedSteps, setCompletedSteps] = useState>(new Set()) const [createExperiment, { isLoading: isSubmitting }] = @@ -41,7 +47,22 @@ const CreateExperimentWizard: FC = ({ [name, hypothesis, selectedFeature], ) - const canContinue = currentStep === 0 ? isStep1Valid : true + const isMeasurementValid = + selectedMetric !== null && expectedDirection !== null + + const stepValidity: Record = { + 0: isStep1Valid, + 3: isStep1Valid && isMeasurementValid, + [MEASUREMENT_STEP]: isMeasurementValid, + } + const canContinue = stepValidity[currentStep] ?? true + + const handleMetricSelect = useCallback((metric: Metric) => { + setSelectedMetric(metric) + setExpectedDirection( + METRIC_DIRECTION_TO_EXPECTED_DIRECTION[metric.direction], + ) + }, []) const handleContinue = useCallback(() => { if (currentStep < TOTAL_STEPS - 1) { @@ -66,12 +87,18 @@ const CreateExperimentWizard: FC = ({ ) const doCreate = useCallback(async () => { - if (!selectedFeature) return + if (!selectedFeature || !selectedMetric || !expectedDirection) return try { await createExperiment({ body: { feature: selectedFeature.id, hypothesis: hypothesis.trim(), + metrics: [ + { + expected_direction: expectedDirection, + metric: selectedMetric.id, + }, + ], name: name.trim(), }, environmentId, @@ -84,14 +111,16 @@ const CreateExperimentWizard: FC = ({ }, [ createExperiment, environmentId, + expectedDirection, hypothesis, name, onCreated, selectedFeature, + selectedMetric, ]) const handleLaunch = useCallback(() => { - if (!selectedFeature) return + if (!selectedFeature || !isMeasurementValid) return openConfirm({ body: ( @@ -106,7 +135,7 @@ const CreateExperimentWizard: FC = ({ title: 'Create experiment?', yesText: 'Create', }) - }, [selectedFeature, doCreate]) + }, [selectedFeature, isMeasurementValid, doCreate]) const renderStep = () => { switch (currentStep) { @@ -126,14 +155,25 @@ const CreateExperimentWizard: FC = ({ case 1: return case 2: - return + return ( + + ) case 3: return ( setCurrentStep(0)} + onEditMeasurement={() => setCurrentStep(MEASUREMENT_STEP)} /> ) default: @@ -160,7 +200,7 @@ const CreateExperimentWizard: FC = ({ onLaunch={handleLaunch} />
- + {SHOW_LIVE_PREVIEW && }
) } diff --git a/frontend/web/components/experiments/CreateMetricInModal.tsx b/frontend/web/components/experiments/CreateMetricInModal.tsx new file mode 100644 index 000000000000..478a9f0b5f18 --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricInModal.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react' +import { Metric } from 'common/types/responses' +import { useCreateMetricMutation } from 'common/services/useMetric' +import Utils from 'common/utils/utils' +import CreateMetricForm from './CreateMetricForm' +import { + buildMetricPayload, + DEFAULT_METRIC_DEFINITION_VERSION, + MetricFormState, +} from './CreateMetricForm/utils' + +type CreateMetricInModalProps = { + environmentId: string + onClose: () => void + onCreated: (metric: Metric) => void +} + +const CreateMetricInModal: FC = ({ + environmentId, + onClose, + onCreated, +}) => { + const [createMetric, { isLoading: isSaving }] = useCreateMetricMutation() + const version = + Number(Utils.getFlagsmithValue('experiment_metrics')) || + DEFAULT_METRIC_DEFINITION_VERSION + + const handleSubmit = async (state: MetricFormState) => { + try { + const metric = await createMetric({ + body: buildMetricPayload(state, version), + environmentId, + }).unwrap() + toast('Metric created successfully') + onClose() + onCreated(metric) + } catch { + toast('Failed to create metric', 'danger') + } + } + + return ( + + ) +} + +export default CreateMetricInModal diff --git a/frontend/web/components/experiments/ExperimentsTable/ExperimentsTable.tsx b/frontend/web/components/experiments/ExperimentsTable/ExperimentsTable.tsx index 610cb42cf06c..1a5560c1d8ec 100644 --- a/frontend/web/components/experiments/ExperimentsTable/ExperimentsTable.tsx +++ b/frontend/web/components/experiments/ExperimentsTable/ExperimentsTable.tsx @@ -3,6 +3,7 @@ import moment from 'moment' import { Experiment } from 'common/types/responses' import StatusBadge from 'components/experiments/StatusBadge' import ExperimentActionDropdown from 'components/experiments/ExperimentActionDropdown' +import { getPrimaryMetric } from 'components/experiments/constants' import './ExperimentsTable.scss' type ExperimentsTableProps = { @@ -28,32 +29,37 @@ const ExperimentsTable: FC = ({ - {experiments.map((exp) => ( - - {exp.name} - - {exp.feature?.name && ( - - {exp.feature.name} - - )} - - - - - {(exp.feature?.multivariate_options?.length ?? 0) + 1} - — - {moment(exp.updated_at).fromNow()} - - - - - ))} + {experiments.map((exp) => { + const primaryMetric = getPrimaryMetric(exp) + return ( + + {exp.name} + + {exp.feature?.name && ( + + {exp.feature.name} + + )} + + + + + {(exp.feature?.multivariate_options?.length ?? 0) + 1} + + {primaryMetric?.metric_name ?? <>—} + + {moment(exp.updated_at).fromNow()} + + + + + ) + })} ) diff --git a/frontend/web/components/experiments/MetricSelectList/MetricSelectList.scss b/frontend/web/components/experiments/MetricSelectList/MetricSelectList.scss new file mode 100644 index 000000000000..35da89037a00 --- /dev/null +++ b/frontend/web/components/experiments/MetricSelectList/MetricSelectList.scss @@ -0,0 +1,25 @@ +.metric-select-list { + display: flex; + flex-direction: column; + gap: 16px; + + &__toolbar { + display: flex; + align-items: center; + gap: 12px; + } + + &__search { + flex: 1; + + .input-container { + width: 100%; + } + } + + &__cards { + display: flex; + flex-direction: column; + gap: 8px; + } +} diff --git a/frontend/web/components/experiments/MetricSelectList/MetricSelectList.tsx b/frontend/web/components/experiments/MetricSelectList/MetricSelectList.tsx new file mode 100644 index 000000000000..abebcde51f3d --- /dev/null +++ b/frontend/web/components/experiments/MetricSelectList/MetricSelectList.tsx @@ -0,0 +1,145 @@ +import { ChangeEvent, FC, useEffect, useState } from 'react' +import { Metric } from 'common/types/responses' +import { useGetMetricsQuery } from 'common/services/useMetric' +import useDebouncedSearch from 'common/useDebouncedSearch' +import Utils from 'common/utils/utils' +import Button from 'components/base/forms/Button' +import SelectableCard from 'components/base/SelectableCard' +import EmptyState from 'components/EmptyState' +import Icon from 'components/icons/Icon' +import Paging from 'components/Paging' +import { METRIC_DIRECTION_LABELS } from 'components/experiments/constants' +import './MetricSelectList.scss' + +const PAGE_SIZE = 5 + +type MetricSelectListProps = { + environmentId: string + selectedMetric: Metric | null + onSelect: (metric: Metric) => void + onCreateClick: () => void +} + +const MetricSelectList: FC = ({ + environmentId, + onCreateClick, + onSelect, + selectedMetric, +}) => { + const { search, searchInput, setSearchInput } = useDebouncedSearch() + const [page, setPage] = useState(1) + + useEffect(() => { + setPage(1) + }, [search]) + + const { data: metricsData, isLoading } = useGetMetricsQuery( + { + environmentId, + page, + page_size: PAGE_SIZE, + q: search || undefined, + }, + { skip: !environmentId }, + ) + + const metrics = metricsData?.results + const metricCount = metricsData?.count ?? 0 + const hasActiveSearch = !!search + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (metricCount === 0 && !hasActiveSearch) { + return ( + + + Create Metric + + } + /> + ) + } + + return ( +
+
+
+ ) => + setSearchInput(Utils.safeParseEventValue(e)) + } + placeholder='Search metrics...' + search + size='small' + /> +
+ +
+ + {metrics?.length ? ( + <> +
+ {metrics.map((metric) => { + const isSelected = selectedMetric?.id === metric.id + return ( + onSelect(metric)} + /> + ) + })} +
+ {metricCount > PAGE_SIZE && ( + setPage(page + 1)} + prevPage={() => setPage(page - 1)} + goToPage={(p: number) => setPage(p)} + isLoading={isLoading} + /> + )} + + ) : ( + + )} +
+ ) +} + +export default MetricSelectList diff --git a/frontend/web/components/experiments/MetricSelectList/index.ts b/frontend/web/components/experiments/MetricSelectList/index.ts new file mode 100644 index 000000000000..3140eb163e6f --- /dev/null +++ b/frontend/web/components/experiments/MetricSelectList/index.ts @@ -0,0 +1 @@ +export { default } from './MetricSelectList' diff --git a/frontend/web/components/experiments/MetricsTable/MetricsTable.scss b/frontend/web/components/experiments/MetricsTable/MetricsTable.scss index 7d6c091500ad..6fb02bbe20c1 100644 --- a/frontend/web/components/experiments/MetricsTable/MetricsTable.scss +++ b/frontend/web/components/experiments/MetricsTable/MetricsTable.scss @@ -65,6 +65,7 @@ &__subline-icon { color: var(--color-text-secondary); + flex-shrink: 0; } &__actions { diff --git a/frontend/web/components/experiments/WizardNavButtons.tsx b/frontend/web/components/experiments/WizardNavButtons.tsx index ca6e2d322f95..c81b571dee62 100644 --- a/frontend/web/components/experiments/WizardNavButtons.tsx +++ b/frontend/web/components/experiments/WizardNavButtons.tsx @@ -32,7 +32,7 @@ const WizardNavButtons: FC = ({ )} {isLastStep ? ( - diff --git a/frontend/web/components/experiments/constants.ts b/frontend/web/components/experiments/constants.ts index 2d9f301c5e44..a58a31346a4c 100644 --- a/frontend/web/components/experiments/constants.ts +++ b/frontend/web/components/experiments/constants.ts @@ -1,4 +1,10 @@ -import { ExperimentStatus } from 'common/types/responses' +import { + ExpectedDirection, + Experiment, + ExperimentMetric, + ExperimentStatus, + MetricDirection, +} from 'common/types/responses' export const EXPERIMENT_STATUS_LABELS: Record = { completed: 'Completed', @@ -21,3 +27,40 @@ export const TAB_ORDER: FilterTab[] = [ 'paused', 'completed', ] + +export const METRIC_DIRECTION_LABELS: Record = { + down: '↓ lower is better', + informational: 'informational', + up: '↑ higher is better', +} + +export type ExpectedDirectionOption = { + value: ExpectedDirection + label: string +} + +export const EXPECTED_DIRECTION_OPTIONS: ExpectedDirectionOption[] = [ + { label: 'Increase', value: 'increase' }, + { label: 'Decrease', value: 'decrease' }, + { label: 'Should not increase', value: 'not_increase' }, + { label: 'Should not decrease', value: 'not_decrease' }, +] + +export const getExpectedDirectionLabel = ( + direction: ExpectedDirection, +): string => + EXPECTED_DIRECTION_OPTIONS.find((option) => option.value === direction) + ?.label ?? direction + +export const METRIC_DIRECTION_TO_EXPECTED_DIRECTION: Record< + MetricDirection, + ExpectedDirection | null +> = { + down: 'decrease', + informational: null, + up: 'increase', +} + +export const getPrimaryMetric = ( + experiment: Experiment, +): ExperimentMetric | undefined => experiment.metrics?.[0] diff --git a/frontend/web/components/experiments/steps/AudienceStep.tsx b/frontend/web/components/experiments/steps/AudienceStep.tsx index d3687d9ff0fc..202fb95f33a4 100644 --- a/frontend/web/components/experiments/steps/AudienceStep.tsx +++ b/frontend/web/components/experiments/steps/AudienceStep.tsx @@ -5,13 +5,10 @@ import ContentCard from 'components/base/grid/ContentCard' const AudienceStep: FC = () => { return (
- -

- Define who is eligible for the experiment using attribute conditions. - Conditions are AND-joined. Leave empty to run on all identities in the - environment. Conditions are frozen at launch. Later edits to existing - Segments cannot drift the experiment audience. -

+
{
- - {/* Sample size hidden for now — hardcoded to 100% in payload */} ) } diff --git a/frontend/web/components/experiments/steps/MeasurementStep.tsx b/frontend/web/components/experiments/steps/MeasurementStep.tsx index 9f11203023ea..fb81f67d59c9 100644 --- a/frontend/web/components/experiments/steps/MeasurementStep.tsx +++ b/frontend/web/components/experiments/steps/MeasurementStep.tsx @@ -1,28 +1,79 @@ -import { FC } from 'react' -import Button from 'components/base/forms/Button' -import Icon from 'components/icons/Icon' +import { FC, useState } from 'react' +import { ExpectedDirection, Metric } from 'common/types/responses' import ContentCard from 'components/base/grid/ContentCard' +import CenteredModal from 'components/base/CenteredModal' +import CreateMetricInModal from 'components/experiments/CreateMetricInModal' +import MetricSelectList from 'components/experiments/MetricSelectList' +import { + EXPECTED_DIRECTION_OPTIONS, + ExpectedDirectionOption, +} from 'components/experiments/constants' + +type MeasurementStepProps = { + environmentId: string + selectedMetric: Metric | null + expectedDirection: ExpectedDirection | null + onMetricSelect: (metric: Metric) => void + onExpectedDirectionChange: (direction: ExpectedDirection) => void +} + +const MeasurementStep: FC = ({ + environmentId, + expectedDirection, + onExpectedDirectionChange, + onMetricSelect, + selectedMetric, +}) => { + const [isCreateMetricOpen, setIsCreateMetricOpen] = useState(false) -const MeasurementStep: FC = () => { return (
- -
-
- + setIsCreateMetricOpen(true)} + /> + + {selectedMetric && ( +
+ + = ({ ? { label: selectedFeature.name, value: selectedFeature.id } : null } - options={multivariateFeatures.map((f) => ({ - feature: f, - label: f.name, - value: f.id, - }))} + options={multivariateFeatures.map((f) => { + const isInExperiment = featureIdsInExperiment.has(f.id) + return { + feature: f, + isDisabled: isInExperiment, + label: isInExperiment + ? `${f.name} (already in an experiment)` + : f.name, + value: f.id, + } + })} onInputChange={(val: string) => setSearchInput(val)} onChange={( option: { diff --git a/frontend/web/components/pages/ExperimentsPage.tsx b/frontend/web/components/pages/ExperimentsPage.tsx index 36d2e89f655a..5665844ceaaa 100644 --- a/frontend/web/components/pages/ExperimentsPage.tsx +++ b/frontend/web/components/pages/ExperimentsPage.tsx @@ -51,7 +51,7 @@ const ExperimentsPage: FC = () => { const { data: warehouseConnections, isLoading: isLoadingWarehouse } = useGetWarehouseConnectionsQuery( - { environmentId: environmentId ?? '' }, + { environmentId: environmentId ?? '', exclude_event_stats: true }, { skip: !environmentId }, ) @@ -214,6 +214,9 @@ const ExperimentsPage: FC = () => { } const renderCta = () => { + if (isLoading || isLoadingWarehouse) { + return undefined + } if (hasWarehouse) { return (