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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions frontend/documentation/components/InlinePillToggle.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<InlinePillToggle
size='small'
options={options}
value={value}
onChange={setValue}
/>
)
}

function MediumExample() {
const [value, setValue] = useState('ALL')
return (
<InlinePillToggle
size='medium'
options={options}
value={value}
onChange={setValue}
/>
)
}

function LargeExample() {
const [value, setValue] = useState('ALL')
return (
<InlinePillToggle
size='large'
options={options}
value={value}
onChange={setValue}
/>
)
}

function InlineWithTextExample() {
const [value, setValue] = useState('ALL')
return (
<span style={{ fontSize: 14, fontWeight: 600 }}>
Include users when{' '}
<InlinePillToggle
size='small'
options={options}
value={value}
onChange={setValue}
/>{' '}
of the following rules apply:
</span>
)
}

function ThreeOptionsExample() {
const [value, setValue] = useState('day')
return (
<InlinePillToggle
options={[
{ label: 'Day', value: 'day' },
{ label: 'Week', value: 'week' },
{ label: 'Month', value: 'month' },
]}
value={value}
onChange={setValue}
/>
)
}

export const Small: Story = { render: () => <SmallExample /> }
export const Medium: Story = { render: () => <MediumExample /> }
export const Large: Story = { render: () => <LargeExample /> }
export const InlineWithText: Story = { render: () => <InlineWithTextExample /> }
export const ThreeOptions: Story = { render: () => <ThreeOptionsExample /> }
10 changes: 9 additions & 1 deletion frontend/e2e/helpers/e2e-helpers.playwright.ts
Original file line number Diff line number Diff line change
Expand Up @@ -511,9 +511,17 @@ 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);
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];
Expand Down
117 changes: 116 additions & 1 deletion frontend/e2e/tests/segment-test.pw.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -359,3 +388,89 @@ 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 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,
} = 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('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)
await createTrait('team', 'alpha')

log('Create ANY-mode segment')
await gotoSegments()
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 (gets overridden value)')
await goToUser(E2E_TEST_IDENTITY)
await waitAndRefresh()
await assertUserFeatureValue(ANY_FEATURE, '"overridden"')

log('Update segment so user no longer matches')
await gotoSegments()
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 (gets default value)')
await goToUser(E2E_TEST_IDENTITY)
await waitAndRefresh()
await assertUserFeatureValue(ANY_FEATURE, '"default"')

log('Clean up feature, segment, and traits')
await gotoFeatures()
await deleteFeature(ANY_FEATURE)
await gotoSegments()
await deleteSegment(ANY_SEGMENT)
await gotoTraits(E2E_TEST_IDENTITY)
await deleteTrait('age_any')
await deleteTrait('team')
})
20 changes: 13 additions & 7 deletions frontend/web/components/SegmentRuleDivider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,30 +7,36 @@ type SegmentRuleDividerType = {
rule: SegmentRule
index: number
className?: string
topLevelRuleType?: 'ALL' | 'ANY'
}

const typeLabel: Record<SegmentRule['type'], string> = {
ALL: 'All of the following',
ANY: 'Any of the following',
NONE: 'None of the following',
}

const SegmentRuleDivider: FC<SegmentRuleDividerType> = ({
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 (
<Row
className={classNames(
'and-divider',
{
'text-danger': rule.type !== 'ANY',
'text-danger': rule.type === 'NONE',
},
className || 'my-1',
)}
>
<Flex className='and-divider__line' />
{Format.camelCase(
`${index > 0 ? 'And ' : ''}${
rule.type === 'ANY' ? 'Any of the following' : 'None of the following'
}`,
)}
{Format.camelCase(`${prefix}${typeLabel[rule.type]}`)}
<Flex className='and-divider__line' />
</Row>
)
Expand Down
81 changes: 81 additions & 0 deletions frontend/web/components/base/forms/InlinePillToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import React, { useRef } from 'react'
import cn from 'classnames'

type InlinePillToggleSize = 'small' | 'medium' | 'large'

type Option<T extends string> = {
label: string
value: T
}

type InlinePillToggleProps<T extends string> = {
options: Option<T>[]
value: T
onChange: (value: T) => void
size?: InlinePillToggleSize
className?: string
'data-test'?: string
}

function InlinePillToggle<T extends string>({
className,
'data-test': dataTest,
onChange,
options,
size = 'medium',
value,
}: InlinePillToggleProps<T>) {
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 (
<div
className={cn(
'inline-pill-toggle',
`inline-pill-toggle--${size}`,
className,
)}
data-test={dataTest}
role='radiogroup'
Comment thread
talissoncosta marked this conversation as resolved.
>
{options.map((option, index) => {
const isActive = value === option.value
return (
<button
key={option.value}
ref={(el) => {
buttonRefs.current[index] = el
}}
type='button'
role='radio'
aria-checked={isActive}
tabIndex={isActive ? 0 : -1}
data-test={dataTest ? `${dataTest}-${option.value}` : undefined}
className={cn('inline-pill-toggle__option', {
'inline-pill-toggle__option--active': isActive,
})}
onClick={() => onChange(option.value)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{option.label}
</button>
)
})}
</div>
)
}

export default InlinePillToggle
Loading
Loading