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..0b017264e33f 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 = 'up' | 'down' | 'informational' + +export type MetricDefinition = { + version: number + event: 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..8c78a77252cc --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.scss @@ -0,0 +1,152 @@ +.create-metric-form { + display: flex; + flex-direction: column; + gap: 24px; + + input { + font-weight: var(--font-weight-regular); + } + + &__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); + + input { + display: none; + } + + &: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); + 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; + } + + &__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; + 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..8f380cefc939 --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricForm/CreateMetricForm.tsx @@ -0,0 +1,177 @@ +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 ( +

+
+ + ) => + 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' + /> +
+
+
+ +
+ + +
+
+ ) +} + +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..54a73304b4ad --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricForm/__tests__/utils.test.ts @@ -0,0 +1,91 @@ +import { + buildMetricPayload, + canSubmitMetric, + DEFAULT_METRIC_DEFINITION_VERSION, + 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: 'down', + event: ' checkout_completed ', + name: ' Purchases ', + } + + expect(buildMetricPayload(state, 1)).toEqual({ + aggregation: 'count', + definition: { event: 'checkout_completed', version: 1 }, + description: 'Purchases made', + direction: 'down', + name: 'Purchases', + }) + }) + + it('stamps the definition with the provided version', () => { + const state: MetricFormState = { + ...DEFAULT_METRIC_FORM_STATE, + event: 'checkout_completed', + name: 'Purchases', + } + + expect(buildMetricPayload(state, 2).definition).toEqual({ + event: 'checkout_completed', + version: 2, + }) + }) + + it('falls back to the default version when none is given', () => { + const state: MetricFormState = { + ...DEFAULT_METRIC_FORM_STATE, + event: 'checkout_completed', + name: 'Purchases', + } + + 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('up') + }) +}) 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..6adf06968408 --- /dev/null +++ b/frontend/web/components/experiments/CreateMetricForm/utils.ts @@ -0,0 +1,86 @@ +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 +} + +export const DEFAULT_METRIC_DEFINITION_VERSION = 1 + +export const DEFAULT_METRIC_FORM_STATE: MetricFormState = { + aggregation: 'occurrence', + description: '', + direction: 'up', + event: '', + 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: 'up' }, + { label: 'Lower is better', value: 'down' }, + { 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, + version: number = DEFAULT_METRIC_DEFINITION_VERSION, +): Req['createMetric']['body'] => { + const definition: MetricDefinition = { event: state.event.trim(), version } + 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 renderCta = () => { + if (hasWarehouse) { + return ( + + ) + } + if (experimentCount > 0) { + return ( + + ) + } + return undefined + } + return (
- setIsCreating(true)}> - - Create Experiment - - ) : undefined - } - /> + {renderBody()}
) 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..58b06522206c --- /dev/null +++ b/frontend/web/components/pages/MetricsPage.tsx @@ -0,0 +1,198 @@ +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 renderBody = () => { + if (isLoading) { + return ( +
+ +
+ ) + } + if (!metrics?.length) { + return ( +
+ +
No metrics yet
+

+ Create your first metric to measure experiment outcomes. +

+ +
+ ) + } + return ( + <> + {!!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} + +
+ ))} +
+ + ) + } + + return ( +
+ + Metrics track the outcomes you measure across experiments. Each + experiment picks one as its primary; the rest are observed for context. + + {renderBody()} +
+ ) +} + +MetricsPage.displayName = 'MetricsPage' +export default MetricsPage diff --git a/frontend/web/routes.js b/frontend/web/routes.js index cc9802e07536..604ae1220d0b 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 MetricsPage from './components/pages/MetricsPage' 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} /> +