From 19d00bf1d44498de47dc33f4e5a47bcacf112005 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 10 Jun 2026 09:36:53 +0200 Subject: [PATCH 01/11] feat(multivariate): add-variant-label-ui --- frontend/common/constants.ts | 2 + frontend/common/types/responses.ts | 3 + .../base/grid/ContentCard/ContentCard.scss | 11 ++ .../base/grid/ContentCard/ContentCard.tsx | 17 +- .../VariationTable/VariationTable.scss | 13 -- .../VariationTable/VariationTable.tsx | 13 +- .../experiments/steps/SetupStep.tsx | 20 +- .../web/components/mv/VariationKeyLabel.tsx | 149 ++++++++++++++ .../web/components/mv/VariationOptions.tsx | 3 + .../web/components/mv/VariationValueInput.tsx | 183 ++++++++++-------- frontend/web/styles/3rdParty/_hljs.scss | 7 +- 11 files changed, 295 insertions(+), 126 deletions(-) create mode 100644 frontend/web/components/mv/VariationKeyLabel.tsx diff --git a/frontend/common/constants.ts b/frontend/common/constants.ts index 404eea10c5b3..62a4ac6c2807 100644 --- a/frontend/common/constants.ts +++ b/frontend/common/constants.ts @@ -396,6 +396,7 @@ const Constants = { 'FEATURE_ID': 150, 'SEGMENT_ID': 150, 'TRAITS_ID': 150, + 'VARIANT_KEY': 255, }, }, @@ -651,6 +652,7 @@ const Constants = { 'Features can have values as well as being simply on or off, e.g. a font size for a banner or an environment variable for a server.', REMOTE_CONFIG_DESCRIPTION_VARIATION: 'Features can have values as well as being simply on or off, e.g. a font size for a banner or an environment variable for a server.
Variation values are set per project, the environment weight is per environment.', + RESERVED_VARIANT_KEY: 'control', SEGMENT_OVERRIDES_DESCRIPTION: 'Set different values for your feature based on what segments users are in. Identity overrides will take priority over any segment override.', TAGS_DESCRIPTION: diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 73b6c5607024..487eb2fd1431 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -573,6 +573,9 @@ export type MultivariateOption = { string_value: string boolean_value?: boolean default_percentage_allocation: number + // A stable, human-readable identifier for the variant (the backend `key`). + // Surfaced in the UI as the variation "Label". Slug-constrained and nullable. + key?: string | null } export type FeatureType = 'STANDARD' | 'MULTIVARIATE' diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.scss b/frontend/web/components/base/grid/ContentCard/ContentCard.scss index e6c78a3a0457..d3f10360e007 100644 --- a/frontend/web/components/base/grid/ContentCard/ContentCard.scss +++ b/frontend/web/components/base/grid/ContentCard/ContentCard.scss @@ -7,12 +7,23 @@ border: 1px solid var(--color-border-default); border-radius: var(--radius-lg); + &__heading { + display: flex; + flex-direction: column; + } + &__header { display: flex; align-items: center; justify-content: space-between; } + &__description { + font-size: var(--font-body-sm-size); + color: var(--color-text-secondary); + margin: 0; + } + .input-container { display: block; } diff --git a/frontend/web/components/base/grid/ContentCard/ContentCard.tsx b/frontend/web/components/base/grid/ContentCard/ContentCard.tsx index 2cb5136a3f88..0eb19fdae542 100644 --- a/frontend/web/components/base/grid/ContentCard/ContentCard.tsx +++ b/frontend/web/components/base/grid/ContentCard/ContentCard.tsx @@ -4,6 +4,7 @@ import './ContentCard.scss' type ContentCardProps = { title?: string + description?: ReactNode action?: ReactNode className?: string children: ReactNode @@ -13,14 +14,22 @@ const ContentCard: FC = ({ action, children, className, + description, title, }) => { return (
- {(title || action) && ( -
- {title &&

{title}

} - {action} + {(title || action || description) && ( +
+ {(title || action) && ( +
+ {title &&

{title}

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

{description}

+ )}
)} {children} diff --git a/frontend/web/components/experiments/VariationTable/VariationTable.scss b/frontend/web/components/experiments/VariationTable/VariationTable.scss index c8293a23db8c..586f23b3a796 100644 --- a/frontend/web/components/experiments/VariationTable/VariationTable.scss +++ b/frontend/web/components/experiments/VariationTable/VariationTable.scss @@ -19,7 +19,6 @@ letter-spacing: 0.02em; &--name, - &--desc, &--value { flex: 1; } @@ -48,12 +47,6 @@ white-space: nowrap; } - &--desc { - flex: 1; - min-width: 0; - word-break: break-word; - } - &--value { flex: 1; min-width: 0; @@ -75,12 +68,6 @@ border-radius: var(--radius-sm); } - &__desc-text { - font-size: var(--font-body-sm-size); - color: var(--color-text-secondary); - line-height: 1.4; - } - &__value-badge { display: inline-block; font-family: var(--font-family); diff --git a/frontend/web/components/experiments/VariationTable/VariationTable.tsx b/frontend/web/components/experiments/VariationTable/VariationTable.tsx index 1f2150af8d9b..6c03e2aef60f 100644 --- a/frontend/web/components/experiments/VariationTable/VariationTable.tsx +++ b/frontend/web/components/experiments/VariationTable/VariationTable.tsx @@ -36,9 +36,6 @@ const VariationTable: FC = ({ Name - - Description - Value @@ -50,11 +47,6 @@ const VariationTable: FC = ({ Control control
-
- - Flag's base value - -
{controlValue ? ( @@ -72,12 +64,9 @@ const VariationTable: FC = ({
- {`Variant ${letter}`} + {mv.key || `Variant ${letter}`}
-
- -
{value ? ( diff --git a/frontend/web/components/experiments/steps/SetupStep.tsx b/frontend/web/components/experiments/steps/SetupStep.tsx index d69c071d3364..906a75af0f5f 100644 --- a/frontend/web/components/experiments/steps/SetupStep.tsx +++ b/frontend/web/components/experiments/steps/SetupStep.tsx @@ -51,12 +51,10 @@ const SetupStep: FC = ({ return (
- -

- Name the experiment and capture what you're trying to learn - before picking a flag. -

- + = ({
- -

- The flag you're experimenting on. Variations are read-only, - defined on the flag itself. -

- +
) => { + const next = Utils.safeParseEventValue(e) + setDraft(next) + setError(validate(next)) + }} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + commit() + } + if (e.key === 'Escape') { + e.preventDefault() + cancel() + } + }} + /> +
+ + +
+ + {!!error && {error}} +
+ ) + } + + return ( + + {displayName} + {canEdit && ( + + )} + + ) +} diff --git a/frontend/web/components/mv/VariationOptions.tsx b/frontend/web/components/mv/VariationOptions.tsx index 400625347a1c..402a7cb1053e 100644 --- a/frontend/web/components/mv/VariationOptions.tsx +++ b/frontend/web/components/mv/VariationOptions.tsx @@ -168,6 +168,9 @@ export const VariationOptions: React.FC = ({ canCreateFeature={canCreateFeature} readOnly={readOnly ?? false} value={theValue} + siblingKeys={multivariateOptions + .filter((_, index) => index !== i) + .map((option) => option.key)} onChange={(e) => { updateVariation(i, e, variationOverrides) }} diff --git a/frontend/web/components/mv/VariationValueInput.tsx b/frontend/web/components/mv/VariationValueInput.tsx index 9102dd4829c8..04232a7b830f 100644 --- a/frontend/web/components/mv/VariationValueInput.tsx +++ b/frontend/web/components/mv/VariationValueInput.tsx @@ -6,6 +6,7 @@ import InputGroup from 'components/base/forms/InputGroup' import Utils from 'common/utils/utils' import shallowEqual from 'fbjs/lib/shallowEqual' import { ProjectPermission } from 'common/types/permissions.types' +import { VariationKeyLabel } from './VariationKeyLabel' interface VariationValueProps { canCreateFeature: boolean @@ -14,6 +15,7 @@ interface VariationValueProps { onChange: (value: any) => void onRemove?: () => void readOnly: boolean + siblingKeys: (string | null | undefined)[] value: any weightTitle: string } @@ -25,95 +27,108 @@ export const VariationValueInput: React.FC = ({ onChange, onRemove, readOnly, + siblingKeys, value, weightTitle, }) => { return ( - -
- - {Utils.renderWithPermission( - canCreateFeature, - readOnly - ? 'Variation values are defined at the feature level and cannot be changed per segment.' - : Constants.projectPermissions( - ProjectPermission.CREATE_FEATURE, - ), - { - const newValue = { - ...value, - // Trim spaces and do conversion on blur - ...Utils.valueToFeatureState( - Utils.featureStateToValue(value), +
+ onChange({ ...value, key })} + /> + +
+ + {Utils.renderWithPermission( + canCreateFeature, + readOnly + ? 'Variation values are defined at the feature level and cannot be changed per segment.' + : Constants.projectPermissions( + ProjectPermission.CREATE_FEATURE, ), - } - if (!shallowEqual(newValue, value)) { - //occurs if we converted a trimmed value - onChange(newValue) - } - }} - onChange={(e: React.ChangeEvent) => { - onChange({ - ...value, - ...Utils.valueToFeatureState( - Utils.safeParseEventValue(e), - false, - ), - }) - }} - placeholder="e.g. 'big' " - />, - )} - - } - tooltip={Constants.strings.REMOTE_CONFIG_DESCRIPTION_VARIATION} - title='Variation Value' - /> -
-
- ) => { - const val = Utils.safeParseEventValue(e) - onChange({ - ...value, - default_percentage_allocation: val ? parseFloat(val) : null, - }) - }} - value={value.default_percentage_allocation} - inputProps={{ - readOnly: disabled, - step: 'any', - }} - title={weightTitle} - /> -
- {!!onRemove && !readOnly && ( -
- + { + const newValue = { + ...value, + // Trim spaces and do conversion on blur + ...Utils.valueToFeatureState( + Utils.featureStateToValue(value), + ), + } + if (!shallowEqual(newValue, value)) { + //occurs if we converted a trimmed value + onChange(newValue) + } + }} + onChange={(e: React.ChangeEvent) => { + onChange({ + ...value, + ...Utils.valueToFeatureState( + Utils.safeParseEventValue(e), + false, + ), + }) + }} + placeholder="e.g. 'big' " + />, + )} + + } + tooltip={Constants.strings.REMOTE_CONFIG_DESCRIPTION_VARIATION} + title='Variation Value' + /> +
+
+ ) => { + const val = Utils.safeParseEventValue(e) + onChange({ + ...value, + default_percentage_allocation: val ? parseFloat(val) : null, + }) + }} + value={value.default_percentage_allocation} + inputProps={{ + readOnly: disabled, + step: 'any', + }} + title={weightTitle} + />
- )} -
+ {!!onRemove && !readOnly && ( +
+ +
+ )} + +
) } diff --git a/frontend/web/styles/3rdParty/_hljs.scss b/frontend/web/styles/3rdParty/_hljs.scss index 453f8ed38630..064e2fa5eeca 100644 --- a/frontend/web/styles/3rdParty/_hljs.scss +++ b/frontend/web/styles/3rdParty/_hljs.scss @@ -20,7 +20,12 @@ } &.code-medium { .hljs { - padding: $input-padding; + // Align the single-line editor height with adjacent inputs ($input-height). + // Reduced vertical padding so border + line-height + padding == $input-height, + // while min-height keeps the empty/placeholder state aligned too. The editor + // still grows for multi-line values. + padding: 9px 12px 9px 16px; + min-height: $input-height; } } textarea { From 63dab47aa324e3c5333a19ec4d819dfb35356ea7 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 10 Jun 2026 12:08:40 +0200 Subject: [PATCH 02/11] feat(multivariate): reworked variants in create flag modal UI --- frontend/common/utils/utils.tsx | 3 +- .../components/Input.stories.tsx | 76 +++++++ .../e2e/helpers/e2e-helpers.playwright.ts | 26 +++ frontend/e2e/tests/flag-tests.pw.ts | 4 + frontend/web/components/base/forms/Input.js | 8 +- .../modals/create-feature/index.tsx | 60 ++++-- .../create-feature/tabs/FeatureValueTab.tsx | 132 ++++++++++-- .../web/components/mv/AddVariationButton.tsx | 23 ++- .../web/components/mv/VariationKeyLabel.tsx | 54 ++--- .../web/components/mv/VariationOptions.tsx | 24 +-- .../components/mv/VariationValueInput.scss | 7 + .../web/components/mv/VariationValueInput.tsx | 192 +++++++++--------- frontend/web/styles/components/_input.scss | 31 +++ frontend/web/styles/project/_type.scss | 4 + 14 files changed, 463 insertions(+), 181 deletions(-) create mode 100644 frontend/documentation/components/Input.stories.tsx create mode 100644 frontend/web/components/mv/VariationValueInput.scss diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index db241e4457f6..b8dbd013f4c4 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -92,7 +92,8 @@ const Utils = Object.assign({}, BaseUtils, { } else if (typeof v.default_percentage_allocation === 'number') { total += v.default_percentage_allocation } else { - total += (v as any).percentage_allocation + // A cleared weight input leaves the allocation null — treat as 0. + total += (v as any).percentage_allocation || 0 } return null }) diff --git a/frontend/documentation/components/Input.stories.tsx b/frontend/documentation/components/Input.stories.tsx new file mode 100644 index 000000000000..6a030aca7e8d --- /dev/null +++ b/frontend/documentation/components/Input.stories.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import Input from 'components/base/forms/Input' + +const meta: Meta = { + parameters: { layout: 'centered' }, + title: 'Components/Forms/Input', +} +export default meta + +type Story = StoryObj + +const Interactive = (props: Record) => { + const [value, setValue] = useState(props.initialValue ?? '') + return ( + ) => + setValue(e.target.value) + } + /> + ) +} + +export const Default: Story = { + render: () => , +} + +export const Sizes: Story = { + render: () => ( +
+ + + + +
+ ), +} + +export const Search: Story = { + render: () => , +} + +export const Password: Story = { + render: () => ( + + ), +} + +// Borderless input with a bottom underline only — used for inline edits +// such as the variant label in the feature modal. +export const Underline: Story = { + render: () => ( + + ), +} + +// Underline combined with centered, as used for the variant weight input. +export const UnderlineCentered: Story = { + render: () => ( +
+
+ +
+ % +
+ ), +} diff --git a/frontend/e2e/helpers/e2e-helpers.playwright.ts b/frontend/e2e/helpers/e2e-helpers.playwright.ts index 543efe790b67..ad74b4f56157 100644 --- a/frontend/e2e/helpers/e2e-helpers.playwright.ts +++ b/frontend/e2e/helpers/e2e-helpers.playwright.ts @@ -626,6 +626,32 @@ export class E2EHelpers { await this.waitForElementNotExist('#create-feature-modal'); } + // Edit a variant's label (the multivariate option key) and verify it persists + async editVariantLabel(featureName: string, index: number, label: string) { + await this.gotoFeatures(); + const featureRow = this.page.locator('[data-test^="feature-item-"]').filter({ + has: this.page.locator(`span:text-is("${featureName}")`) + }).first(); + await featureRow.waitFor({ state: 'visible', timeout: LONG_TIMEOUT }); + await featureRow.dispatchEvent('click'); + await this.waitForElementVisible(byId('update-feature-btn')); + await this.click(byId(`featureVariationKeyEdit${index}`)); + await this.setText(byId(`featureVariationKeyInput${index}`), label); + await this.click(byId(`featureVariationKeySave${index}`)); + await expect(this.page.locator(byId(`featureVariationKey${index}`))).toHaveText(label); + await this.waitForToastsToClear(); + await this.click(byId('update-feature-btn')); + await this.waitForToast(); + await this.closeModal(); + await this.waitForElementNotExist('#create-feature-modal'); + // Reopen the feature and verify the label was saved + await featureRow.dispatchEvent('click'); + await this.waitForElementVisible(byId('update-feature-btn')); + await expect(this.page.locator(byId(`featureVariationKey${index}`))).toHaveText(label); + await this.closeModal(); + await this.waitForElementNotExist('#create-feature-modal'); + } + // Create an environment async createEnvironment(name: string) { await this.page.waitForLoadState('networkidle'); diff --git a/frontend/e2e/tests/flag-tests.pw.ts b/frontend/e2e/tests/flag-tests.pw.ts index 805d103680ca..c349137c0fb3 100644 --- a/frontend/e2e/tests/flag-tests.pw.ts +++ b/frontend/e2e/tests/flag-tests.pw.ts @@ -10,6 +10,7 @@ test.describe('Flag Tests', () => { createRemoteConfig, deleteFeature, editRemoteConfig, + editVariantLabel, gotoFeatures, gotoProject, login, @@ -71,6 +72,9 @@ test.describe('Flag Tests', () => { expect(json.header_size.value).toBe('big') expect(json.mv_flag.value).toBe('big') + log('Edit variant label') + await editVariantLabel('mv_flag', 0, 'variant_medium') + log('Update feature') await editRemoteConfig('header_size', 12) diff --git a/frontend/web/components/base/forms/Input.js b/frontend/web/components/base/forms/Input.js index 5042ec2c48b7..f00c39f82520 100644 --- a/frontend/web/components/base/forms/Input.js +++ b/frontend/web/components/base/forms/Input.js @@ -75,6 +75,7 @@ const Input = class extends React.Component { render() { const { + centered, disabled, inputClassName, isValid, @@ -82,6 +83,7 @@ const Input = class extends React.Component { placeholderChar, showSuccess, size, + underline, ...rest } = this.props @@ -91,6 +93,7 @@ const Input = class extends React.Component { { 'focused': this.state.isFocused, 'input-container': true, + 'input-underline': underline, invalid, 'password': this.props.type === 'password', 'search': this.props.search, @@ -101,7 +104,8 @@ const Input = class extends React.Component { const innerClassName = cn( { - input: true, + 'input': true, + 'text-center': centered, }, inputClassName, sizeClassNames[size], @@ -215,6 +219,7 @@ Input.defaultProps = { Input.propTypes = { autocomplete: propTypes.string, + centered: propTypes.bool, className: propTypes.any, inputClassName: OptionalString, isValid: propTypes.any, @@ -226,6 +231,7 @@ Input.propTypes = { placeholderChar: OptionalString, search: propTypes.Boolean, size: OptionalString, + underline: propTypes.bool, } export default Input diff --git a/frontend/web/components/modals/create-feature/index.tsx b/frontend/web/components/modals/create-feature/index.tsx index 5300b1cd5f0e..1237eb572c74 100644 --- a/frontend/web/components/modals/create-feature/index.tsx +++ b/frontend/web/components/modals/create-feature/index.tsx @@ -1,4 +1,11 @@ -import React, { FC, useCallback, useEffect, useRef, useState } from 'react' +import React, { + FC, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react' import cloneDeep from 'lodash/cloneDeep' import moment from 'moment' import { useProjectEnvironments } from 'common/hooks/useProjectEnvironments' @@ -69,6 +76,21 @@ type InjectedSegmentOverrideProps = { removeMultivariateOption: (id: number) => void } +// Replaces each option's default weight with its environment allocation. +const mergeEnvironmentWeights = (options: any[], variations: any[]): any[] => + options.map((v: any) => { + const matchingVariation = variations.find( + (e: any) => e.multivariate_feature_option === v.id, + ) + return { + ...v, + default_percentage_allocation: + (matchingVariation && matchingVariation.percentage_allocation) || + v.default_percentage_allocation || + 0, + } + }) + const CreateFeatureModal: FC = (props) => { const { changeRequest: existingChangeRequest, @@ -232,23 +254,34 @@ const CreateFeatureModal: FC = (props) => { if (!identity && environmentVariations?.length) { setProjectFlag((prev: any) => ({ ...prev, - multivariate_options: prev.multivariate_options?.map((v: any) => { - const matchingVariation = ( - props.multivariate_options || environmentVariations - ).find((e: any) => e.multivariate_feature_option === v.id) - return { - ...v, - default_percentage_allocation: - (matchingVariation && matchingVariation.percentage_allocation) || - v.default_percentage_allocation || - 0, - } - }), + multivariate_options: + prev.multivariate_options && + mergeEnvironmentWeights( + prev.multivariate_options, + props.multivariate_options || environmentVariations, + ), })) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [environmentVariations]) + // The persisted variants with the same environment weights merged in, + // so the modal's edited copy only differs after a user change. + const originalMultivariateOptions = useMemo(() => { + const options = props.projectFlag?.multivariate_options + if (!options) { + return undefined + } + if (identity || !environmentVariations?.length) { + return options + } + return mergeEnvironmentWeights( + options, + props.multivariate_options || environmentVariations, + ) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [props.projectFlag?.multivariate_options, environmentVariations]) + const cleanInputValue = (value: any) => { if (value && typeof value === 'string') { return value.trim() @@ -596,6 +629,7 @@ const CreateFeatureModal: FC = (props) => { isVersioned={isVersioned} isSaving={isSaving} existingChangeRequest={!!existingChangeRequest} + originalMultivariateOptions={originalMultivariateOptions} onSaveFeatureValue={saveFeatureValue} onEnvironmentFlagChange={(changes: any) => { setEnvironmentFlag((prev: any) => ({ diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx index 9918c360c396..0d0a27a3e75e 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx @@ -5,6 +5,7 @@ import Constants from 'common/constants' import { VariationOptions } from 'components/mv/VariationOptions' import { AddVariationButton } from 'components/mv/AddVariationButton' import ErrorMessage from 'components/ErrorMessage' +import InfoMessage from 'components/InfoMessage' import WarningMessage from 'components/WarningMessage' import Tooltip from 'components/Tooltip' import Icon from 'components/icons/Icon' @@ -12,7 +13,11 @@ import Switch from 'components/Switch' import JSONReference from 'components/JSONReference' import { FlagValueFooter } from 'components/modals/FlagValueFooter' import Utils from 'common/utils/utils' -import { FeatureState, ProjectFlag } from 'common/types/responses' +import { + FeatureState, + MultivariateOption, + ProjectFlag, +} from 'common/types/responses' import { useHasPermission } from 'common/providers/Permission' import { ProjectPermission } from 'common/types/permissions.types' @@ -42,6 +47,8 @@ type FeatureValueTabProps = { isSaving?: boolean existingChangeRequest?: boolean onSaveFeatureValue?: (schedule?: boolean) => void + // The persisted variants, used to tag edited ones as not saved. + originalMultivariateOptions?: MultivariateOption[] onEnvironmentFlagChange: (changes: Partial) => void onProjectFlagChange: (changes: Partial) => void onRemoveMultivariateOption?: (id: number) => void @@ -63,6 +70,7 @@ const FeatureValueTab: FC = ({ onProjectFlagChange, onRemoveMultivariateOption, onSaveFeatureValue, + originalMultivariateOptions, projectFlag, projectId, }) => { @@ -88,9 +96,17 @@ const FeatureValueTab: FC = ({ controlPercentage < 0 const addVariation = () => { + // Default the label to the first free Variant_n so new variants are + // saved with a key even if the user never edits it. + const existingKeys = multivariate_options.map((option) => option.key) + let variantNumber = multivariate_options.length + 1 + while (existingKeys.includes(`Variant_${variantNumber}`)) { + variantNumber += 1 + } const newVariation = { ...Utils.valueToFeatureState(''), default_percentage_allocation: 0, + key: `Variant_${variantNumber}`, } onProjectFlagChange({ multivariate_options: [...multivariate_options, newVariation], @@ -155,13 +171,60 @@ const FeatureValueTab: FC = ({ const enabledString = isEdit ? 'Enabled' : 'Enabled by default' - const getValueString = () => { - if (multivariate_options && multivariate_options.length) { - return `Control Value - ${controlPercentage}%` + const hasVariations = !!multivariate_options && !!multivariate_options.length + + // Fields the user can change on a variant from this tab. + const variantFields: (keyof MultivariateOption)[] = [ + 'key', + 'type', + 'string_value', + 'integer_value', + 'boolean_value', + 'default_percentage_allocation', + ] + const unsavedVariations = multivariate_options.map((option) => { + if (!originalMultivariateOptions) { + return false } - return 'Value' - } - const valueString = getValueString() + if (!option.id) { + return true + } + const original = originalMultivariateOptions.find((o) => o.id === option.id) + if (!original) { + return true + } + return variantFields.some((field) => option[field] !== original[field]) + }) + const valueTitle = hasVariations ? ( + + Control Value + + + + {controlPercentage}% + + ) : ( + 'Value' + ) + + const variationsInfo = hasVariations && ( +

+ + Changing a Variation Value will affect all environments + , their weights are specific to this environment. Existing users will + see the new variation value if it is changed. These values will only + apply when you identify via the SDK. + + Check the Docs for more details + + . + +

+ ) const showValue = !( !!identity && @@ -203,7 +266,7 @@ const FeatureValueTab: FC = ({ = ({ ? '
Setting this when creating a feature will set the value for all environments. You can edit this individually for each environment once the feature is created.' : '' }`} - title={`${valueString}`} + title={valueTitle} + hideTooltipIcon={hasVariations} /> )} @@ -255,6 +319,7 @@ const FeatureValueTab: FC = ({ {!!identity && (
+ {variationsInfo} = ({ {!identity && (
+ {variationsInfo} + {hasVariations && ( + + {Utils.getFlagsmithHasFeature('experimental_flags') ? ( + + Variants + + } + > + To use this flag in an experiment, all the variants must have + a label. + + ) : ( + + )} + {Utils.renderWithPermission( + createFeature, + Constants.projectPermissions(ProjectPermission.CREATE_FEATURE), + , + )} + + )} {(!!environmentVariations || !isEdit) && ( = ({ multivariate_feature_state_values: variations as any, }) } + unsavedVariations={unsavedVariations} updateVariation={handleUpdateVariation} weightTitle={ isEdit ? 'Environment Weight %' : 'Default Weight %' @@ -309,14 +403,18 @@ const FeatureValueTab: FC = ({ /> )} - {Utils.renderWithPermission( - createFeature, - Constants.projectPermissions(ProjectPermission.CREATE_FEATURE), - , + {!hasVariations && ( +
+ {Utils.renderWithPermission( + createFeature, + Constants.projectPermissions(ProjectPermission.CREATE_FEATURE), + , + )} +
)}
)} diff --git a/frontend/web/components/mv/AddVariationButton.tsx b/frontend/web/components/mv/AddVariationButton.tsx index 62cabae815fd..5ac4a0ebd6b9 100644 --- a/frontend/web/components/mv/AddVariationButton.tsx +++ b/frontend/web/components/mv/AddVariationButton.tsx @@ -1,4 +1,5 @@ import Button from 'components/base/forms/Button' +import Icon from 'components/icons/Icon' import React from 'react' interface AddVariationButtonProps { @@ -13,16 +14,16 @@ export const AddVariationButton: React.FC = ({ onClick, }) => { return ( -
- -
+ ) } diff --git a/frontend/web/components/mv/VariationKeyLabel.tsx b/frontend/web/components/mv/VariationKeyLabel.tsx index 954b1000470a..3ce7925c7f85 100644 --- a/frontend/web/components/mv/VariationKeyLabel.tsx +++ b/frontend/web/components/mv/VariationKeyLabel.tsx @@ -35,22 +35,23 @@ export const VariationKeyLabel: FC = ({ // Display-only placeholder when no label is set; the persisted key stays null. const displayName = value || `Variant_${index + 1}` + // Validates the raw input — trimming here would hide a trailing + // space from the user until their next keystroke. const validate = (next: string): string | null => { - const trimmed = next.trim() - if (!trimmed) { + if (!next) { // Empty clears the label (key persists as null). return null } - if (trimmed.length > Constants.forms.maxLength.VARIANT_KEY) { + if (next.length > Constants.forms.maxLength.VARIANT_KEY) { return `Label must be ${Constants.forms.maxLength.VARIANT_KEY} characters or fewer.` } - if (!VARIANT_KEY_REGEX.test(trimmed)) { + if (!VARIANT_KEY_REGEX.test(next)) { return 'Label can only contain letters, numbers, hyphens and underscores.' } - if (trimmed === Constants.strings.RESERVED_VARIANT_KEY) { + if (next === Constants.strings.RESERVED_VARIANT_KEY) { return `"${Constants.strings.RESERVED_VARIANT_KEY}" is a reserved label.` } - if (siblingKeys.some((key) => key === trimmed)) { + if (siblingKeys.some((key) => key === next)) { return 'This label is already used by another variation.' } return null @@ -69,24 +70,26 @@ export const VariationKeyLabel: FC = ({ } const commit = () => { - const trimmed = draft.trim() - const validationError = validate(trimmed) + const validationError = validate(draft) if (validationError) { setError(validationError) return } - onChange(trimmed || null) + onChange(draft || null) setError(null) setIsEditing(false) } if (isEditing && canEdit) { return ( -
- +
+ = ({ } }} /> -
- - + +
{!!error && {error}} @@ -132,13 +130,19 @@ export const VariationKeyLabel: FC = ({ } return ( - - {displayName} + + + {displayName} + {canEdit && ( + + + )} +
+
+ + {Utils.renderWithPermission( + canCreateFeature, + readOnly + ? 'Variation values are defined at the feature level and cannot be changed per segment.' + : Constants.projectPermissions( + ProjectPermission.CREATE_FEATURE, + ), + { + const newValue = { + ...value, + // Trim spaces and do conversion on blur + ...Utils.valueToFeatureState( + Utils.featureStateToValue(value), + ), + } + if (!shallowEqual(newValue, value)) { + //occurs if we converted a trimmed value + onChange(newValue) + } + }} + onChange={(e: React.ChangeEvent) => { + onChange({ + ...value, + ...Utils.valueToFeatureState( + Utils.safeParseEventValue(e), + false, + ), + }) + }} + placeholder="e.g. 'big' " + />, + )} + + } + tooltip={Constants.strings.REMOTE_CONFIG_DESCRIPTION_VARIATION} + title='Variation Value' + /> + + +
+
+ ) => { + const val = Utils.safeParseEventValue(e) + onChange({ + ...value, + default_percentage_allocation: val ? parseFloat(val) : null, + }) + }} + value={value.default_percentage_allocation} + readOnly={disabled} + step='any' + />
- )} + % +
) diff --git a/frontend/web/styles/components/_input.scss b/frontend/web/styles/components/_input.scss index fbe5c8fdd1dc..82335fd274e2 100644 --- a/frontend/web/styles/components/_input.scss +++ b/frontend/web/styles/components/_input.scss @@ -28,6 +28,37 @@ textarea { @import '~react-datepicker/dist/react-datepicker.css'; +// Borderless input with a bottom underline only; the underline turns +// primary while focused. Used for inline edits (variant label, weight). +.input-container.input-underline { + input.input { + border: none; + border-bottom: 1px solid $input-border-color; + border-radius: 0; + background-color: transparent; + padding-left: 8px; + padding-right: 8px; + // _forms.scss re-applies a full border shorthand on hover/focus. + &:hover { + border: none; + border-bottom: 1px solid $basic-alpha-48; + } + &:focus { + border: none; + border-bottom: 1px solid $primary; + } + } + input[type='number'].input { + -moz-appearance: textfield; + appearance: textfield; + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } +} + .input-container, .react-datepicker-wrapper .react-datepicker__input-container { font-family: $font-family; diff --git a/frontend/web/styles/project/_type.scss b/frontend/web/styles/project/_type.scss index c0aeabc3e1d7..cf0724f20ead 100644 --- a/frontend/web/styles/project/_type.scss +++ b/frontend/web/styles/project/_type.scss @@ -59,6 +59,10 @@ a { font-weight: 500 !important; } +.font-weight-semibold { + font-weight: 600 !important; +} + .text-primary { color: $primary; } From 385dfbe0854290bd3f4fbaae73f2331699d4e891 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 10 Jun 2026 15:46:10 +0200 Subject: [PATCH 03/11] feat(multivariate): refactored css and inline style --- .../mv/VariationKeyLabel/VariationKeyLabel.scss | 5 +++++ .../mv/{ => VariationKeyLabel}/VariationKeyLabel.tsx | 8 +++++--- frontend/web/components/mv/VariationKeyLabel/index.ts | 1 + .../mv/{ => VariationValueInput}/VariationValueInput.scss | 6 +++++- .../mv/{ => VariationValueInput}/VariationValueInput.tsx | 7 ++++--- frontend/web/components/mv/VariationValueInput/index.ts | 1 + frontend/web/styles/project/_type.scss | 2 +- 7 files changed, 22 insertions(+), 8 deletions(-) create mode 100644 frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.scss rename frontend/web/components/mv/{ => VariationKeyLabel}/VariationKeyLabel.tsx (93%) create mode 100644 frontend/web/components/mv/VariationKeyLabel/index.ts rename frontend/web/components/mv/{ => VariationValueInput}/VariationValueInput.scss (63%) rename frontend/web/components/mv/{ => VariationValueInput}/VariationValueInput.tsx (94%) create mode 100644 frontend/web/components/mv/VariationValueInput/index.ts diff --git a/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.scss b/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.scss new file mode 100644 index 000000000000..75cd9fd4032a --- /dev/null +++ b/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.scss @@ -0,0 +1,5 @@ +.variation-key-label { + &__input { + width: 150px; + } +} diff --git a/frontend/web/components/mv/VariationKeyLabel.tsx b/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx similarity index 93% rename from frontend/web/components/mv/VariationKeyLabel.tsx rename to frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx index 3ce7925c7f85..513f42f2913f 100644 --- a/frontend/web/components/mv/VariationKeyLabel.tsx +++ b/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx @@ -4,6 +4,8 @@ import Button from 'components/base/forms/Button' import Icon from 'components/icons/Icon' import Input from 'components/base/forms/Input' import Utils from 'common/utils/utils' +import { colorIconAction, colorIconSecondary } from 'common/theme/tokens' +import './VariationKeyLabel.scss' interface VariationKeyLabelProps { // The variant's `key`, surfaced in the UI as its "Label". @@ -88,8 +90,8 @@ export const VariationKeyLabel: FC = ({ autoFocus size='small' underline + className='variation-key-label__input' data-test={`featureVariationKeyInput${index}`} - style={{ width: 150 }} value={draft} isValid={!error} maxLength={Constants.forms.maxLength.VARIANT_KEY} @@ -117,10 +119,10 @@ export const VariationKeyLabel: FC = ({ data-test={`featureVariationKeySave${index}`} aria-label='Save label' > - +
diff --git a/frontend/web/components/mv/VariationKeyLabel/index.ts b/frontend/web/components/mv/VariationKeyLabel/index.ts new file mode 100644 index 000000000000..8186a2da71cc --- /dev/null +++ b/frontend/web/components/mv/VariationKeyLabel/index.ts @@ -0,0 +1 @@ +export { VariationKeyLabel } from './VariationKeyLabel' diff --git a/frontend/web/components/mv/VariationValueInput.scss b/frontend/web/components/mv/VariationValueInput/VariationValueInput.scss similarity index 63% rename from frontend/web/components/mv/VariationValueInput.scss rename to frontend/web/components/mv/VariationValueInput/VariationValueInput.scss index 68727f5028f9..822d0af70f9e 100644 --- a/frontend/web/components/mv/VariationValueInput.scss +++ b/frontend/web/components/mv/VariationValueInput/VariationValueInput.scss @@ -2,6 +2,10 @@ // The InputGroup label is internal to the component, so its weight // can only be reduced from here. label { - font-weight: 400; + font-weight: var(--font-weight-regular); + } + + &__weight { + width: 64px; } } diff --git a/frontend/web/components/mv/VariationValueInput.tsx b/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx similarity index 94% rename from frontend/web/components/mv/VariationValueInput.tsx rename to frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx index 693807b1859f..6e3a37e63c1f 100644 --- a/frontend/web/components/mv/VariationValueInput.tsx +++ b/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx @@ -8,7 +8,8 @@ import Button from 'components/base/forms/Button' import Utils from 'common/utils/utils' import shallowEqual from 'fbjs/lib/shallowEqual' import { ProjectPermission } from 'common/types/permissions.types' -import { VariationKeyLabel } from './VariationKeyLabel' +import { colorIconSecondary } from 'common/theme/tokens' +import { VariationKeyLabel } from 'components/mv/VariationKeyLabel' import './VariationValueInput.scss' interface VariationValueProps { @@ -56,7 +57,7 @@ export const VariationValueInput: React.FC = ({ id='delete-multivariate' aria-label='Remove variant' > - + )}
@@ -113,7 +114,7 @@ export const VariationValueInput: React.FC = ({
-
+
Date: Wed, 10 Jun 2026 15:48:18 +0200 Subject: [PATCH 04/11] feat(multivariate): addressed gemini comments --- .../create-feature/tabs/FeatureValueTab.tsx | 15 +++++++++++---- .../VariationValueInput/VariationValueInput.tsx | 2 +- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx index 0d0a27a3e75e..de9c01e0325d 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx @@ -97,10 +97,13 @@ const FeatureValueTab: FC = ({ const addVariation = () => { // Default the label to the first free Variant_n so new variants are - // saved with a key even if the user never edits it. - const existingKeys = multivariate_options.map((option) => option.key) + // saved with a key even if the user never edits it. Variants with no + // key display as Variant_{index + 1}, so avoid those names too. + const existingNames = multivariate_options.map( + (option, index) => option.key || `Variant_${index + 1}`, + ) let variantNumber = multivariate_options.length + 1 - while (existingKeys.includes(`Variant_${variantNumber}`)) { + while (existingNames.includes(`Variant_${variantNumber}`)) { variantNumber += 1 } const newVariation = { @@ -193,7 +196,11 @@ const FeatureValueTab: FC = ({ if (!original) { return true } - return variantFields.some((field) => option[field] !== original[field]) + return variantFields.some((field) => { + const edited = option[field] ?? null + const persisted = original[field] ?? null + return edited !== persisted + }) }) const valueTitle = hasVariations ? ( diff --git a/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx b/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx index 6e3a37e63c1f..169d11884632 100644 --- a/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx +++ b/frontend/web/components/mv/VariationValueInput/VariationValueInput.tsx @@ -130,7 +130,7 @@ export const VariationValueInput: React.FC = ({ default_percentage_allocation: val ? parseFloat(val) : null, }) }} - value={value.default_percentage_allocation} + value={value.default_percentage_allocation ?? ''} readOnly={disabled} step='any' /> From 5df610c44432411fb8ba13928982a27ba30b573b Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 10 Jun 2026 17:07:28 +0200 Subject: [PATCH 05/11] feat: improved api response callbacks and store management of mv options --- frontend/common/stores/feature-list-store.ts | 67 +++++++++++++++---- .../modals/create-feature/index.tsx | 31 ++++++++- .../create-feature/tabs/FeatureValueTab.tsx | 34 +++++++--- .../VariationKeyLabel/VariationKeyLabel.tsx | 24 ++++--- .../web/components/mv/VariationOptions.tsx | 3 + .../VariationValueInput.tsx | 8 +++ 6 files changed, 134 insertions(+), 33 deletions(-) diff --git a/frontend/common/stores/feature-list-store.ts b/frontend/common/stores/feature-list-store.ts index 4f3c8c11e9e2..de64cc6617a9 100644 --- a/frontend/common/stores/feature-list-store.ts +++ b/frontend/common/stores/feature-list-store.ts @@ -21,6 +21,7 @@ import { ChangeRequest, Environment, FeatureState, + MultivariateOption, PagedResponse, ProjectFlag, TypedFeatureState, @@ -247,12 +248,21 @@ const controller = { store.model && store.model.features ? store.model.features.find((v) => v.id === flag.id) : flag + store.error = null Promise.all( (flag.multivariate_options || []).map((v, i) => { - const originalMV = - v.id && originalFlag?.multivariate_options - ? originalFlag.multivariate_options.find((m) => m.id === v.id) - : null + let originalMV = null + if (originalFlag?.multivariate_options) { + if (v.id) { + originalMV = originalFlag.multivariate_options.find( + (m: MultivariateOption) => m.id === v.id, + ) + } else if (v.key) { + originalMV = originalFlag.multivariate_options.find( + (m: MultivariateOption) => !!m.key && m.key === v.key, + ) + } + } const url = `${Project.api}projects/${projectId}/features/${flag.id}/mv-options/` const mvData = { ...v, @@ -263,14 +273,16 @@ const controller = { originalMV ? data.put(`${url}${originalMV.id}/`, mvData) : data.post(url, mvData) - ).then((res) => { - // It's important to preserve the original order of multivariate_options, so that editing feature states can use the updated ID - flag.multivariate_options[i] = res - return { - ...v, - id: res.id, - } - }) + ) + .then((res) => { + // It's important to preserve the original order of multivariate_options, so that editing feature states can use the updated ID + flag.multivariate_options[i] = res + return { + ...v, + id: res.id, + } + }) + .catch((e) => Promise.reject({ mvIndex: i, source: e })) }), ) .then(() => { @@ -290,6 +302,32 @@ const controller = { onComplete(flag) } }) + .catch((e) => { + if (typeof e?.mvIndex !== 'number') { + API.ajaxHandler(store, e) + return + } + // Attribute the failure to the option that caused it so the UI + // can surface it on the right variation. + const surface = (body: any) => { + store.error = { multivariate_options: { [e.mvIndex]: body } } as any + store.goneABitWest() + } + if (typeof e.source?.text === 'function') { + e.source + .text() + .then((text: string) => { + let body = text + try { + body = JSON.parse(text) + } catch {} + surface(body) + }) + .catch(() => surface(null)) + } else { + surface(e.source ?? null) + } + }) }, editFeatureState: async ( projectId, @@ -852,6 +890,11 @@ const controller = { projectId, }).then((version) => { if (version.error) { + // Multivariate options are saved separately at the project + // level, so an unchanged environment state is not a failure. + if (version.error.message === 'Feature contains no changes') { + return + } throw version.error } const featureState = version.data.feature_states[0].data diff --git a/frontend/web/components/modals/create-feature/index.tsx b/frontend/web/components/modals/create-feature/index.tsx index 1237eb572c74..4491b0029bd1 100644 --- a/frontend/web/components/modals/create-feature/index.tsx +++ b/frontend/web/components/modals/create-feature/index.tsx @@ -245,6 +245,7 @@ const CreateFeatureModal: FC = (props) => { useEffect(() => { if (props.projectFlag) { setProjectFlag(cloneDeep(props.projectFlag)) + setSavedMultivariateOptions(null) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [props.projectFlag?.id]) @@ -266,8 +267,16 @@ const CreateFeatureModal: FC = (props) => { }, [environmentVariations]) // The persisted variants with the same environment weights merged in, - // so the modal's edited copy only differs after a user change. + // so the modal's edited copy only differs after a user change. Refreshed + // from the edited copy after each successful value save. + const [savedMultivariateOptions, setSavedMultivariateOptions] = useState< + any[] | null + >(null) + const mvBaselineRefreshRef = useRef(false) const originalMultivariateOptions = useMemo(() => { + if (savedMultivariateOptions) { + return savedMultivariateOptions + } const options = props.projectFlag?.multivariate_options if (!options) { return undefined @@ -280,7 +289,11 @@ const CreateFeatureModal: FC = (props) => { props.multivariate_options || environmentVariations, ) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.projectFlag?.multivariate_options, environmentVariations]) + }, [ + props.projectFlag?.multivariate_options, + environmentVariations, + savedMultivariateOptions, + ]) const cleanInputValue = (value: any) => { if (value && typeof value === 'string') { @@ -440,10 +453,23 @@ const CreateFeatureModal: FC = (props) => { return ( { + if (mvBaselineRefreshRef.current) { + // The value save failed — keep the unsaved indicators accurate. + mvBaselineRefreshRef.current = false + setValueChanged(true) + } + }} onSave={() => { if (identity) { close() } + if (mvBaselineRefreshRef.current) { + mvBaselineRefreshRef.current = false + setSavedMultivariateOptions( + cloneDeep(projectFlag.multivariate_options || []), + ) + } AppActions.refreshFeatures(projectId, environmentId) if (is4Eyes && !identity) { @@ -571,6 +597,7 @@ const CreateFeatureModal: FC = (props) => { ) } else { setValueChanged(false) + mvBaselineRefreshRef.current = true save(editFeatureValue, isSaving) } }, diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx index de9c01e0325d..b2fabf8fa876 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx @@ -189,18 +189,31 @@ const FeatureValueTab: FC = ({ if (!originalMultivariateOptions) { return false } - if (!option.id) { - return true + // A just-saved variant may not have its id reflected in local state + // yet, so id-less options are matched against id-less baseline entries. + const savedMvs = originalMultivariateOptions.filter((o) => + option.id ? o.id === option.id : !o.id, + ) + return !savedMvs.some((savedMv) => + variantFields.every( + (field) => (option[field] ?? null) === (savedMv[field] ?? null), + ), + ) + }) + + const variationApiErrors = multivariate_options.map((_, i) => { + const variationError = error?.multivariate_options?.[i] + if (!variationError) { + return null } - const original = originalMultivariateOptions.find((o) => o.id === option.id) - if (!original) { - return true + if (typeof variationError === 'string') { + return variationError } - return variantFields.some((field) => { - const edited = option[field] ?? null - const persisted = original[field] ?? null - return edited !== persisted - }) + const firstField = Object.values(variationError)[0] + return ( + (Array.isArray(firstField) ? firstField[0] : firstField) || + 'Failed to save this variation.' + ) }) const valueTitle = hasVariations ? ( @@ -400,6 +413,7 @@ const FeatureValueTab: FC = ({ multivariate_feature_state_values: variations as any, }) } + apiErrors={variationApiErrors} unsavedVariations={unsavedVariations} updateVariation={handleUpdateVariation} weightTitle={ diff --git a/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx b/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx index 513f42f2913f..1af3ed914e6b 100644 --- a/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx +++ b/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx @@ -72,11 +72,6 @@ export const VariationKeyLabel: FC = ({ } const commit = () => { - const validationError = validate(draft) - if (validationError) { - setError(validationError) - return - } onChange(draft || null) setError(null) setIsEditing(false) @@ -97,18 +92,23 @@ export const VariationKeyLabel: FC = ({ maxLength={Constants.forms.maxLength.VARIANT_KEY} placeholder={`Variant_${index + 1}`} onChange={(e: React.ChangeEvent) => { - const next = Utils.safeParseEventValue(e) + const next = Utils.safeParseEventValue(e).replace(/ /g, '_') setDraft(next) setError(validate(next)) }} + onBlur={commit} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() commit() } if (e.key === 'Escape') { - e.preventDefault() - cancel() + // The input blurs (and commits) on Escape before this + // handler runs — revert to the original value. + onChange(value ?? null) + setDraft(value ?? '') + setError(null) + setIsEditing(false) } }} /> @@ -116,12 +116,18 @@ export const VariationKeyLabel: FC = ({ -
diff --git a/frontend/web/components/mv/VariationOptions.tsx b/frontend/web/components/mv/VariationOptions.tsx index c054d5f38fb7..a0f3dcc611d1 100644 --- a/frontend/web/components/mv/VariationOptions.tsx +++ b/frontend/web/components/mv/VariationOptions.tsx @@ -13,6 +13,7 @@ type VariationOverride = { } interface VariationOptionsProps { + apiErrors?: (string | null)[] canCreateFeature: boolean controlPercentage: number controlValue: FlagsmithValue @@ -34,6 +35,7 @@ interface VariationOptionsProps { } export const VariationOptions: React.FC = ({ + apiErrors, canCreateFeature, controlPercentage, controlValue, @@ -146,6 +148,7 @@ export const VariationOptions: React.FC = ({ = ({ + apiError, canCreateFeature, disabled, index, @@ -138,6 +141,11 @@ export const VariationValueInput: React.FC = ({ %
+ {!!apiError && ( +
+ +
+ )}
) } From 1adfffb25bae292e6e059261121b4ff48aea810f Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 10 Jun 2026 17:56:16 +0200 Subject: [PATCH 06/11] feat: enforced fallback value to api and disabled button on error --- .../common/providers/FeatureListProvider.js | 19 ++++++++++++--- frontend/common/utils/utils.tsx | 5 ++++ .../create-feature/tabs/FeatureValueTab.tsx | 12 +++++----- .../VariationKeyLabel/VariationKeyLabel.tsx | 24 +++++++++++++++---- .../web/components/mv/VariationOptions.tsx | 9 +++++-- 5 files changed, 53 insertions(+), 16 deletions(-) diff --git a/frontend/common/providers/FeatureListProvider.js b/frontend/common/providers/FeatureListProvider.js index eebb9573e36d..76a6adc908fc 100644 --- a/frontend/common/providers/FeatureListProvider.js +++ b/frontend/common/providers/FeatureListProvider.js @@ -79,7 +79,18 @@ const FeatureListProvider = class extends React.Component { environmentFlag, segmentOverrides, ) => { - AppActions.createFlag(projectId, environmentId, flag, segmentOverrides) + AppActions.createFlag( + projectId, + environmentId, + { + ...flag, + multivariate_options: flag.multivariate_options?.map((v, i) => ({ + ...v, + key: v.key || Utils.getDefaultVariantKey(i), + })), + }, + segmentOverrides, + ) } editFeatureValue = ( @@ -94,7 +105,7 @@ const FeatureListProvider = class extends React.Component { Object.assign({}, projectFlag, { multivariate_options: flag.multivariate_options && - flag.multivariate_options.map((v) => { + flag.multivariate_options.map((v, i) => { const matchingProjectVariate = (projectFlag.multivariate_options && projectFlag.multivariate_options.find((p) => p.id === v.id)) || @@ -103,6 +114,7 @@ const FeatureListProvider = class extends React.Component { ...v, default_percentage_allocation: matchingProjectVariate.default_percentage_allocation, + key: v.key || Utils.getDefaultVariantKey(i), } }), }), @@ -192,7 +204,7 @@ const FeatureListProvider = class extends React.Component { Object.assign({}, projectFlag, flag, { multivariate_options: flag.multivariate_options && - flag.multivariate_options.map((v) => { + flag.multivariate_options.map((v, i) => { const matchingProjectVariate = (projectFlag.multivariate_options && projectFlag.multivariate_options.find((p) => p.id === v.id)) || @@ -201,6 +213,7 @@ const FeatureListProvider = class extends React.Component { ...v, default_percentage_allocation: matchingProjectVariate.default_percentage_allocation, + key: v.key || Utils.getDefaultVariantKey(i), } }), }), diff --git a/frontend/common/utils/utils.tsx b/frontend/common/utils/utils.tsx index b8dbd013f4c4..2b9f993acd26 100644 --- a/frontend/common/utils/utils.tsx +++ b/frontend/common/utils/utils.tsx @@ -256,6 +256,11 @@ const Utils = Object.assign({}, BaseUtils, { OrganisationPermission.CREATE_PROJECT ] }, + // The label a variant displays (and is saved with) when the user never + // sets one — keep display, validation and save payloads consistent. + getDefaultVariantKey(index: number) { + return `Variant_${index + 1}` + }, getExistingWaitForTime: ( waitFor: string | undefined, ): diff --git a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx index b2fabf8fa876..387c646d31e5 100644 --- a/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx +++ b/frontend/web/components/modals/create-feature/tabs/FeatureValueTab.tsx @@ -98,18 +98,18 @@ const FeatureValueTab: FC = ({ const addVariation = () => { // Default the label to the first free Variant_n so new variants are // saved with a key even if the user never edits it. Variants with no - // key display as Variant_{index + 1}, so avoid those names too. + // key display (and persist) their fallback, so avoid those names too. const existingNames = multivariate_options.map( - (option, index) => option.key || `Variant_${index + 1}`, + (option, index) => option.key || Utils.getDefaultVariantKey(index), ) - let variantNumber = multivariate_options.length + 1 - while (existingNames.includes(`Variant_${variantNumber}`)) { - variantNumber += 1 + let nextIndex = multivariate_options.length + while (existingNames.includes(Utils.getDefaultVariantKey(nextIndex))) { + nextIndex += 1 } const newVariation = { ...Utils.valueToFeatureState(''), default_percentage_allocation: 0, - key: `Variant_${variantNumber}`, + key: Utils.getDefaultVariantKey(nextIndex), } onProjectFlagChange({ multivariate_options: [...multivariate_options, newVariation], diff --git a/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx b/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx index 1af3ed914e6b..16a551e6516a 100644 --- a/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx +++ b/frontend/web/components/mv/VariationKeyLabel/VariationKeyLabel.tsx @@ -34,8 +34,8 @@ export const VariationKeyLabel: FC = ({ const [error, setError] = useState(null) const canEdit = !readOnly && !disabled - // Display-only placeholder when no label is set; the persisted key stays null. - const displayName = value || `Variant_${index + 1}` + // Fallback label when none is set — persisted on save by the provider. + const displayName = value || Utils.getDefaultVariantKey(index) // Validates the raw input — trimming here would hide a trailing // space from the user until their next keystroke. @@ -72,6 +72,9 @@ export const VariationKeyLabel: FC = ({ } const commit = () => { + if (error) { + return + } onChange(draft || null) setError(null) setIsEditing(false) @@ -90,13 +93,19 @@ export const VariationKeyLabel: FC = ({ value={draft} isValid={!error} maxLength={Constants.forms.maxLength.VARIANT_KEY} - placeholder={`Variant_${index + 1}`} + placeholder={Utils.getDefaultVariantKey(index)} onChange={(e: React.ChangeEvent) => { const next = Utils.safeParseEventValue(e).replace(/ /g, '_') setDraft(next) setError(validate(next)) }} - onBlur={commit} + // An invalid draft must not be committed — keep the row in edit + // mode with the error visible instead. + onBlur={() => { + if (!error) { + commit() + } + }} onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault() @@ -115,12 +124,17 @@ export const VariationKeyLabel: FC = ({