From cacb97c1d3902f49efe4644c86e92d22eaf169a4 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 5 May 2026 12:42:28 +0200 Subject: [PATCH 01/10] feat: implemented toggle any and all rules with correct text diff --- .../components/InlinePillToggle.stories.tsx | 98 +++++++++++++++++++ .../web/components/SegmentRuleDivider.tsx | 20 ++-- .../base/forms/InlinePillToggle.tsx | 56 +++++++++++ .../web/components/modals/CreateSegment.tsx | 67 ++++++++++--- .../modals/CreateSegmentRulesTabForm.tsx | 37 ++++++- .../web/components/segments/Rule/Rule.tsx | 3 + .../Rule/components/RuleConditionRow.tsx | 6 +- frontend/web/styles/components/_index.scss | 1 + .../components/_inline-pill-toggle.scss | 57 +++++++++++ 9 files changed, 317 insertions(+), 28 deletions(-) create mode 100644 frontend/documentation/components/InlinePillToggle.stories.tsx create mode 100644 frontend/web/components/base/forms/InlinePillToggle.tsx create mode 100644 frontend/web/styles/components/_inline-pill-toggle.scss diff --git a/frontend/documentation/components/InlinePillToggle.stories.tsx b/frontend/documentation/components/InlinePillToggle.stories.tsx new file mode 100644 index 000000000000..560a230596b7 --- /dev/null +++ b/frontend/documentation/components/InlinePillToggle.stories.tsx @@ -0,0 +1,98 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from 'storybook' + +import InlinePillToggle from 'components/base/forms/InlinePillToggle' + +const meta: Meta = { + parameters: { + docs: { + description: { + component: + 'A compact inline segmented control for toggling between two or more options. Available in small, medium, and large sizes.', + }, + }, + layout: 'padded', + }, + title: 'Components/Forms/InlinePillToggle', +} +export default meta + +type Story = StoryObj + +const options = [ + { label: 'ALL', value: 'ALL' }, + { label: 'ANY', value: 'ANY' }, +] + +function SmallExample() { + const [value, setValue] = useState('ALL') + return ( + + ) +} + +function MediumExample() { + const [value, setValue] = useState('ALL') + return ( + + ) +} + +function LargeExample() { + const [value, setValue] = useState('ALL') + return ( + + ) +} + +function InlineWithTextExample() { + const [value, setValue] = useState('ALL') + return ( + + Include users when{' '} + {' '} + of the following rules apply: + + ) +} + +function ThreeOptionsExample() { + const [value, setValue] = useState('day') + return ( + + ) +} + +export const Small: Story = { render: () => } +export const Medium: Story = { render: () => } +export const Large: Story = { render: () => } +export const InlineWithText: Story = { render: () => } +export const ThreeOptions: Story = { render: () => } diff --git a/frontend/web/components/SegmentRuleDivider.tsx b/frontend/web/components/SegmentRuleDivider.tsx index 5d64c8e8c5d3..a1958842b919 100644 --- a/frontend/web/components/SegmentRuleDivider.tsx +++ b/frontend/web/components/SegmentRuleDivider.tsx @@ -7,30 +7,36 @@ type SegmentRuleDividerType = { rule: SegmentRule index: number className?: string + topLevelRuleType?: 'ALL' | 'ANY' +} + +const typeLabel: Record = { + ALL: 'All of the following', + ANY: 'Any of the following', + NONE: 'None of the following', } const SegmentRuleDivider: FC = ({ className, index, rule, + topLevelRuleType = 'ALL', }) => { - if (rule?.type === 'ALL') return null + if (rule?.type === 'ALL' && topLevelRuleType === 'ALL') return null + const connector = topLevelRuleType === 'ANY' ? 'Or' : 'And' + const prefix = index > 0 ? `${connector} ` : '' return ( - {Format.camelCase( - `${index > 0 ? 'And ' : ''}${ - rule.type === 'ANY' ? 'Any of the following' : 'None of the following' - }`, - )} + {Format.camelCase(`${prefix}${typeLabel[rule.type]}`)} ) diff --git a/frontend/web/components/base/forms/InlinePillToggle.tsx b/frontend/web/components/base/forms/InlinePillToggle.tsx new file mode 100644 index 000000000000..0df74cf2d852 --- /dev/null +++ b/frontend/web/components/base/forms/InlinePillToggle.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import cn from 'classnames' + +type InlinePillToggleSize = 'small' | 'medium' | 'large' + +type Option = { + label: string + value: T +} + +type InlinePillToggleProps = { + options: Option[] + value: T + onChange: (value: T) => void + size?: InlinePillToggleSize + className?: string + 'data-test'?: string +} + +function InlinePillToggle({ + className, + 'data-test': dataTest, + onChange, + options, + size = 'medium', + value, +}: InlinePillToggleProps) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} + +export default InlinePillToggle diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index 43fb8fc2279d..b859d7839ae0 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -229,6 +229,23 @@ const CreateSegment: FC = ({ setRules(newRules) } + const topLevelRuleType: 'ALL' | 'ANY' = + rules[0]?.type === 'ANY' ? 'ANY' : 'ALL' + + const setTopLevelRuleType = (newType: 'ALL' | 'ANY') => { + const subRuleType = newType === 'ANY' ? 'ALL' : 'ANY' + const newRules = cloneDeep(rules) + for (const topRule of newRules) { + topRule.type = newType + topRule.rules = topRule.rules.map((subRule) => { + if (subRule.delete) return subRule + return { ...subRule, type: subRuleType as SegmentRule['type'] } + }) + } + setRules(newRules) + setValueChanged(true) + } + const save = async (e: FormEvent) => { try { Utils.preventDefault(e) @@ -414,7 +431,11 @@ const CreateSegment: FC = ({ const displayIndex = rulesToShow.indexOf(rule) return (
- + = ({ rule={rule} index={i} operators={operators} + conditionLabel={rule.type === 'ALL' ? 'And' : 'Or'} onChange={(v: SegmentRule) => { setValueChanged(true) updateRule(rulesIndex, i, v) @@ -438,27 +460,38 @@ const CreateSegment: FC = ({
{hasNoRules && ( - Add at least one AND/NOT rule to create a segment. + {topLevelRuleType === 'ANY' + ? 'Add at least one OR rule to create a segment.' + : 'Add at least one AND/NOT rule to create a segment.'} )} {!readOnly && ( -
addRule('ANY')} className='text-center'> +
+ addRule(topLevelRuleType === 'ANY' ? 'ALL' : 'ANY') + } + className='text-center' + > +
+ )} + {topLevelRuleType !== 'ANY' && ( +
addRule('NONE')} className='text-center'> +
)} -
addRule('NONE')} className='text-center'> - -
@@ -536,6 +569,8 @@ const CreateSegment: FC = ({ isValid={isValid} isLimitReached={isLimitReached} onCancel={onCancel} + topLevelRuleType={topLevelRuleType} + setTopLevelRuleType={setTopLevelRuleType} /> @@ -610,6 +645,8 @@ const CreateSegment: FC = ({ isValid={isValid} isLimitReached={isLimitReached} onCancel={onCancel} + topLevelRuleType={topLevelRuleType} + setTopLevelRuleType={setTopLevelRuleType} /> @@ -647,6 +684,8 @@ const CreateSegment: FC = ({ isValid={isValid} isLimitReached={isLimitReached} onCancel={onCancel} + topLevelRuleType={topLevelRuleType} + setTopLevelRuleType={setTopLevelRuleType} /> )} diff --git a/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx b/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx index 0fc0d8f6c9ea..6387b22d9460 100644 --- a/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx +++ b/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx @@ -4,6 +4,7 @@ import Input from 'components/base/forms/Input' import InputGroup from 'components/base/forms/InputGroup' import Switch from 'components/Switch' import Button from 'components/base/forms/Button' +import InlinePillToggle from 'components/base/forms/InlinePillToggle' import Format from 'common/utils/format' import Utils from 'common/utils/utils' import Constants from 'common/constants' @@ -45,6 +46,8 @@ interface CreateSegmentRulesTabFormProps { isValid: boolean isLimitReached: boolean onCancel?: () => void + topLevelRuleType?: 'ALL' | 'ANY' + setTopLevelRuleType?: (type: 'ALL' | 'ANY') => void } const CreateSegmentRulesTabForm: React.FC = ({ @@ -68,8 +71,10 @@ const CreateSegmentRulesTabForm: React.FC = ({ setDescription, setName, setShowDescriptions, + setTopLevelRuleType, setValueChanged, showDescriptions, + topLevelRuleType = 'ALL', }) => { const SEGMENT_ID_MAXLENGTH = Constants.forms.maxLength.SEGMENT_ID @@ -184,11 +189,6 @@ const CreateSegmentRulesTabForm: React.FC = ({ Show condition descriptions
- - - {!condensed && ( Trait names are case sensitive. Learn more about rule and trait @@ -199,6 +199,33 @@ const CreateSegmentRulesTabForm: React.FC = ({ . )} + {!readOnly && + setTopLevelRuleType && + Utils.getFlagsmithHasFeature('segment_any_rule_type') ? ( + + + + + + ) : ( + + + + )} {allWarnings?.map((warning, i) => (
diff --git a/frontend/web/components/segments/Rule/Rule.tsx b/frontend/web/components/segments/Rule/Rule.tsx index 55b8f1cb34e3..20c4d5d41315 100644 --- a/frontend/web/components/segments/Rule/Rule.tsx +++ b/frontend/web/components/segments/Rule/Rule.tsx @@ -27,9 +27,11 @@ interface RuleProps { 'data-test'?: string errors: SegmentConditionsError[] projectId: number + conditionLabel?: string } const Rule: React.FC = ({ + conditionLabel, 'data-test': dataTest, errors, onChange, @@ -201,6 +203,7 @@ const Rule: React.FC = ({ rules={rules} data-test={`${dataTest}`} projectId={projectId} + conditionLabel={conditionLabel} /> ))}
diff --git a/frontend/web/components/segments/Rule/components/RuleConditionRow.tsx b/frontend/web/components/segments/Rule/components/RuleConditionRow.tsx index 6712bbce68e3..64baf1dc8013 100644 --- a/frontend/web/components/segments/Rule/components/RuleConditionRow.tsx +++ b/frontend/web/components/segments/Rule/components/RuleConditionRow.tsx @@ -31,10 +31,12 @@ interface RuleConditionRowProps { addRule: () => void rules: SegmentCondition[] projectId: number + conditionLabel?: string } const RuleConditionRow: React.FC = ({ addRule, + conditionLabel = 'Or', 'data-test': dataTest, errors: ruleErrors, operators, @@ -75,7 +77,7 @@ const RuleConditionRow: React.FC = ({
- Or + {conditionLabel}
@@ -141,7 +143,7 @@ const RuleConditionRow: React.FC = ({ type='button' onClick={addRule} > - Or + {conditionLabel} ) : (
diff --git a/frontend/web/styles/components/_index.scss b/frontend/web/styles/components/_index.scss index c0f41182d1d1..d8bdcfe21eea 100644 --- a/frontend/web/styles/components/_index.scss +++ b/frontend/web/styles/components/_index.scss @@ -17,3 +17,4 @@ @import 'feature-row-skeleton'; @import 'empty-state'; @import 'oauth-authorize'; +@import 'inline-pill-toggle'; diff --git a/frontend/web/styles/components/_inline-pill-toggle.scss b/frontend/web/styles/components/_inline-pill-toggle.scss new file mode 100644 index 000000000000..7e315f8a4dca --- /dev/null +++ b/frontend/web/styles/components/_inline-pill-toggle.scss @@ -0,0 +1,57 @@ +.inline-pill-toggle { + display: inline-flex; + background-color: $pill-bg; + border-radius: $border-radius-lg; + vertical-align: middle; + + &--small { + padding: 2px; + gap: 1px; + } + + &--medium { + padding: 3px; + gap: 2px; + } + + &--large { + padding: 4px; + gap: 2px; + } + + &__option { + border: none; + border-radius: calc(#{$border-radius-lg} - 2px); + background: transparent; + color: $text-icon-grey; + font-weight: 700; + font-family: inherit; + letter-spacing: 0.3px; + text-transform: uppercase; + cursor: pointer; + white-space: nowrap; + + .inline-pill-toggle--small & { + padding: 0 6px; + font-size: $font-caption-xs; + line-height: 18px; + } + + .inline-pill-toggle--medium & { + padding: 1px 8px; + font-size: $font-caption-sm; + line-height: 20px; + } + + .inline-pill-toggle--large & { + padding: 2px 12px; + font-size: $font-caption; + line-height: $btn-line-height-sm; + } + + &--active { + background-color: $primary; + color: $text-icon-light; + } + } +} From 8f2f7043f6b759ebd2a8b2d533a98a2755062c0d Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 5 May 2026 13:40:05 +0200 Subject: [PATCH 02/10] feat: show error message when top rules have different types --- frontend/web/components/modals/CreateSegment.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index b859d7839ae0..7225b6564a4b 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -231,6 +231,8 @@ const CreateSegment: FC = ({ const topLevelRuleType: 'ALL' | 'ANY' = rules[0]?.type === 'ANY' ? 'ANY' : 'ALL' + const hasMixedTopLevelRuleTypes = + rules.length > 1 && rules.some((r) => r.type !== rules[0]?.type) const setTopLevelRuleType = (newType: 'ALL' | 'ANY') => { const subRuleType = newType === 'ANY' ? 'ALL' : 'ANY' @@ -422,6 +424,12 @@ const CreateSegment: FC = ({ const rulesEl = (
+ {hasMixedTopLevelRuleTypes && ( + + This segment has top-level rules with mixed types. Changing the rule + type will normalise all top-level rules to the same type. + + )}
{rules.map((topRule, rulesIndex) => topRule.rules.map((rule, i) => { From 83c0d83782a7c30fdd616b29b0d8ba0884631655 Mon Sep 17 00:00:00 2001 From: wadii Date: Tue, 5 May 2026 17:21:23 +0200 Subject: [PATCH 03/10] feat: render top-level any rules in segment diff --- frontend/web/components/diff/diff-utils.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/web/components/diff/diff-utils.ts b/frontend/web/components/diff/diff-utils.ts index 5c1f96b66763..2f6cf4202410 100644 --- a/frontend/web/components/diff/diff-utils.ts +++ b/frontend/web/components/diff/diff-utils.ts @@ -215,8 +215,8 @@ export function getSegmentDiff( ): TSegmentDiff { const oldRules = oldSegment?.rules || [] const newRules = newSegment?.rules || [] - const oldString = getRulesDiff(oldRules, 0) - const newString = getRulesDiff(newRules, 0) + const oldString = getRulesDiff(oldRules, 0, '', oldRules[0]?.type) + const newString = getRulesDiff(newRules, 0, '', newRules[0]?.type) return { newString, oldString, totalChanges: newString === oldString ? 0 : 1 } } @@ -224,21 +224,25 @@ function getRulesDiff( rules: SegmentRule[], level: number, currentString = '', + parentType: SegmentRule['type'] = 'ALL', ): string { const indent = ' '.repeat(level) let str = currentString || `${indent}\n` + const connector = parentType === 'ANY' ? 'Or' : 'And' rules.forEach((rule, i) => { - const prepend = i > 0 ? 'And ' : '' + const prepend = i > 0 ? `${connector} ` : '' if (rule.type === 'ANY') { str += `${indent}------ ${prepend}Any of the following ------\n` + } else if (rule.type === 'ALL') { + str += `${indent}------ ${prepend}All of the following ------\n` } else if (rule.type === 'NONE') { str += `${indent}------ ${prepend}None of the following ------\n` } str = getConditionsDiff(rule.conditions, level + 1, str) if (rule.rules) { - str = getRulesDiff(rule.rules, level + 1, str) + str = getRulesDiff(rule.rules, level + 1, str, rule.type) } }) From aff9247034714feb715309171a6329bdb1c62e73 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 6 May 2026 09:18:58 +0200 Subject: [PATCH 04/10] feat: added segment rules any type e2e tests --- .../e2e/helpers/e2e-helpers.playwright.ts | 7 +- frontend/e2e/tests/segment-test.pw.ts | 103 +++++++++++++++++- .../base/forms/InlinePillToggle.tsx | 1 + 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/frontend/e2e/helpers/e2e-helpers.playwright.ts b/frontend/e2e/helpers/e2e-helpers.playwright.ts index ef09172c9399..3bddaa9df231 100644 --- a/frontend/e2e/helpers/e2e-helpers.playwright.ts +++ b/frontend/e2e/helpers/e2e-helpers.playwright.ts @@ -511,9 +511,14 @@ export class E2EHelpers { } // Create a segment - async createSegment(name: string, rules?: Rule[]) { + async createSegment( + name: string, + rules?: Rule[], + topLevelRuleType: 'ALL' | 'ANY' = 'ALL', + ) { await this.click(byId('show-create-segment-btn')); await this.setText(byId('segmentID'), name); + await this.click(byId(`top-level-rule-type-${topLevelRuleType}`)); if (rules && rules.length > 0) { for (let x = 0; x < rules.length; x++) { const rule = rules[x]; diff --git a/frontend/e2e/tests/segment-test.pw.ts b/frontend/e2e/tests/segment-test.pw.ts index 3f0455741055..89ea57d1bbf4 100644 --- a/frontend/e2e/tests/segment-test.pw.ts +++ b/frontend/e2e/tests/segment-test.pw.ts @@ -1,5 +1,5 @@ import { test, expect } from '../test-setup'; -import { byId, log, createHelpers, visualSnapshot } from '../helpers'; +import { byId, log, createHelpers, visualSnapshot, getFlagsmith } from '../helpers'; import { E2E_USER, PASSWORD, E2E_TEST_IDENTITY, E2E_SEGMENT_PROJECT_1, E2E_SEGMENT_PROJECT_2, E2E_SEGMENT_PROJECT_3 } from '../config' const REMOTE_CONFIG_FEATURE = 'remote_config' @@ -53,6 +53,35 @@ const segmentRules = [ }, ] +// (age_any = 18 AND team = "alpha") OR (age_any = 25 AND team = "beta") +// Note: `ors` field is reused for "additional conditions in the same group" — in ANY mode these are AND-ed. +const segmentAnyRules = [ + { + name: 'age_any', + operator: 'EQUAL', + value: 18, + ors: [ + { + name: 'team', + operator: 'EQUAL', + value: 'alpha', + }, + ], + }, + { + name: 'age_any', + operator: 'EQUAL', + value: 25, + ors: [ + { + name: 'team', + operator: 'EQUAL', + value: 'beta', + }, + ], + }, +] + test('Segment test 1 - Create, update, and manage segments with multivariate flags @oss', async ({ page }, testInfo) => { const { addSegmentOverride, @@ -359,3 +388,75 @@ test('Segment test 3 - Test user-specific feature overrides @oss', async ({ page await deleteFeature(FLAG_FEATURE) await deleteFeature(REMOTE_CONFIG_FEATURE) }) + +test('Segment test 4 - Create ANY rule type segment and verify match changes when rule is updated @oss', async ({ page }) => { + const { + click, + createSegment, + createTrait, + deleteSegment, + deleteTrait, + goToUser, + gotoProject, + gotoSegments, + gotoTraits, + login, + navigateToSegment, + setSegmentRule, + waitAndRefresh, + waitForElementVisible, + } = createHelpers(page) + const flagsmith = await getFlagsmith() + const hasFeature = flagsmith.hasFeature('segment_any_rule_type') + + log('Login') + await login(E2E_USER, PASSWORD) + + if (!hasFeature) { + log('Skipping ANY segment test, feature not enabled.') + test.skip() + return + } + + await gotoProject(E2E_SEGMENT_PROJECT_1) + await waitForElementVisible(byId('features-page')) + + log('Set traits matching the first ANY group') + await gotoTraits(E2E_TEST_IDENTITY) + await createTrait('age_any', 18) + await createTrait('team', 'alpha') + + log('Create ANY-mode segment') + await gotoSegments() + await createSegment('any_segment_test', segmentAnyRules, 'ANY') + + log('Verify user is in the segment (matches first ANY group)') + await goToUser(E2E_TEST_IDENTITY) + await waitAndRefresh() + const matchingSegment = page + .locator('[data-test^="segment-"][data-test$="-name"]') + .filter({ hasText: 'any_segment_test' }) + await expect(matchingSegment).toBeVisible() + + log('Update segment so user no longer matches') + await gotoSegments() + await navigateToSegment('any_segment_test') + // Change the first group's `team` condition from "alpha" to "gamma" — user has team=alpha so no longer matches. + await setSegmentRule(0, 1, 'team', 'EQUAL', 'gamma') + await click(byId('update-segment')) + + log('Verify user is no longer in the segment') + await goToUser(E2E_TEST_IDENTITY) + await waitAndRefresh() + const stillMatching = page + .locator('[data-test^="segment-"][data-test$="-name"]') + .filter({ hasText: 'any_segment_test' }) + await expect(stillMatching).toHaveCount(0) + + log('Clean up segment and traits') + await gotoSegments() + await deleteSegment('any_segment_test') + await gotoTraits(E2E_TEST_IDENTITY) + await deleteTrait('age_any') + await deleteTrait('team') +}) diff --git a/frontend/web/components/base/forms/InlinePillToggle.tsx b/frontend/web/components/base/forms/InlinePillToggle.tsx index 0df74cf2d852..3670f42b1e04 100644 --- a/frontend/web/components/base/forms/InlinePillToggle.tsx +++ b/frontend/web/components/base/forms/InlinePillToggle.tsx @@ -41,6 +41,7 @@ function InlinePillToggle({ type='button' role='radio' aria-checked={value === option.value} + data-test={dataTest ? `${dataTest}-${option.value}` : undefined} className={cn('inline-pill-toggle__option', { 'inline-pill-toggle__option--active': value === option.value, })} From da5bb9807ad683e1104e044fa103055471abd913 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 6 May 2026 10:41:17 +0200 Subject: [PATCH 05/10] feat: create feature in segment 4 test --- frontend/e2e/tests/segment-test.pw.ts | 42 ++++++++++++++++++--------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/frontend/e2e/tests/segment-test.pw.ts b/frontend/e2e/tests/segment-test.pw.ts index 89ea57d1bbf4..e255a7aef2e8 100644 --- a/frontend/e2e/tests/segment-test.pw.ts +++ b/frontend/e2e/tests/segment-test.pw.ts @@ -390,18 +390,27 @@ test('Segment test 3 - Test user-specific feature overrides @oss', async ({ page }) test('Segment test 4 - Create ANY rule type segment and verify match changes when rule is updated @oss', async ({ page }) => { + const ANY_FEATURE = 'any_segment_feature' + const ANY_SEGMENT = 'any_segment_test' const { + addSegmentOverrideConfig, + assertUserFeatureValue, click, + createRemoteConfig, createSegment, createTrait, + deleteFeature, deleteSegment, deleteTrait, goToUser, + gotoFeature, + gotoFeatures, gotoProject, gotoSegments, gotoTraits, login, navigateToSegment, + saveFeatureSegments, setSegmentRule, waitAndRefresh, waitForElementVisible, @@ -421,6 +430,9 @@ test('Segment test 4 - Create ANY rule type segment and verify match changes whe await gotoProject(E2E_SEGMENT_PROJECT_1) await waitForElementVisible(byId('features-page')) + log('Create remote config feature with default value') + await createRemoteConfig({ name: ANY_FEATURE, value: 'default' }) + log('Set traits matching the first ANY group') await gotoTraits(E2E_TEST_IDENTITY) await createTrait('age_any', 18) @@ -428,34 +440,36 @@ test('Segment test 4 - Create ANY rule type segment and verify match changes whe log('Create ANY-mode segment') await gotoSegments() - await createSegment('any_segment_test', segmentAnyRules, 'ANY') + await createSegment(ANY_SEGMENT, segmentAnyRules, 'ANY') + + log('Override feature value via segment') + await gotoFeatures() + await gotoFeature(ANY_FEATURE) + await addSegmentOverrideConfig(0, 'overridden', 0) + await saveFeatureSegments() - log('Verify user is in the segment (matches first ANY group)') + log('Verify user is in the segment (gets overridden value)') await goToUser(E2E_TEST_IDENTITY) await waitAndRefresh() - const matchingSegment = page - .locator('[data-test^="segment-"][data-test$="-name"]') - .filter({ hasText: 'any_segment_test' }) - await expect(matchingSegment).toBeVisible() + await assertUserFeatureValue(ANY_FEATURE, '"overridden"') log('Update segment so user no longer matches') await gotoSegments() - await navigateToSegment('any_segment_test') + await navigateToSegment(ANY_SEGMENT) // Change the first group's `team` condition from "alpha" to "gamma" — user has team=alpha so no longer matches. await setSegmentRule(0, 1, 'team', 'EQUAL', 'gamma') await click(byId('update-segment')) - log('Verify user is no longer in the segment') + log('Verify user is no longer in the segment (gets default value)') await goToUser(E2E_TEST_IDENTITY) await waitAndRefresh() - const stillMatching = page - .locator('[data-test^="segment-"][data-test$="-name"]') - .filter({ hasText: 'any_segment_test' }) - await expect(stillMatching).toHaveCount(0) + await assertUserFeatureValue(ANY_FEATURE, '"default"') - log('Clean up segment and traits') + log('Clean up feature, segment, and traits') + await gotoFeatures() + await deleteFeature(ANY_FEATURE) await gotoSegments() - await deleteSegment('any_segment_test') + await deleteSegment(ANY_SEGMENT) await gotoTraits(E2E_TEST_IDENTITY) await deleteTrait('age_any') await deleteTrait('team') From c5a692c61e7e6139bef3e962745992f86414efc2 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 6 May 2026 11:06:46 +0200 Subject: [PATCH 06/10] deps: bump engine to 10.1.0 --- api/poetry.lock | 10 +++++----- api/pyproject.toml | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/poetry.lock b/api/poetry.lock index ba8c445098be..cfe554534102 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -2102,14 +2102,14 @@ test-tools = ["pyfakefs (>=5,<6)", "pytest-django (>=4,<5)"] [[package]] name = "flagsmith-flag-engine" -version = "10.0.4" +version = "10.1.0" description = "Flag engine for the Flagsmith API." optional = false python-versions = "*" groups = ["main", "workflows"] files = [ - {file = "flagsmith_flag_engine-10.0.4-py3-none-any.whl", hash = "sha256:3d9fc0eaf7ec9bc9251de781a652b77c962115bdcc81b2b8a800655849ccdc3f"}, - {file = "flagsmith_flag_engine-10.0.4.tar.gz", hash = "sha256:bf71712c5cce62311c7a9da01f1a7a7d7a97c86655a76f4efdfb6c975f93563c"}, + {file = "flagsmith_flag_engine-10.1.0-py3-none-any.whl", hash = "sha256:767dcf2f32586948eaa7816b5cbdae272d76d89e30c4642cbd74894c89a2d469"}, + {file = "flagsmith_flag_engine-10.1.0.tar.gz", hash = "sha256:fcb7e6833a874001c4ad3b91a66a4c31f050d53d94b116f88ad5c7ecd9650e8a"}, ] [package.dependencies] @@ -6023,4 +6023,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">3.11,<3.14" -content-hash = "76493b905db6f50e6692f6f2577c47d7c4c1e35efa7756a81acfa1502cdbff82" +content-hash = "13c3c174bc913f77b4998499cc0e1d8feea33c521e5cd747d77743bb8fc422ed" diff --git a/api/pyproject.toml b/api/pyproject.toml index f9fd4797f7cd..702e542fab38 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -150,7 +150,7 @@ environs = "^14.1.1" django-lifecycle = "~1.2.4" drf-writable-nested = "~0.6.2" django-filter = "~2.4.0" -flagsmith-flag-engine = "^10.0.3" +flagsmith-flag-engine = "^10.1.0" boto3 = "~1.35.95" slack-sdk = "~3.9.0" asgiref = "~3.8.1" From 984ef893462e41921de122be5248e171086a9cbf Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 7 May 2026 16:35:08 +0200 Subject: [PATCH 07/10] feat: use semantic tokens in pill --- .../web/styles/components/_inline-pill-toggle.scss | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/web/styles/components/_inline-pill-toggle.scss b/frontend/web/styles/components/_inline-pill-toggle.scss index 7e315f8a4dca..adc5593333b5 100644 --- a/frontend/web/styles/components/_inline-pill-toggle.scss +++ b/frontend/web/styles/components/_inline-pill-toggle.scss @@ -1,7 +1,7 @@ .inline-pill-toggle { display: inline-flex; - background-color: $pill-bg; - border-radius: $border-radius-lg; + background-color: var(--color-surface-muted); + border-radius: var(--radius-lg); vertical-align: middle; &--small { @@ -21,9 +21,9 @@ &__option { border: none; - border-radius: calc(#{$border-radius-lg} - 2px); + border-radius: var(--radius-md); background: transparent; - color: $text-icon-grey; + color: var(--color-text-secondary); font-weight: 700; font-family: inherit; letter-spacing: 0.3px; @@ -50,8 +50,8 @@ } &--active { - background-color: $primary; - color: $text-icon-light; + background-color: var(--color-surface-action); + color: var(--slate-0); } } } From 4b8ece99be2f9f7f5b1fe94ef216a470a7d9b224 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 7 May 2026 16:37:41 +0200 Subject: [PATCH 08/10] feat:gate top-level rule type click on segment_any_rule_type flag --- frontend/e2e/helpers/e2e-helpers.playwright.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/e2e/helpers/e2e-helpers.playwright.ts b/frontend/e2e/helpers/e2e-helpers.playwright.ts index 3bddaa9df231..543efe790b67 100644 --- a/frontend/e2e/helpers/e2e-helpers.playwright.ts +++ b/frontend/e2e/helpers/e2e-helpers.playwright.ts @@ -518,7 +518,10 @@ export class E2EHelpers { ) { await this.click(byId('show-create-segment-btn')); await this.setText(byId('segmentID'), name); - await this.click(byId(`top-level-rule-type-${topLevelRuleType}`)); + const flagsmith = await getFlagsmith(); + if (flagsmith.hasFeature('segment_any_rule_type')) { + await this.click(byId(`top-level-rule-type-${topLevelRuleType}`)); + } if (rules && rules.length > 0) { for (let x = 0; x < rules.length; x++) { const rule = rules[x]; From 2ad908923ab5eae6d080504f07bfa22b475e37d3 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 7 May 2026 16:41:32 +0200 Subject: [PATCH 09/10] feat: add keyboard nav and focus-visible to InlinePillToggle --- .../base/forms/InlinePillToggle.tsx | 56 +++++++++++++------ .../modals/CreateSegmentRulesTabForm.tsx | 4 +- .../components/_inline-pill-toggle.scss | 5 ++ 3 files changed, 47 insertions(+), 18 deletions(-) diff --git a/frontend/web/components/base/forms/InlinePillToggle.tsx b/frontend/web/components/base/forms/InlinePillToggle.tsx index 3670f42b1e04..65ba8053fffc 100644 --- a/frontend/web/components/base/forms/InlinePillToggle.tsx +++ b/frontend/web/components/base/forms/InlinePillToggle.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useRef } from 'react' import cn from 'classnames' type InlinePillToggleSize = 'small' | 'medium' | 'large' @@ -25,6 +25,22 @@ function InlinePillToggle({ size = 'medium', value, }: InlinePillToggleProps) { + const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]) + + const handleKeyDown = (e: React.KeyboardEvent, index: number) => { + let next: number | null = null + if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + next = (index + 1) % options.length + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + next = (index - 1 + options.length) % options.length + } + if (next !== null) { + e.preventDefault() + onChange(options[next].value) + buttonRefs.current[next]?.focus() + } + } + return (
({ data-test={dataTest} role='radiogroup' > - {options.map((option) => ( - - ))} + {options.map((option, index) => { + const isActive = value === option.value + return ( + + ) + })}
) } diff --git a/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx b/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx index 6387b22d9460..0c9663be9b0f 100644 --- a/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx +++ b/frontend/web/components/modals/CreateSegmentRulesTabForm.tsx @@ -208,8 +208,8 @@ const CreateSegmentRulesTabForm: React.FC = ({ data-test='top-level-rule-type' size='medium' options={[ - { label: 'ALL', value: 'ALL' as const }, - { label: 'ANY', value: 'ANY' as const }, + { label: 'ALL', value: 'ALL' }, + { label: 'ANY', value: 'ANY' }, ]} value={topLevelRuleType} onChange={setTopLevelRuleType} diff --git a/frontend/web/styles/components/_inline-pill-toggle.scss b/frontend/web/styles/components/_inline-pill-toggle.scss index adc5593333b5..f5dba6288744 100644 --- a/frontend/web/styles/components/_inline-pill-toggle.scss +++ b/frontend/web/styles/components/_inline-pill-toggle.scss @@ -31,6 +31,11 @@ cursor: pointer; white-space: nowrap; + &:focus-visible { + outline: 2px solid var(--color-border-action); + outline-offset: 2px; + } + .inline-pill-toggle--small & { padding: 0 6px; font-size: $font-caption-xs; From e821a394b3c02f95a89fa7719e10b429ecc6f0de Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 7 May 2026 17:27:15 +0200 Subject: [PATCH 10/10] fix: preserve NONE sub-rules when toggling top-level rule type --- frontend/web/components/modals/CreateSegment.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index f03369210d32..a4e4510a4698 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -240,7 +240,7 @@ const CreateSegment: FC = ({ for (const topRule of newRules) { topRule.type = newType topRule.rules = topRule.rules.map((subRule) => { - if (subRule.delete) return subRule + if (subRule.delete || subRule.type === 'NONE') return subRule return { ...subRule, type: subRuleType as SegmentRule['type'] } }) }