Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion frontend/common/services/useMetric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,23 @@ export const metricService = service
}),
}),
deleteMetric: builder.mutation<void, Req['deleteMetric']>({
invalidatesTags: [{ id: 'LIST', type: 'Metric' }],
invalidatesTags: (_res, _err, { metricId }) => [
{ id: 'LIST', type: 'Metric' },
{ id: metricId, type: 'Metric' },
],
query: ({ environmentId, metricId }) => ({
method: 'DELETE',
url: `environments/${environmentId}/experiment-metrics/${metricId}/`,
}),
}),
getMetric: builder.query<Res['metric'], Req['getMetric']>({
providesTags: (_res, _err, { metricId }) => [
{ id: metricId, type: 'Metric' },
],
query: ({ environmentId, metricId }) => ({
url: `environments/${environmentId}/experiment-metrics/${metricId}/`,
}),
}),
getMetrics: builder.query<Res['metrics'], Req['getMetrics']>({
providesTags: [{ id: 'LIST', type: 'Metric' }],
query: ({ environmentId, ...rest }) => ({
Expand All @@ -32,11 +43,24 @@ export const metricService = service
}),
transformResponse: (res, _, req) => transformCorePaging(req, res),
}),
updateMetric: builder.mutation<Res['metric'], Req['updateMetric']>({
invalidatesTags: (_res, _err, { metricId }) => [
{ id: 'LIST', type: 'Metric' },
{ id: metricId, type: 'Metric' },
],
query: ({ body, environmentId, metricId }) => ({
body,
method: 'PATCH',
url: `environments/${environmentId}/experiment-metrics/${metricId}/`,
}),
}),
}),
})

export const {
useCreateMetricMutation,
useDeleteMetricMutation,
useGetMetricQuery,
useGetMetricsQuery,
useUpdateMetricMutation,
} = metricService
7 changes: 7 additions & 0 deletions frontend/common/types/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,9 @@ export type Req = {
deleteExperiment: { environmentId: string; experimentId: number }
getMetrics: PagedRequest<{
environmentId: string
q?: string
}>
getMetric: { environmentId: string; metricId: number }
createMetric: {
environmentId: string
body: {
Expand All @@ -1031,6 +1033,11 @@ export type Req = {
definition: MetricDefinition
}
}
updateMetric: {
environmentId: string
metricId: number
body: Req['createMetric']['body']
}
deleteMetric: { environmentId: string; metricId: number }
// END OF TYPES
}
7 changes: 7 additions & 0 deletions frontend/common/types/responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,13 +590,20 @@ export type MetricDefinition = {
event: string
}

export type MetricExperiment = {
id: number
name: string
status: ExperimentStatus
}

export type Metric = {
id: number
name: string
description: string
aggregation: MetricAggregation
direction: MetricDirection
definition: MetricDefinition
experiments: MetricExperiment[]
created_at: string
updated_at: string
}
Expand Down
8 changes: 6 additions & 2 deletions frontend/web/components/Paging.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default class Paging extends PureComponent {
static displayName = 'Paging'

static propTypes = {
className: propTypes.string,
goToPage: propTypes.func,
isLoading: propTypes.bool,
onNextClick: propTypes.func,
Expand All @@ -20,7 +21,7 @@ export default class Paging extends PureComponent {

render() {
const {
props: { goToPage, isLoading, nextPage, paging, prevPage },
props: { className, goToPage, isLoading, nextPage, paging, prevPage },
} = this
const currentIndex = paging.currentPage - 1
const lastPage = Math.ceil(paging.count / paging.pageSize)
Expand All @@ -38,7 +39,10 @@ export default class Paging extends PureComponent {
}
return (
<Row
className='paging justify-content-end table-column py-2'
className={cn(
'paging justify-content-end table-column py-2',
className,
)}
style={isLoading ? { opacity: 0.5 } : {}}
>
{!!paging.count && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,27 @@ import {
import './CreateMetricForm.scss'

type CreateMetricFormProps = {
initialState?: MetricFormState
isSaving?: boolean
submitLabel?: string
onCancel: () => void
onSubmit: (state: MetricFormState) => void
}

const CreateMetricForm: FC<CreateMetricFormProps> = ({
initialState = DEFAULT_METRIC_FORM_STATE,
isSaving,
onCancel,
onSubmit,
submitLabel = 'Create Metric',
}) => {
const [state, setState] = useState<MetricFormState>(DEFAULT_METRIC_FORM_STATE)
const [state, setState] = useState<MetricFormState>(initialState)

const update = (patch: Partial<MetricFormState>) =>
setState((prev) => ({ ...prev, ...patch }))

const handleCancel = () => {
setState(DEFAULT_METRIC_FORM_STATE)
setState(initialState)
onCancel()
}

Expand Down Expand Up @@ -166,7 +170,7 @@ const CreateMetricForm: FC<CreateMetricFormProps> = ({
onClick={handleSubmit}
disabled={!canSubmitMetric(state) || isSaving}
>
{isSaving ? 'Creating…' : 'Create Metric'}
{isSaving ? 'Saving…' : submitLabel}
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { FC } from 'react'
import Skeleton from 'components/Skeleton'
import './CreateMetricForm.scss'

const FIELD_COUNT = 4

const CreateMetricFormSkeleton: FC = () => (
<div className='create-metric-form' aria-hidden>
{Array.from({ length: FIELD_COUNT }).map((_, index) => (
<div className='create-metric-form__field' key={index}>
<Skeleton width={140} height={14} className='mb-2' />
<Skeleton width='100%' height={38} />
</div>
))}
<div className='d-flex gap-2 mt-3'>
<Skeleton variant='badge' width={120} height={38} />
<Skeleton variant='badge' width={88} height={38} />
</div>
</div>
)

CreateMetricFormSkeleton.displayName = 'CreateMetricFormSkeleton'
export default CreateMetricFormSkeleton
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { Metric } from 'common/types/responses'
import {
buildMetricPayload,
canSubmitMetric,
DEFAULT_METRIC_DEFINITION_VERSION,
DEFAULT_METRIC_FORM_STATE,
getMetricAggregationLabel,
getMetricSubline,
getMetricUsageLabel,
metricToFormState,
MetricFormState,
} from 'components/experiments/CreateMetricForm/utils'

const buildMetric = (overrides: Partial<Metric> = {}): Metric => ({
aggregation: 'occurrence',
created_at: '2026-01-01T00:00:00Z',
definition: { event: 'checkout_completed', version: 1 },
description: 'Percentage of users completing checkout',
direction: 'up',
experiments: [],
id: 1,
name: 'Checkout Conversion Rate',
updated_at: '2026-01-01T00:00:00Z',
...overrides,
})

describe('canSubmitMetric', () => {
it('returns false when the name is empty', () => {
const state: MetricFormState = {
Expand Down Expand Up @@ -89,3 +107,57 @@ describe('DEFAULT_METRIC_FORM_STATE', () => {
expect(DEFAULT_METRIC_FORM_STATE.direction).toBe('up')
})
})

describe('getMetricAggregationLabel', () => {
it('maps each aggregation to its human label', () => {
expect(getMetricAggregationLabel('occurrence')).toBe('Occurrence')
expect(getMetricAggregationLabel('count')).toBe('Count')
expect(getMetricAggregationLabel('sum')).toBe('Sum')
expect(getMetricAggregationLabel('mean')).toBe('Mean')
})
})

describe('getMetricSubline', () => {
it('combines the aggregation label and event name', () => {
const metric = buildMetric({
aggregation: 'count',
definition: { event: 'checkout_completed', version: 1 },
})

expect(getMetricSubline(metric)).toBe('Count · checkout_completed')
})
})

describe('getMetricUsageLabel', () => {
it('reads "Not in use" when no experiments use the metric', () => {
expect(getMetricUsageLabel(0)).toBe('Not in use')
})

it('uses the singular form for a single experiment', () => {
expect(getMetricUsageLabel(1)).toBe('1 experiment')
})

it('uses the plural form for multiple experiments', () => {
expect(getMetricUsageLabel(3)).toBe('3 experiments')
})
})

describe('metricToFormState', () => {
it('maps a metric to editable form state', () => {
const metric = buildMetric({
aggregation: 'sum',
definition: { event: 'purchase', version: 1 },
description: 'Total revenue',
direction: 'down',
name: 'Revenue',
})

expect(metricToFormState(metric)).toEqual({
aggregation: 'sum',
description: 'Total revenue',
direction: 'down',
event: 'purchase',
name: 'Revenue',
})
})
})
26 changes: 26 additions & 0 deletions frontend/web/components/experiments/CreateMetricForm/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Req } from 'common/types/requests'
import {
Metric,
MetricAggregation,
MetricDefinition,
MetricDirection,
Expand Down Expand Up @@ -71,6 +72,31 @@ export const DIRECTION_OPTIONS: DirectionOption[] = [
export const canSubmitMetric = (state: MetricFormState): boolean =>
state.name.trim().length > 0 && state.event.trim().length > 0

export const getMetricAggregationLabel = (
aggregation: MetricAggregation,
): string =>
MEASUREMENT_OPTIONS.find((option) => option.value === aggregation)?.title ??
aggregation

export const getMetricSubline = (metric: Metric): string =>
`${getMetricAggregationLabel(metric.aggregation)} · ${
metric.definition.event
}`

export const getMetricUsageLabel = (experimentCount: number): string => {
if (experimentCount === 0) return 'Not in use'
if (experimentCount === 1) return '1 experiment'
return `${experimentCount} experiments`
}

export const metricToFormState = (metric: Metric): MetricFormState => ({
aggregation: metric.aggregation,
description: metric.description,
direction: metric.direction,
event: metric.definition.event,
name: metric.name,
})

export const buildMetricPayload = (
state: MetricFormState,
version: number = DEFAULT_METRIC_DEFINITION_VERSION,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
.metrics-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
border: 1px solid var(--color-border-default);
border-radius: var(--radius-lg);
overflow: hidden;

th {
padding: 10px 16px;
font-size: var(--font-body-sm-size);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
background: var(--color-surface-subtle);
text-align: left;
border-bottom: 1px solid var(--color-border-default);
}

td {
padding: 14px 16px;
font-size: var(--font-body-sm-size);
color: var(--color-text-default);
border-bottom: 1px solid var(--color-border-default);
vertical-align: top;
}

tbody tr:last-child td {
border-bottom: none;
}

th:nth-child(1),
td:nth-child(1) {
width: 26%;
}

th:nth-child(3),
td:nth-child(3),
th:nth-child(4),
td:nth-child(4) {
white-space: nowrap;
}

&__row {
transition: background var(--duration-fast) var(--easing-standard);

&:hover {
background: var(--color-surface-hover);
}
}

&__name {
display: block;
font-weight: var(--font-weight-medium);
color: var(--color-text-default);
}

&__subline {
display: flex;
align-items: center;
gap: 6px;
margin-top: 2px;
font-size: var(--font-caption-size);
color: var(--color-text-secondary);
}

&__subline-icon {
color: var(--color-text-secondary);
}

&__actions {
text-align: right;
white-space: nowrap;

.btn-with-icon + .btn-with-icon {
margin-left: 8px;
}
}
}
Loading
Loading