From d4b7cba3205062c917a104696bb801e4deffd920 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 5 Jun 2026 09:05:00 +0200 Subject: [PATCH 1/4] feat(experiments): create metric page metrics --- frontend/common/services/useMetric.ts | 42 ++++ frontend/common/theme/tokens.json | 6 + frontend/common/theme/tokens.ts | 26 +++ frontend/common/types/requests.ts | 17 ++ frontend/common/types/responses.ts | 22 ++ .../TokenReference.generated.stories.tsx | 50 +++++ frontend/scripts/generate-tokens.mjs | 68 +++--- .../CreateMetricForm/CreateMetricForm.scss | 166 +++++++++++++++ .../CreateMetricForm/CreateMetricForm.tsx | 195 ++++++++++++++++++ .../CreateMetricForm/__tests__/utils.test.ts | 93 +++++++++ .../experiments/CreateMetricForm/index.ts | 1 + .../experiments/CreateMetricForm/utils.ts | 89 ++++++++ .../ExperimentsFakeDoor.scss | 2 +- .../navigation/navbars/EnvironmentNavbar.tsx | 9 + .../web/components/pages/CreateMetricPage.tsx | 57 +++++ .../web/components/pages/ExperimentsPage.tsx | 31 ++- frontend/web/routes.js | 7 + frontend/web/styles/_tokens.scss | 6 + 18 files changed, 852 insertions(+), 35 deletions(-) create mode 100644 frontend/common/services/useMetric.ts create mode 100644 frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss create mode 100644 frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx create mode 100644 frontend/web/components/experiments/CreateMetricForm/__tests__/utils.test.ts create mode 100644 frontend/web/components/experiments/CreateMetricForm/index.ts create mode 100644 frontend/web/components/experiments/CreateMetricForm/utils.ts create mode 100644 frontend/web/components/pages/CreateMetricPage.tsx diff --git a/frontend/common/services/useMetric.ts b/frontend/common/services/useMetric.ts new file mode 100644 index 000000000000..48ab3014c256 --- /dev/null +++ b/frontend/common/services/useMetric.ts @@ -0,0 +1,42 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' +import Utils from 'common/utils/utils' +import transformCorePaging from 'common/transformCorePaging' + +export const metricService = service + .enhanceEndpoints({ addTagTypes: ['Metric'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + createMetric: builder.mutation({ + invalidatesTags: [{ id: 'LIST', type: 'Metric' }], + query: ({ body, environmentId }) => ({ + body, + method: 'POST', + url: `environments/${environmentId}/experiment-metrics/`, + }), + }), + deleteMetric: builder.mutation({ + invalidatesTags: [{ id: 'LIST', type: 'Metric' }], + query: ({ environmentId, metricId }) => ({ + method: 'DELETE', + url: `environments/${environmentId}/experiment-metrics/${metricId}/`, + }), + }), + getMetrics: builder.query({ + providesTags: [{ id: 'LIST', type: 'Metric' }], + query: ({ environmentId, ...rest }) => ({ + url: `environments/${environmentId}/experiment-metrics/?${Utils.toParam( + rest, + )}`, + }), + transformResponse: (res, _, req) => transformCorePaging(req, res), + }), + }), + }) + +export const { + useCreateMetricMutation, + useDeleteMetricMutation, + useGetMetricsQuery, +} = metricService diff --git a/frontend/common/theme/tokens.json b/frontend/common/theme/tokens.json index acd40cad8317..7004ce34a887 100644 --- a/frontend/common/theme/tokens.json +++ b/frontend/common/theme/tokens.json @@ -168,5 +168,11 @@ "standard": { "cssVar": "--easing-standard", "value": "cubic-bezier(0.2, 0, 0.38, 0.9)", "description": "Default for most transitions. Smooth deceleration. Use for elements moving within the page." }, "entrance": { "cssVar": "--easing-entrance", "value": "cubic-bezier(0.0, 0, 0.38, 0.9)", "description": "Elements entering the viewport. Decelerates into resting position. Modals, toasts, slide-ins." }, "exit": { "cssVar": "--easing-exit", "value": "cubic-bezier(0.2, 0, 1, 0.9)", "description": "Elements leaving the viewport. Accelerates out of view. Closing modals, dismissing toasts." } + }, + "font-weight": { + "regular": { "cssVar": "--font-weight-regular", "value": "400", "description": "Body copy, default text." }, + "medium": { "cssVar": "--font-weight-medium", "value": "500", "description": "Subtle emphasis. Labels, secondary headings, table headers." }, + "semibold": { "cssVar": "--font-weight-semibold", "value": "600", "description": "Strong emphasis. Card titles, selected states, section headings." }, + "bold": { "cssVar": "--font-weight-bold", "value": "700", "description": "Maximum emphasis. Page titles, key figures." } } } diff --git a/frontend/common/theme/tokens.ts b/frontend/common/theme/tokens.ts index 67159199be92..2b8839e6feaa 100644 --- a/frontend/common/theme/tokens.ts +++ b/frontend/common/theme/tokens.ts @@ -105,6 +105,26 @@ export const easing: Record = { value: 'var(--easing-standard)', }, } +// Font-weight +export const fontWeight: Record = { + 'bold': { + description: 'Maximum emphasis. Page titles, key figures.', + value: 'var(--font-weight-bold, 700)', + }, + 'medium': { + description: 'Subtle emphasis. Labels, secondary headings, table headers.', + value: 'var(--font-weight-medium, 500)', + }, + 'regular': { + description: 'Body copy, default text.', + value: 'var(--font-weight-regular, 400)', + }, + 'semibold': { + description: + 'Strong emphasis. Card titles, selected states, section headings.', + value: 'var(--font-weight-semibold, 600)', + }, +} // ============================================================================= // Flat token constants — semantic tokens as CSS value strings. @@ -233,3 +253,9 @@ export const easingEntrance = export const easingExit = 'var(--easing-exit, cubic-bezier(0.2, 0, 1, 0.9))' export const easingStandard = 'var(--easing-standard, cubic-bezier(0.2, 0, 0.38, 0.9))' + +// Font-weight +export const fontWeightBold = 'var(--font-weight-bold, 700)' +export const fontWeightMedium = 'var(--font-weight-medium, 500)' +export const fontWeightRegular = 'var(--font-weight-regular, 400)' +export const fontWeightSemibold = 'var(--font-weight-semibold, 600)' diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index c4c08e6a1944..863729500029 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -25,6 +25,9 @@ import { StageActionBody, ChangeRequest, ExperimentStatus, + MetricAggregation, + MetricDirection, + MetricDefinition, FlagsmithValue, TagStrategy, FeatureType, @@ -997,5 +1000,19 @@ export type Req = { } experimentAction: { environmentId: string; experimentId: number } deleteExperiment: { environmentId: string; experimentId: number } + getMetrics: PagedRequest<{ + environmentId: string + }> + createMetric: { + environmentId: string + body: { + name: string + description: string + aggregation: MetricAggregation + direction: MetricDirection + definition: MetricDefinition + } + } + deleteMetric: { environmentId: string; metricId: number } // END OF TYPES } diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 713cf47fac47..f435fb074ee4 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -581,6 +581,26 @@ export type ExperimentStatus = 'created' | 'running' | 'paused' | 'completed' export type ExperimentStatusCounts = Record +export type MetricAggregation = 'count' | 'sum' | 'mean' | 'occurrence' + +export type MetricDirection = 'increase' | 'decrease' | 'informational' + +export type MetricDefinition = { + event: string + filter?: string +} + +export type Metric = { + id: number + name: string + description: string + aggregation: MetricAggregation + direction: MetricDirection + definition: MetricDefinition + created_at: string + updated_at: string +} + export type ExperimentFeature = { id: number name: string @@ -1379,5 +1399,7 @@ export type Res = { status_counts?: ExperimentStatusCounts } experiment: Experiment + metric: Metric + metrics: PagedResponse // END OF TYPES } diff --git a/frontend/documentation/TokenReference.generated.stories.tsx b/frontend/documentation/TokenReference.generated.stories.tsx index 6c5809bb7dc3..15927ca8228f 100644 --- a/frontend/documentation/TokenReference.generated.stories.tsx +++ b/frontend/documentation/TokenReference.generated.stories.tsx @@ -728,6 +728,56 @@ export const AllTokens: StoryObj = { +

Font-weight

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TokenValueUsage
+ --font-weight-regular + + 400 + Body copy, default text.
+ --font-weight-medium + + 500 + Subtle emphasis. Labels, secondary headings, table headers.
+ --font-weight-semibold + + 600 + + Strong emphasis. Card titles, selected states, section headings. +
+ --font-weight-bold + + 700 + Maximum emphasis. Page titles, key figures.

Dark mode shadows

diff --git a/frontend/scripts/generate-tokens.mjs b/frontend/scripts/generate-tokens.mjs index bfe5ce88601a..447c46463136 100644 --- a/frontend/scripts/generate-tokens.mjs +++ b/frontend/scripts/generate-tokens.mjs @@ -27,18 +27,19 @@ const json = JSON.parse( // Helpers // --------------------------------------------------------------------------- -const kebabToCamel = (s) => - s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()) +const kebabToCamel = (s) => s.replace(/-([a-z0-9])/g, (_, c) => c.toUpperCase()) const sorted = (obj) => - Object.entries(obj).sort(([a], [b]) => a.localeCompare(b, undefined, { numeric: true })) + Object.entries(obj).sort(([a], [b]) => + a.localeCompare(b, undefined, { numeric: true }), + ) const esc = (s) => s.replace(/\\/g, '\\\\').replace(/'/g, "\\'") const lightVal = (e) => e.light ?? e.value const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1) -const NON_COLOUR = ['radius', 'shadow', 'duration', 'easing'] -const DESCRIBED = ['radius', 'shadow', 'duration', 'easing'] +const NON_COLOUR = ['radius', 'shadow', 'duration', 'easing', 'font-weight'] +const DESCRIBED = ['radius', 'shadow', 'duration', 'easing', 'font-weight'] // Chart colours are like colour tokens (light/dark) but not under "color" const CHART_CATEGORY = 'chart' @@ -82,7 +83,9 @@ function toPrimitiveRef(val) { if (hexMatch) return hexMatch // rgba match → oklch relative colour - const rgbaMatch = val.match(/^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/) + const rgbaMatch = val.match( + /^rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)$/, + ) if (rgbaMatch) { const rgb = `${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}` const alpha = rgbaMatch[4] @@ -161,7 +164,7 @@ function buildScssLines() { rootLines.push('') } - return { rootLines, darkLines } + return { darkLines, rootLines } } function buildTsDescribedLines() { @@ -170,7 +173,9 @@ function buildTsDescribedLines() { if (!json[cat]) continue const lines = [] lines.push(`// ${cap(cat)}`) - lines.push(`export const ${cat}: Record = {`) + lines.push( + `export const ${kebabToCamel(cat)}: Record = {`, + ) for (const [key, e] of sorted(json[cat])) { const v = esc(makeCssVar(e.cssVar, lightVal(e))) const d = esc(e.description || '') @@ -210,14 +215,22 @@ function cssVarToConstName(cssVar) { function buildFlatConstants() { const lines = [] - lines.push('// =============================================================================') + lines.push( + '// =============================================================================', + ) lines.push('// Flat token constants — semantic tokens as CSS value strings.') lines.push('// Use directly in any context that accepts a CSS value:') lines.push('// (recharts prop)') lines.push('// style={{ color: colorTextSecondary }} (inline style)') - lines.push('// border: `1px solid ${colorBorderDefault}` (template strings)') - lines.push('// var() resolves at render; theme toggle updates colours via CSS cascade.') - lines.push('// =============================================================================') + lines.push( + '// border: `1px solid ${colorBorderDefault}` (template strings)', + ) + lines.push( + '// var() resolves at render; theme toggle updates colours via CSS cascade.', + ) + lines.push( + '// =============================================================================', + ) lines.push('') // Colour tokens under json.color (border, icon, surface, text) @@ -297,7 +310,7 @@ function buildTableRows(title, entries, opts = {}) { // --------------------------------------------------------------------------- function generateScss() { - const { rootLines, darkLines } = buildScssLines() + const { darkLines, rootLines } = buildScssLines() const header = [ '// =============================================================================', @@ -356,18 +369,20 @@ function generateMcpStory() { if (json[CHART_CATEGORY]) { const chartData = Object.values(json[CHART_CATEGORY]).map((e) => ({ cssVar: e.cssVar, - value: e.light, description: e.description || '', + value: e.light, })) - tables.push(...buildTableRows('Chart colours', chartData, { showDescription: true })) + tables.push( + ...buildTableRows('Chart colours', chartData, { showDescription: true }), + ) } for (const cat of DESCRIBED) { if (!json[cat]) continue const data = Object.values(json[cat]).map((e) => ({ cssVar: e.cssVar, - value: lightVal(e), description: e.description || '', + value: lightVal(e), })) tables.push(...buildTableRows(cap(cat), data, { showDescription: true })) } @@ -397,7 +412,7 @@ function generateMcpStory() { ' >', ...tables, '', - "

Dark mode shadows

", + '

Dark mode shadows

', '

', ' Dark mode overrides use stronger opacity (0.20-0.40 vs 0.05-0.20).', ' Higher elevation surfaces should use lighter backgrounds', @@ -431,10 +446,10 @@ function generateUtilities() { // Colour utilities const colourMappings = { - surface: { prefix: 'bg-surface', property: 'background-color' }, - text: { prefix: 'text', property: 'color' }, border: { prefix: 'border', property: 'border-color' }, - icon: { prefix: 'icon', property: null }, // special: color + fill + icon: { prefix: 'icon', property: null }, + surface: { prefix: 'bg-surface', property: 'background-color' }, + text: { prefix: 'text', property: 'color' }, // special: color + fill } for (const [category, entries] of Object.entries(json.color)) { @@ -445,7 +460,9 @@ function generateUtilities() { for (const [key, e] of sorted(entries)) { const cls = `${mapping.prefix}-${key}` if (category === 'icon') { - lines.push(`.${cls} { color: var(${e.cssVar}); fill: var(${e.cssVar}); }`) + lines.push( + `.${cls} { color: var(${e.cssVar}); fill: var(${e.cssVar}); }`, + ) } else { lines.push(`.${cls} { ${mapping.property}: var(${e.cssVar}); }`) } @@ -493,7 +510,10 @@ function generateUtilities() { const scssPath = resolve(ROOT, 'web/styles/_tokens.scss') const utilitiesPath = resolve(ROOT, 'web/styles/_token-utilities.scss') const tsPath = resolve(ROOT, 'common/theme/tokens.ts') -const storyPath = resolve(ROOT, 'documentation/TokenReference.generated.stories.tsx') +const storyPath = resolve( + ROOT, + 'documentation/TokenReference.generated.stories.tsx', +) writeFileSync(scssPath, generateScss(), 'utf-8') writeFileSync(utilitiesPath, generateUtilities(), 'utf-8') @@ -523,5 +543,7 @@ console.log(` ${utilitiesPath}`) console.log(` ${tsPath}`) console.log(` ${storyPath}`) console.log( - ` ${colorCount} colour + ${nonColorCount} non-colour = ${colorCount + nonColorCount} tokens`, + ` ${colorCount} colour + ${nonColorCount} non-colour = ${ + colorCount + nonColorCount + } tokens`, ) diff --git a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss new file mode 100644 index 000000000000..624bacb74e7e --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss @@ -0,0 +1,166 @@ +.create-metric-form { + display: flex; + flex-direction: column; + gap: 24px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-xl); + background: var(--color-surface-default); + padding: 24px; + box-shadow: var(--shadow-sm); + + &__header { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 12px; + border-bottom: 1px solid var(--color-border-default); + } + + &__title { + font-size: var(--font-heading-sm-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__subtitle { + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + } + + &__field { + display: flex; + flex-direction: column; + gap: 6px; + } + + &__label { + font-size: var(--font-body-sm-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-default); + margin: 0; + } + + &__measurement-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + } + + &__measurement-card { + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 12px; + padding: 14px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-default); + text-align: left; + cursor: pointer; + transition: all var(--duration-fast) var(--easing-standard); + + &:hover { + border-color: var(--color-border-action); + } + + &--selected { + border-color: var(--color-border-action); + background: var(--color-surface-action-subtle); + box-shadow: 0 0 0 1px var(--color-border-action); + } + } + + &__measurement-head { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__measurement-title { + font-size: var(--font-body-size); + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__measurement-desc { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } + + &__measurement-ex { + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + font-style: italic; + } + + &__direction-group { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &__direction-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-full); + cursor: pointer; + font-size: var(--font-body-sm-size); + color: var(--color-text-default); + background: var(--color-surface-default); + transition: all var(--duration-fast) var(--easing-standard); + + input { + display: none; + } + + &:hover { + border-color: var(--color-border-action); + } + + &--selected { + border-color: var(--color-border-action); + background: var(--color-surface-action-subtle); + color: var(--color-text-action); + font-weight: var(--font-weight-semibold); + } + } + + &__hint { + color: var(--color-text-secondary); + } + + &__source { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + padding: 16px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-subtle); + } + + &__source-col { + display: flex; + flex-direction: column; + gap: 4px; + } + + &__sublabel { + font-size: var(--font-caption-size); + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; + } + + &__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 8px; + } +} diff --git a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx new file mode 100644 index 000000000000..80f3b43da3ba --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx @@ -0,0 +1,195 @@ +import React, { FC, useState } from 'react' +import Button from 'components/base/forms/Button' +import Input from 'components/base/forms/Input' +import { + canSubmitMetric, + DEFAULT_METRIC_FORM_STATE, + DIRECTION_OPTIONS, + MEASUREMENT_OPTIONS, + MetricFormState, +} from './utils' +import './CreateMetricForm.scss' + +type CreateMetricFormProps = { + isSaving?: boolean + onCancel: () => void + onSubmit: (state: MetricFormState) => void +} + +const CreateMetricForm: FC = ({ + isSaving, + onCancel, + onSubmit, +}) => { + const [state, setState] = useState(DEFAULT_METRIC_FORM_STATE) + + const update = (patch: Partial) => + setState((prev) => ({ ...prev, ...patch })) + + const handleCancel = () => { + setState(DEFAULT_METRIC_FORM_STATE) + onCancel() + } + + const handleSubmit = () => { + if (!canSubmitMetric(state) || isSaving) return + onSubmit(state) + } + + return ( +

+
+ Create Metric + + Metrics capture the outcomes your experiments measure. + +
+ +
+ + ) => + update({ name: e.target.value }) + } + placeholder='e.g. Signup Completion Rate' + /> +
+ +
+ + ) => + update({ description: e.target.value }) + } + placeholder='What does this metric measure?' + /> +
+ +
+ +
+ {MEASUREMENT_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+ +
+ {DIRECTION_OPTIONS.map((opt) => ( + + ))} +
+
+ +
+ + + Where this metric is collected from. Reads from your connected + warehouse. + +
+
+ + ) => + update({ event: e.target.value }) + } + placeholder='e.g. checkout_completed' + /> +
+
+ + ) => + update({ filter: e.target.value }) + } + placeholder="e.g. status = 'complete'" + /> +
+
+
+ +
+ + +
+
+ ) +} + +CreateMetricForm.displayName = 'CreateMetricForm' +export default CreateMetricForm diff --git a/frontend/web/components/experiments/CreateMetricForm/__tests__/utils.test.ts b/frontend/web/components/experiments/CreateMetricForm/__tests__/utils.test.ts new file mode 100644 index 000000000000..51dd5f64f6fc --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricForm/__tests__/utils.test.ts @@ -0,0 +1,93 @@ +import { + buildMetricPayload, + canSubmitMetric, + DEFAULT_METRIC_FORM_STATE, + MetricFormState, +} from 'components/experiments/CreateMetricForm/utils' + +describe('canSubmitMetric', () => { + it('returns false when the name is empty', () => { + const state: MetricFormState = { + ...DEFAULT_METRIC_FORM_STATE, + event: 'checkout_completed', + name: ' ', + } + + expect(canSubmitMetric(state)).toBe(false) + }) + + it('returns false when the event is empty', () => { + const state: MetricFormState = { + ...DEFAULT_METRIC_FORM_STATE, + event: '', + name: 'Signup completion', + } + + expect(canSubmitMetric(state)).toBe(false) + }) + + it('returns true when both name and event are present', () => { + const state: MetricFormState = { + ...DEFAULT_METRIC_FORM_STATE, + event: 'checkout_completed', + name: 'Signup completion', + } + + expect(canSubmitMetric(state)).toBe(true) + }) +}) + +describe('buildMetricPayload', () => { + it('trims fields and maps state to the create payload', () => { + const state: MetricFormState = { + aggregation: 'count', + description: ' Purchases made ', + direction: 'decrease', + event: ' checkout_completed ', + filter: '', + name: ' Purchases ', + } + + expect(buildMetricPayload(state)).toEqual({ + aggregation: 'count', + definition: { event: 'checkout_completed' }, + description: 'Purchases made', + direction: 'decrease', + name: 'Purchases', + }) + }) + + it('includes the trimmed filter in the definition when provided', () => { + const state: MetricFormState = { + ...DEFAULT_METRIC_FORM_STATE, + event: 'checkout_completed', + filter: " status = 'complete' ", + name: 'Purchases', + } + + expect(buildMetricPayload(state).definition).toEqual({ + event: 'checkout_completed', + filter: "status = 'complete'", + }) + }) + + it('omits the filter from the definition when blank', () => { + const state: MetricFormState = { + ...DEFAULT_METRIC_FORM_STATE, + event: 'checkout_completed', + filter: ' ', + name: 'Purchases', + } + + expect(buildMetricPayload(state).definition).toEqual({ + event: 'checkout_completed', + }) + }) +}) + +describe('DEFAULT_METRIC_FORM_STATE', () => { + it('defaults to an occurrence metric where higher is better', () => { + expect(DEFAULT_METRIC_FORM_STATE.aggregation).toBe('occurrence') + expect(DEFAULT_METRIC_FORM_STATE.direction).toBe('increase') + }) +}) diff --git a/frontend/web/components/experiments/CreateMetricForm/index.ts b/frontend/web/components/experiments/CreateMetricForm/index.ts new file mode 100644 index 000000000000..12ff82544941 --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricForm/index.ts @@ -0,0 +1 @@ +export { default } from './CreateMetricForm' diff --git a/frontend/web/components/experiments/CreateMetricForm/utils.ts b/frontend/web/components/experiments/CreateMetricForm/utils.ts new file mode 100644 index 000000000000..c2c50a268e3d --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricForm/utils.ts @@ -0,0 +1,89 @@ +import { Req } from 'common/types/requests' +import { + MetricAggregation, + MetricDefinition, + MetricDirection, +} from 'common/types/responses' + +export type MetricFormState = { + name: string + description: string + aggregation: MetricAggregation + direction: MetricDirection + event: string + filter: string +} + +export const DEFAULT_METRIC_FORM_STATE: MetricFormState = { + aggregation: 'occurrence', + description: '', + direction: 'increase', + event: '', + filter: '', + name: '', +} + +export type MeasurementOption = { + value: MetricAggregation + title: string + description: string + example: string +} + +export const MEASUREMENT_OPTIONS: MeasurementOption[] = [ + { + description: 'Whether an event is seen at least once', + example: 'ex: Signup completion', + title: 'Occurrence', + value: 'occurrence', + }, + { + description: 'Number of times an event occurred', + example: 'ex: Number of purchases', + title: 'Count', + value: 'count', + }, + { + description: 'Total of a numeric value across events', + example: 'ex: Total revenue', + title: 'Sum', + value: 'sum', + }, + { + description: 'Average of a numeric value across events', + example: 'ex: Average order value', + title: 'Mean', + value: 'mean', + }, +] + +export type DirectionOption = { + value: MetricDirection + label: string +} + +export const DIRECTION_OPTIONS: DirectionOption[] = [ + { label: 'Higher is better', value: 'increase' }, + { label: 'Lower is better', value: 'decrease' }, + { label: 'Neither — informational only', value: 'informational' }, +] + +export const canSubmitMetric = (state: MetricFormState): boolean => + state.name.trim().length > 0 && state.event.trim().length > 0 + +export const buildMetricPayload = ( + state: MetricFormState, +): Req['createMetric']['body'] => { + const definition: MetricDefinition = { event: state.event.trim() } + const filter = state.filter.trim() + if (filter) { + definition.filter = filter + } + return { + aggregation: state.aggregation, + definition, + description: state.description.trim(), + direction: state.direction, + name: state.name.trim(), + } +} diff --git a/frontend/web/components/experiments/ExperimentsFakeDoor/ExperimentsFakeDoor.scss b/frontend/web/components/experiments/ExperimentsFakeDoor/ExperimentsFakeDoor.scss index eb33d69779b7..78eb8eed0a82 100644 --- a/frontend/web/components/experiments/ExperimentsFakeDoor/ExperimentsFakeDoor.scss +++ b/frontend/web/components/experiments/ExperimentsFakeDoor/ExperimentsFakeDoor.scss @@ -44,7 +44,7 @@ &__cta-thanks { font-size: var(--font-heading-sm-size, 18px); - font-weight: 700; + font-weight: var(--font-weight-bold); white-space: nowrap; } diff --git a/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx b/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx index 3c834ad5303e..8d2bfa2c68ed 100644 --- a/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx +++ b/frontend/web/components/navigation/navbars/EnvironmentNavbar.tsx @@ -98,6 +98,15 @@ const EnvironmentNavbar: FC = ({ ) )} + {Utils.getFlagsmithHasFeature('experiment_metrics') && ( + + Metrics + + )} { + const { environmentId, projectId } = useRouteContext() + const history = useHistory() + const [formKey, setFormKey] = useState(0) + const [createMetric, { isLoading: isSaving }] = useCreateMetricMutation() + + if (!environmentId || !projectId) return null + + if (!Utils.getFlagsmithHasFeature('experiment_metrics')) { + history.replace( + `/project/${projectId}/environment/${environmentId}/features`, + ) + return null + } + + const resetForm = () => setFormKey((key) => key + 1) + + const handleSubmit = async (state: MetricFormState) => { + try { + await createMetric({ + body: buildMetricPayload(state), + environmentId, + }).unwrap() + toast('Metric created successfully') + resetForm() + } catch { + toast('Failed to create metric', 'danger') + } + } + + return ( +
+ + +
+ ) +} + +CreateMetricPage.displayName = 'CreateMetricPage' +export default CreateMetricPage diff --git a/frontend/web/components/pages/ExperimentsPage.tsx b/frontend/web/components/pages/ExperimentsPage.tsx index 0a0e576d04d3..aadd581e63f5 100644 --- a/frontend/web/components/pages/ExperimentsPage.tsx +++ b/frontend/web/components/pages/ExperimentsPage.tsx @@ -212,19 +212,28 @@ const ExperimentsPage: FC = () => { ) } + const renderCta = () => { + if (hasWarehouse) { + return ( + + ) + } + if (experimentCount > 0) { + return ( + + ) + } + return undefined + } + return (
- setIsCreating(true)}> - - Create Experiment - - ) : undefined - } - /> + {renderBody()}
) diff --git a/frontend/web/routes.js b/frontend/web/routes.js index cc9802e07536..a194ac187364 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -47,6 +47,7 @@ import CreateReleasePipelinePage from './components/pages/CreateReleasePipelineP import ReleasePipelineDetailPage from './components/pages/ReleasePipelineDetailPage' import SegmentPage from './components/pages/SegmentPage' import ExperimentsPage from './components/pages/ExperimentsPage' +import CreateMetricPage from './components/pages/CreateMetricPage' import ReleaseManagerPage from './components/pages/ReleaseManagerPage' import FlagEnvironmentsPage from './components/pages/FlagEnvironmentsPage' import ExecutiveViewPage from './components/pages/ExecutiveViewPage' @@ -102,6 +103,7 @@ export const routes = { 'lifecycle': '/project/:projectId/lifecycle/:section?', 'login': '/login', 'maintenance': '/maintenance', + 'metrics': '/project/:projectId/environment/:environmentId/metrics', 'not-found': '/404', 'oauth': '/oauth/:type', 'oauth-authorize': '/oauth/authorize', @@ -169,6 +171,11 @@ export default ( exact component={ExperimentsPage} /> + Date: Fri, 5 Jun 2026 14:55:21 +0200 Subject: [PATCH 2/4] feat(experimentation): list skeleton and ui adjustements --- frontend/common/types/responses.ts | 4 +- .../CreateMetricForm/CreateMetricForm.scss | 26 +-- .../CreateMetricForm/CreateMetricForm.tsx | 23 --- .../CreateMetricForm/__tests__/utils.test.ts | 28 ++- .../experiments/CreateMetricForm/utils.ts | 17 +- .../web/components/pages/CreateMetricPage.tsx | 57 ------ .../web/components/pages/MetricsPage.scss | 103 ++++++++++ frontend/web/components/pages/MetricsPage.tsx | 191 ++++++++++++++++++ frontend/web/routes.js | 4 +- 9 files changed, 321 insertions(+), 132 deletions(-) delete mode 100644 frontend/web/components/pages/CreateMetricPage.tsx create mode 100644 frontend/web/components/pages/MetricsPage.scss create mode 100644 frontend/web/components/pages/MetricsPage.tsx diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index f435fb074ee4..0b017264e33f 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -583,11 +583,11 @@ export type ExperimentStatusCounts = Record export type MetricAggregation = 'count' | 'sum' | 'mean' | 'occurrence' -export type MetricDirection = 'increase' | 'decrease' | 'informational' +export type MetricDirection = 'up' | 'down' | 'informational' export type MetricDefinition = { + version: number event: string - filter?: string } export type Metric = { diff --git a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss index 624bacb74e7e..351d031cdc81 100644 --- a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss +++ b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss @@ -2,29 +2,9 @@ display: flex; flex-direction: column; gap: 24px; - border: 1px solid var(--color-border-default); - border-radius: var(--radius-xl); - background: var(--color-surface-default); - padding: 24px; - box-shadow: var(--shadow-sm); - &__header { - display: flex; - flex-direction: column; - gap: 4px; - padding-bottom: 12px; - border-bottom: 1px solid var(--color-border-default); - } - - &__title { - font-size: var(--font-heading-sm-size); - font-weight: var(--font-weight-semibold); - color: var(--color-text-default); - } - - &__subtitle { - font-size: var(--font-body-sm-size); - color: var(--color-text-secondary); + input { + font-weight: var(--font-weight-regular); } &__field { @@ -134,7 +114,7 @@ &__source { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr; gap: 16px; padding: 16px; border: 1px solid var(--color-border-default); diff --git a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx index 80f3b43da3ba..58fd2bae5365 100644 --- a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx +++ b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx @@ -38,13 +38,6 @@ const CreateMetricForm: FC = ({ return (
-
- Create Metric - - Metrics capture the outcomes your experiments measure. - -
-
-
- - ) => - update({ filter: e.target.value }) - } - placeholder="e.g. status = 'complete'" - /> -
diff --git a/frontend/web/components/experiments/CreateMetricForm/__tests__/utils.test.ts b/frontend/web/components/experiments/CreateMetricForm/__tests__/utils.test.ts index 51dd5f64f6fc..54a73304b4ad 100644 --- a/frontend/web/components/experiments/CreateMetricForm/__tests__/utils.test.ts +++ b/frontend/web/components/experiments/CreateMetricForm/__tests__/utils.test.ts @@ -1,6 +1,7 @@ import { buildMetricPayload, canSubmitMetric, + DEFAULT_METRIC_DEFINITION_VERSION, DEFAULT_METRIC_FORM_STATE, MetricFormState, } from 'components/experiments/CreateMetricForm/utils' @@ -42,52 +43,49 @@ describe('buildMetricPayload', () => { const state: MetricFormState = { aggregation: 'count', description: ' Purchases made ', - direction: 'decrease', + direction: 'down', event: ' checkout_completed ', - filter: '', name: ' Purchases ', } - expect(buildMetricPayload(state)).toEqual({ + expect(buildMetricPayload(state, 1)).toEqual({ aggregation: 'count', - definition: { event: 'checkout_completed' }, + definition: { event: 'checkout_completed', version: 1 }, description: 'Purchases made', - direction: 'decrease', + direction: 'down', name: 'Purchases', }) }) - it('includes the trimmed filter in the definition when provided', () => { + it('stamps the definition with the provided version', () => { const state: MetricFormState = { ...DEFAULT_METRIC_FORM_STATE, event: 'checkout_completed', - filter: " status = 'complete' ", name: 'Purchases', } - expect(buildMetricPayload(state).definition).toEqual({ + expect(buildMetricPayload(state, 2).definition).toEqual({ event: 'checkout_completed', - filter: "status = 'complete'", + version: 2, }) }) - it('omits the filter from the definition when blank', () => { + it('falls back to the default version when none is given', () => { const state: MetricFormState = { ...DEFAULT_METRIC_FORM_STATE, event: 'checkout_completed', - filter: ' ', name: 'Purchases', } - expect(buildMetricPayload(state).definition).toEqual({ - event: 'checkout_completed', - }) + expect(buildMetricPayload(state).definition.version).toBe( + DEFAULT_METRIC_DEFINITION_VERSION, + ) }) }) describe('DEFAULT_METRIC_FORM_STATE', () => { it('defaults to an occurrence metric where higher is better', () => { expect(DEFAULT_METRIC_FORM_STATE.aggregation).toBe('occurrence') - expect(DEFAULT_METRIC_FORM_STATE.direction).toBe('increase') + expect(DEFAULT_METRIC_FORM_STATE.direction).toBe('up') }) }) diff --git a/frontend/web/components/experiments/CreateMetricForm/utils.ts b/frontend/web/components/experiments/CreateMetricForm/utils.ts index c2c50a268e3d..6adf06968408 100644 --- a/frontend/web/components/experiments/CreateMetricForm/utils.ts +++ b/frontend/web/components/experiments/CreateMetricForm/utils.ts @@ -11,15 +11,15 @@ export type MetricFormState = { aggregation: MetricAggregation direction: MetricDirection event: string - filter: string } +export const DEFAULT_METRIC_DEFINITION_VERSION = 1 + export const DEFAULT_METRIC_FORM_STATE: MetricFormState = { aggregation: 'occurrence', description: '', - direction: 'increase', + direction: 'up', event: '', - filter: '', name: '', } @@ -63,8 +63,8 @@ export type DirectionOption = { } export const DIRECTION_OPTIONS: DirectionOption[] = [ - { label: 'Higher is better', value: 'increase' }, - { label: 'Lower is better', value: 'decrease' }, + { label: 'Higher is better', value: 'up' }, + { label: 'Lower is better', value: 'down' }, { label: 'Neither — informational only', value: 'informational' }, ] @@ -73,12 +73,9 @@ export const canSubmitMetric = (state: MetricFormState): boolean => export const buildMetricPayload = ( state: MetricFormState, + version: number = DEFAULT_METRIC_DEFINITION_VERSION, ): Req['createMetric']['body'] => { - const definition: MetricDefinition = { event: state.event.trim() } - const filter = state.filter.trim() - if (filter) { - definition.filter = filter - } + const definition: MetricDefinition = { event: state.event.trim(), version } return { aggregation: state.aggregation, definition, diff --git a/frontend/web/components/pages/CreateMetricPage.tsx b/frontend/web/components/pages/CreateMetricPage.tsx deleted file mode 100644 index 1c488c2496ba..000000000000 --- a/frontend/web/components/pages/CreateMetricPage.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { FC, useState } from 'react' -import { useHistory } from 'react-router-dom' -import Utils from 'common/utils/utils' -import { useRouteContext } from 'components/providers/RouteContext' -import { useCreateMetricMutation } from 'common/services/useMetric' -import PageTitle from 'components/PageTitle' -import CreateMetricForm from 'components/experiments/CreateMetricForm' -import { - buildMetricPayload, - MetricFormState, -} from 'components/experiments/CreateMetricForm/utils' - -const CreateMetricPage: FC = () => { - const { environmentId, projectId } = useRouteContext() - const history = useHistory() - const [formKey, setFormKey] = useState(0) - const [createMetric, { isLoading: isSaving }] = useCreateMetricMutation() - - if (!environmentId || !projectId) return null - - if (!Utils.getFlagsmithHasFeature('experiment_metrics')) { - history.replace( - `/project/${projectId}/environment/${environmentId}/features`, - ) - return null - } - - const resetForm = () => setFormKey((key) => key + 1) - - const handleSubmit = async (state: MetricFormState) => { - try { - await createMetric({ - body: buildMetricPayload(state), - environmentId, - }).unwrap() - toast('Metric created successfully') - resetForm() - } catch { - toast('Failed to create metric', 'danger') - } - } - - return ( -
- - -
- ) -} - -CreateMetricPage.displayName = 'CreateMetricPage' -export default CreateMetricPage diff --git a/frontend/web/components/pages/MetricsPage.scss b/frontend/web/components/pages/MetricsPage.scss new file mode 100644 index 000000000000..0a1ce52a6953 --- /dev/null +++ b/frontend/web/components/pages/MetricsPage.scss @@ -0,0 +1,103 @@ +.metrics-page { + &__banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 16px; + margin-bottom: 16px; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + background: var(--color-surface-subtle); + } + + &__banner-text { + display: flex; + align-items: center; + gap: 8px; + color: var(--color-text-secondary); + font-size: var(--font-body-sm-size); + + strong { + color: var(--color-text-default); + font-weight: var(--font-weight-semibold); + } + } + + &__banner-icon { + color: var(--color-text-action); + } + + &__banner-link { + flex-shrink: 0; + color: var(--color-text-action); + font-weight: var(--font-weight-medium); + cursor: pointer; + + &:hover { + text-decoration: underline; + } + } + + &__controls { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + } + + &__search { + flex: 1; + + > div { + width: 100%; + } + + input { + width: 100%; + } + } + + &__list { + display: flex; + flex-direction: column; + border: 1px solid var(--color-border-default); + border-radius: var(--radius-md); + overflow: hidden; + } + + &__row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 14px 16px; + + & + & { + border-top: 1px solid var(--color-border-default); + } + } + + &__row-main { + display: flex; + flex-direction: column; + gap: 2px; + } + + &__row-name { + font-weight: var(--font-weight-semibold); + color: var(--color-text-default); + } + + &__row-desc { + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + } + + &__row-agg { + flex-shrink: 0; + text-transform: capitalize; + font-size: var(--font-caption-size); + color: var(--color-text-secondary); + } +} diff --git a/frontend/web/components/pages/MetricsPage.tsx b/frontend/web/components/pages/MetricsPage.tsx new file mode 100644 index 000000000000..ab6b47974cba --- /dev/null +++ b/frontend/web/components/pages/MetricsPage.tsx @@ -0,0 +1,191 @@ +import { ChangeEvent, FC, useEffect, useState } from 'react' +import { useHistory, useLocation } from 'react-router-dom' +import Utils from 'common/utils/utils' +import { useRouteContext } from 'components/providers/RouteContext' +import { + useCreateMetricMutation, + useGetMetricsQuery, +} from 'common/services/useMetric' +import { useGetWarehouseConnectionsQuery } from 'common/services/useWarehouseConnection' +import { WarehouseType } from 'common/types/responses' +import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' +import PageTitle from 'components/PageTitle' +import CreateMetricForm from 'components/experiments/CreateMetricForm' +import { + buildMetricPayload, + DEFAULT_METRIC_DEFINITION_VERSION, + MetricFormState, +} from 'components/experiments/CreateMetricForm/utils' +import './MetricsPage.scss' + +const WAREHOUSE_TYPE_LABEL: Record = { + clickhouse: 'ClickHouse', + flagsmith: 'Flagsmith', + snowflake: 'Snowflake', +} + +const MetricsPage: FC = () => { + const { environmentId, projectId } = useRouteContext() + const history = useHistory() + const location = useLocation() + const [searchInput, setSearchInput] = useState('') + const [createMetric, { isLoading: isSaving }] = useCreateMetricMutation() + + const isEnabled = Utils.getFlagsmithHasFeature('experiment_metrics') + const isCreating = + new URLSearchParams(location.search).get('create') === 'true' + + useEffect(() => { + if (!isEnabled && environmentId && projectId) { + history.replace( + `/project/${projectId}/environment/${environmentId}/features`, + ) + } + }, [isEnabled, environmentId, projectId, history]) + + const { data: metricsData, isLoading } = useGetMetricsQuery( + { environmentId: environmentId ?? '' }, + { skip: !environmentId || !isEnabled }, + ) + + const { data: warehouseConnections } = useGetWarehouseConnectionsQuery( + { environmentId: environmentId ?? '' }, + { skip: !environmentId || !isEnabled }, + ) + + if (!environmentId || !projectId || !isEnabled) return null + + const connection = warehouseConnections?.[0] + const warehouseLabel = connection + ? WAREHOUSE_TYPE_LABEL[connection.warehouse_type] ?? + connection.warehouse_type + : '' + const settingsUrl = `/project/${projectId}/environment/${environmentId}/settings?tab=warehouse` + const metricsPath = `/project/${projectId}/environment/${environmentId}/metrics` + const metrics = metricsData?.results + + const handleSubmit = async (state: MetricFormState) => { + const version = + Number(Utils.getFlagsmithValue('experiment_metrics')) || + DEFAULT_METRIC_DEFINITION_VERSION + try { + await createMetric({ + body: buildMetricPayload(state, version), + environmentId, + }).unwrap() + toast('Metric created successfully') + history.push(metricsPath) + } catch { + toast('Failed to create metric', 'danger') + } + } + + if (isCreating) { + return ( +
+ + Metrics capture the outcomes your experiments measure. + + history.push(metricsPath)} + onSubmit={handleSubmit} + /> +
+ ) + } + + const renderList = () => { + if (isLoading) { + return ( +
+ +
+ ) + } + if (!metrics?.length) { + return ( +
+ +
No metrics yet
+

+ Create your first metric to measure experiment outcomes. +

+
+ ) + } + return ( +
+ {metrics.map((metric) => ( +
+
+ {metric.name} + {!!metric.description && ( + + {metric.description} + + )} +
+ {metric.aggregation} +
+ ))} +
+ ) + } + + return ( +
+ + Metrics track the outcomes you measure across experiments. Each + experiment picks one as its primary; the rest are observed for context. + + + {!!connection && ( +
+ + + Metrics are computed from your {warehouseLabel}{' '} + warehouse. + + history.push(settingsUrl)} + > + Manage connection + +
+ )} + +
+
+ ) => + setSearchInput(Utils.safeParseEventValue(e)) + } + placeholder='Search metrics...' + search + /> +
+ +
+ + {renderList()} +
+ ) +} + +MetricsPage.displayName = 'MetricsPage' +export default MetricsPage diff --git a/frontend/web/routes.js b/frontend/web/routes.js index a194ac187364..604ae1220d0b 100644 --- a/frontend/web/routes.js +++ b/frontend/web/routes.js @@ -47,7 +47,7 @@ import CreateReleasePipelinePage from './components/pages/CreateReleasePipelineP import ReleasePipelineDetailPage from './components/pages/ReleasePipelineDetailPage' import SegmentPage from './components/pages/SegmentPage' import ExperimentsPage from './components/pages/ExperimentsPage' -import CreateMetricPage from './components/pages/CreateMetricPage' +import MetricsPage from './components/pages/MetricsPage' import ReleaseManagerPage from './components/pages/ReleaseManagerPage' import FlagEnvironmentsPage from './components/pages/FlagEnvironmentsPage' import ExecutiveViewPage from './components/pages/ExecutiveViewPage' @@ -174,7 +174,7 @@ export default ( Date: Fri, 5 Jun 2026 14:58:47 +0200 Subject: [PATCH 3/4] feat(experimentation): improved no metrics placeholder --- frontend/web/components/pages/MetricsPage.tsx | 117 ++++++++++-------- 1 file changed, 62 insertions(+), 55 deletions(-) diff --git a/frontend/web/components/pages/MetricsPage.tsx b/frontend/web/components/pages/MetricsPage.tsx index ab6b47974cba..58b06522206c 100644 --- a/frontend/web/components/pages/MetricsPage.tsx +++ b/frontend/web/components/pages/MetricsPage.tsx @@ -96,7 +96,7 @@ const MetricsPage: FC = () => { ) } - const renderList = () => { + const renderBody = () => { if (isLoading) { return (
@@ -113,28 +113,73 @@ const MetricsPage: FC = () => { className='text-muted mb-3 d-block mx-auto' />
No metrics yet
-

+

Create your first metric to measure experiment outcomes.

+
) } return ( -
- {metrics.map((metric) => ( -
-
- {metric.name} - {!!metric.description && ( - - {metric.description} - - )} -
- {metric.aggregation} + <> + {!!connection && ( +
+ + + Metrics are computed from your {warehouseLabel}{' '} + warehouse. + + history.push(settingsUrl)} + > + Manage connection +
- ))} -
+ )} + +
+
+ ) => + setSearchInput(Utils.safeParseEventValue(e)) + } + placeholder='Search metrics...' + search + /> +
+ +
+ +
+ {metrics.map((metric) => ( +
+
+ {metric.name} + {!!metric.description && ( + + {metric.description} + + )} +
+ + {metric.aggregation} + +
+ ))} +
+ ) } @@ -144,45 +189,7 @@ const MetricsPage: FC = () => { Metrics track the outcomes you measure across experiments. Each experiment picks one as its primary; the rest are observed for context. - - {!!connection && ( -
- - - Metrics are computed from your {warehouseLabel}{' '} - warehouse. - - history.push(settingsUrl)} - > - Manage connection - -
- )} - -
-
- ) => - setSearchInput(Utils.safeParseEventValue(e)) - } - placeholder='Search metrics...' - search - /> -
- -
- - {renderList()} + {renderBody()}
) } From 98961534684dfffec5eabf6ad43557b934a1b294 Mon Sep 17 00:00:00 2001 From: wadii Date: Fri, 5 Jun 2026 15:07:09 +0200 Subject: [PATCH 4/4] feat(experimentation): addressed review comment on accessibility --- .../CreateMetricForm/CreateMetricForm.scss | 6 ++++++ .../CreateMetricForm/CreateMetricForm.tsx | 13 +++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss index 351d031cdc81..8c78a77252cc 100644 --- a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss +++ b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss @@ -39,6 +39,10 @@ cursor: pointer; transition: all var(--duration-fast) var(--easing-standard); + input { + display: none; + } + &:hover { border-color: var(--color-border-action); } @@ -64,11 +68,13 @@ &__measurement-desc { font-size: var(--font-caption-size); + font-weight: var(--font-weight-regular); color: var(--color-text-secondary); } &__measurement-ex { font-size: var(--font-caption-size); + font-weight: var(--font-weight-regular); color: var(--color-text-secondary); font-style: italic; } diff --git a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx index 58fd2bae5365..8f380cefc939 100644 --- a/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx +++ b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx @@ -75,16 +75,21 @@ const CreateMetricForm: FC = ({
{MEASUREMENT_OPTIONS.map((opt) => ( - + ))}