From 4a4cffd32e488634c058f31c9ca5c2422e930284 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 9 May 2026 02:58:18 +0100 Subject: [PATCH 1/9] feat(segment_membership): Surface identity counts in segments UI Adds two reusable badges sourced from the Segment.memberships field: - SegmentMembershipTotalBadge: aggregates counts across environments and shows the most recent sync time as a relative interval. Rendered on each segment row in the project segments list (with the "identities" noun) and next to the Identities tab label on the segment edit page (compact, count + sync only). - SegmentMembershipEnvBadge: per-env count, rendered as the option label inside the Identities tab's environment select. Selecting an environment in the Identities tab displays the full last_synced_at timestamp underneath the select; before any selection the row stays in place as a placeholder. The shared Option component in project-components now honours selectProps.formatOptionLabel when callers provide one, falling back to the existing label/description layout. Drops the !important on the chip's align-self so badges can opt into vertical centering inline. beep boop --- frontend/common/types/responses.ts | 6 + .../e2e/tests/segment-membership-test.pw.ts | 119 ++++++++++++++++++ .../web/components/modals/CreateSegment.tsx | 15 ++- .../modals/CreateSegmentUsersTabContent.tsx | 69 ++++++++-- .../segments/SegmentMembershipBadge.tsx | 89 +++++++++++++ .../segments/SegmentRow/SegmentRow.tsx | 2 + frontend/web/project/project-components.js | 14 ++- frontend/web/styles/components/_chip.scss | 2 +- 8 files changed, 302 insertions(+), 14 deletions(-) create mode 100644 frontend/e2e/tests/segment-membership-test.pw.ts create mode 100644 frontend/web/components/segments/SegmentMembershipBadge.tsx diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index b3edece5bd24..ef606bdb1875 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -160,6 +160,11 @@ export type SegmentRule = { conditions: SegmentCondition[] version_of: number | undefined } +export type SegmentMembership = { + environment: number + count: number + last_synced_at: string +} export type Segment = { id: number rules: SegmentRule[] @@ -169,6 +174,7 @@ export type Segment = { project: string | number feature?: number metadata: Metadata[] | [] + memberships?: SegmentMembership[] } export type ProjectChangeRequest = Omit< ChangeRequest, diff --git a/frontend/e2e/tests/segment-membership-test.pw.ts b/frontend/e2e/tests/segment-membership-test.pw.ts new file mode 100644 index 000000000000..6a05f50e323a --- /dev/null +++ b/frontend/e2e/tests/segment-membership-test.pw.ts @@ -0,0 +1,119 @@ +import { test, expect } from '../test-setup' +import { log, createHelpers } from '../helpers' +import { E2E_USER, PASSWORD, E2E_SEGMENT_PROJECT_1 } from '../config' + +const TEST_SEGMENT = 'segment_membership_badge' +const ENV_COUNTS = [42, 17] + +type Env = { id: number; name: string; api_key?: string } + +test('Segment membership badges render in list, tab, and env select @oss', async ({ + page, +}) => { + const { + createSegment, + deleteSegment, + gotoProject, + gotoSegments, + login, + waitForElementVisible, + } = createHelpers(page) + + const envs: Env[] = [] + + await page.route(/\/projects\/\d+\/environments\/\?/, async (route) => { + const response = await route.fetch() + const body = await response.json() + if (!envs.length && Array.isArray(body?.results)) { + body.results.slice(0, ENV_COUNTS.length).forEach((e: Env) => { + envs.push({ id: e.id, name: e.name, api_key: e.api_key }) + }) + } + await route.fulfill({ response, json: body }) + }) + + const memberships = () => + envs.slice(0, ENV_COUNTS.length).map((e, i) => ({ + environment: e.id, + count: ENV_COUNTS[i], + last_synced_at: new Date().toISOString(), + })) + + await page.route(/\/projects\/\d+\/segments\/\?/, async (route) => { + const response = await route.fetch() + const body = await response.json() + if (envs.length && Array.isArray(body?.results) && body.results.length) { + const target = + body.results.find((s: { name: string }) => s.name === TEST_SEGMENT) ?? + body.results[0] + target.memberships = memberships() + } + await route.fulfill({ response, json: body }) + }) + + await page.route(/\/projects\/\d+\/segments\/\d+\/?(?:\?|$)/, async (route) => { + const response = await route.fetch() + const body = await response.json() + if (envs.length && body && typeof body === 'object') { + body.memberships = memberships() + } + await route.fulfill({ response, json: body }) + }) + + log('Login and create segment') + await login(E2E_USER, PASSWORD) + await gotoProject(E2E_SEGMENT_PROJECT_1) + await waitForElementVisible('#features-page') + await gotoSegments() + await createSegment(TEST_SEGMENT, [ + { name: 'plan', operator: 'EQUAL', value: 'growth' }, + ]) + + log('Reload segments list with mocked memberships') + await gotoSegments() + + if (!envs.length) { + throw new Error('Expected to capture project environments via route mock') + } + + log('Assert total badge renders with sum across envs') + const total = ENV_COUNTS.reduce((a, b) => a + b, 0) + const totalBadge = page + .locator('[data-test="segment-membership-total"]') + .filter({ hasText: `${total}` }) + await expect(totalBadge.first()).toBeVisible() + + log('Open segment edit page') + await page.getByText(TEST_SEGMENT).first().click() + + log('Switch to Identities tab — total badge sits next to label') + await page.getByRole('button', { name: /Identities/ }).click() + await expect( + page + .getByRole('button', { name: /Identities/ }) + .locator('[data-test="segment-membership-total"]'), + ).toBeVisible() + + log('Open environment select and assert per-env badge') + const select = page.locator('.react-select__control').first() + await select.click() + for (const env of envs.slice(0, ENV_COUNTS.length)) { + await expect( + page.locator(`[data-test="segment-membership-${env.api_key ?? ''}"]`).or( + page.locator('.react-select__option').filter({ hasText: env.name }), + ), + ).toHaveCount(1, { timeout: 5_000 }) + } + + log('Select first env — full timestamp appears below the select') + await page + .locator('.react-select__option') + .filter({ hasText: envs[0].name }) + .click() + await expect(page.getByText(/Last synced:/)).toBeVisible() + + log('Clean up test segment') + await page.goBack() + await gotoSegments() + await deleteSegment(TEST_SEGMENT) +}) diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index a4e4510a4698..1d87a4a29114 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -28,6 +28,7 @@ import { } from 'common/services/useSegment' import Utils from 'common/utils/utils' import AssociatedSegmentOverrides from 'components/segments/AssociatedSegmentOverrides' +import { SegmentMembershipTotalBadge } from 'components/segments/SegmentMembershipBadge' import Button from 'components/base/forms/Button' import InfoMessage from 'components/InfoMessage' import InputGroup from 'components/base/forms/InputGroup' @@ -582,7 +583,18 @@ const CreateSegment: FC = ({ /> - + + Identities + + + } + >
= ({ name={name} searchInput={searchInput} setSearchInput={setSearchInput} + memberships={segment.memberships} />
diff --git a/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx b/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx index 69b8c0e87248..1d665b780448 100644 --- a/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx +++ b/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx @@ -1,16 +1,19 @@ import React, { FC } from 'react' +import moment from 'moment' import EnvironmentSelect from 'components/EnvironmentSelect' import PanelSearch from 'components/PanelSearch' import InfoMessage from 'components/InfoMessage' import InputGroup from 'components/base/forms/InputGroup' import Utils from 'common/utils/utils' -import { Res } from 'common/types/responses' +import { Environment, Res, SegmentMembership } from 'common/types/responses' import Icon from 'components/icons/Icon' import { identitySegmentService, useGetIdentitySegmentsQuery, } from 'common/services/useIdentitySegment' import { getStore } from 'common/store' +import ProjectStore from 'common/stores/project-store' +import { SegmentMembershipEnvBadge } from 'components/segments/SegmentMembershipBadge' interface CreateSegmentUsersTabContentProps { projectId: string | number @@ -23,6 +26,13 @@ interface CreateSegmentUsersTabContentProps { name: string searchInput: string setSearchInput: (input: string) => void + memberships?: SegmentMembership[] +} + +type EnvOption = { + value: string + label: string + environment: Environment } type UserRowType = { @@ -80,6 +90,7 @@ const CreateSegmentUsersTabContent: React.FC< environmentId, identities, identitiesLoading, + memberships, name, page, projectId, @@ -88,6 +99,37 @@ const CreateSegmentUsersTabContent: React.FC< setPage, setSearchInput, }) => { + const membershipByEnvId = React.useMemo(() => { + const map = new Map() + ;(memberships ?? []).forEach((m) => map.set(m.environment, m)) + return map + }, [memberships]) + + const renderEnvOption = (data: unknown) => { + const { environment, label } = data as Partial + const membership = environment + ? membershipByEnvId.get(environment.id) + : undefined + return ( + + {label} + {environment && membership && ( + + )} + + ) + } + + const selectedMembership = React.useMemo(() => { + if (!environmentId) return null + const envs = (ProjectStore.getEnvs() as Environment[] | null) || [] + const env = envs.find((e) => e.api_key === environmentId) + return env ? membershipByEnvId.get(env.id) ?? null : null + }, [environmentId, membershipByEnvId]) + return ( <> @@ -100,13 +142,24 @@ const CreateSegmentUsersTabContent: React.FC< title='Environment' className='col-4' component={ - { - setEnvironmentId(environmentId) - }} - /> + <> + { + setEnvironmentId(environmentId) + }} + formatOptionLabel={renderEnvOption} + /> +
+ Last synced:{' '} + {selectedMembership + ? moment(selectedMembership.last_synced_at).format( + 'Do MMM YYYY HH:mm:ss', + ) + : '—'} +
+ } /> { + const diffSec = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000)) + if (diffSec < 60) return `${diffSec}s ago` + const diffMin = Math.round(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHr = Math.round(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + return `${Math.round(diffHr / 24)}d ago` +} + +type ChipProps = { + count: number + ago?: string + dataTest?: string + compact?: boolean +} + +const Chip: FC = ({ ago, compact, count, dataTest }) => { + const noun = count === 1 ? 'identity' : 'identities' + return ( + + + + {count} + {compact ? '' : ` ${noun}`} + {ago ? ` ~${ago}` : ''} + + + ) +} + +type TotalProps = { + memberships: SegmentMembership[] | undefined + compact?: boolean +} + +export const SegmentMembershipTotalBadge: FC = ({ + compact, + memberships, +}) => { + if (!memberships?.length) { + return null + } + const total = memberships.reduce((sum, m) => sum + m.count, 0) + const latest = memberships.reduce( + (acc, m) => (!acc || m.last_synced_at > acc ? m.last_synced_at : acc), + '', + ) + return ( + + ) +} + +type EnvProps = { + membership: SegmentMembership + environment?: Environment +} + +export const SegmentMembershipEnvBadge: FC = ({ + environment, + membership, +}) => { + const envs = (ProjectStore.getEnvs() as Environment[] | null) || [] + const env = environment ?? envs.find((e) => e.id === membership.environment) + if (!env) { + return null + } + return ( + + ) +} diff --git a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx index a965bc95ebf0..fbcae5e35c14 100644 --- a/frontend/web/components/segments/SegmentRow/SegmentRow.tsx +++ b/frontend/web/components/segments/SegmentRow/SegmentRow.tsx @@ -5,6 +5,7 @@ import { useHasPermission } from 'common/providers/Permission' import { Segment } from 'common/types/responses' import SegmentAction from './components/SegmentAction' +import { SegmentMembershipTotalBadge } from 'components/segments/SegmentMembershipBadge' import ConfirmCloneSegment from 'components/modals/ConfirmCloneSegment' import { useCloneSegmentMutation } from 'common/services/useSegment' import { handleRemoveSegment } from 'components/modals/ConfirmRemoveSegment' @@ -81,6 +82,7 @@ const SegmentRow: FC = ({ index, projectId, segment }) => { {feature && (
Feature-Specific
)} +
{description || 'No description'} diff --git a/frontend/web/project/project-components.js b/frontend/web/project/project-components.js index 696c7e0e0a9d..0dcef7ea6bb5 100644 --- a/frontend/web/project/project-components.js +++ b/frontend/web/project/project-components.js @@ -92,6 +92,15 @@ global.ToggleChip = ToggleChip // Custom Option component to show the tick mark next to selected option in the dropdown const Option = (props) => { + const { formatOptionLabel } = props.selectProps + const labelContent = formatOptionLabel + ? formatOptionLabel(props.data, { context: 'menu' }) + : ( + <> + {props.data.label} +
{props.data.description}
+ + ) return (
{ props.data.isDisabled ? 'text-muted cursor-not-allowed' : '' }`} > -
- {props.data.label} -
{props.data.description}
-
+
{labelContent}
{props.isSelected && ( )} diff --git a/frontend/web/styles/components/_chip.scss b/frontend/web/styles/components/_chip.scss index d4e0313bb5c1..0f3e07a99d85 100644 --- a/frontend/web/styles/components/_chip.scss +++ b/frontend/web/styles/components/_chip.scss @@ -27,7 +27,7 @@ .chip { display: flex; align-items: center; - align-self: flex-start !important; + align-self: flex-start; background-color: $primary-alfa-8; border: 1px solid $primary-alfa-24; padding: 5px 12px; From f87b4ebc3ff85da27337be073ff4bf3cb505b2ae Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 22 May 2026 15:55:14 +0100 Subject: [PATCH 2/9] refactor(Segment membership): Move identity noun and last-sync into a tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chip is now count-only; descriptive text (`N identities — last synced ~Xd ago`) lives in a tooltip on hover. The `compact` prop disappears since both call sites now render the same shape. beep boop --- .../web/components/modals/CreateSegment.tsx | 1 - .../segments/SegmentMembershipBadge.tsx | 52 ++++++++++--------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index 1d87a4a29114..db77d1063ac2 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -589,7 +589,6 @@ const CreateSegment: FC = ({ <> Identities diff --git a/frontend/web/components/segments/SegmentMembershipBadge.tsx b/frontend/web/components/segments/SegmentMembershipBadge.tsx index 53ab0e44150d..76feb3d61dbf 100644 --- a/frontend/web/components/segments/SegmentMembershipBadge.tsx +++ b/frontend/web/components/segments/SegmentMembershipBadge.tsx @@ -2,6 +2,7 @@ import React, { FC } from 'react' import { Environment, SegmentMembership } from 'common/types/responses' import ProjectStore from 'common/stores/project-store' +import Tooltip from 'components/Tooltip' import UsersIcon from 'components/icons/UsersIcon' const shortAgo = (iso: string): string => { @@ -14,40 +15,41 @@ const shortAgo = (iso: string): string => { return `${Math.round(diffHr / 24)}d ago` } +const formatTooltip = (count: number, lastSyncedAt: string | undefined): string => { + const noun = count === 1 ? 'identity' : 'identities' + const base = `${count} ${noun}` + return lastSyncedAt ? `${base} — last synced ~${shortAgo(lastSyncedAt)}` : base +} + type ChipProps = { count: number - ago?: string dataTest?: string - compact?: boolean + tooltip: string } -const Chip: FC = ({ ago, compact, count, dataTest }) => { - const noun = count === 1 ? 'identity' : 'identities' - return ( - - - - {count} - {compact ? '' : ` ${noun}`} - {ago ? ` ~${ago}` : ''} +const Chip: FC = ({ count, dataTest, tooltip }) => ( + + + {count} - - ) -} + } + > + {tooltip} + +) type TotalProps = { memberships: SegmentMembership[] | undefined - compact?: boolean } -export const SegmentMembershipTotalBadge: FC = ({ - compact, - memberships, -}) => { +export const SegmentMembershipTotalBadge: FC = ({ memberships }) => { if (!memberships?.length) { return null } @@ -58,9 +60,8 @@ export const SegmentMembershipTotalBadge: FC = ({ ) return ( ) @@ -83,6 +84,7 @@ export const SegmentMembershipEnvBadge: FC = ({ return ( ) From 8e80b526da489f26af6ab02fb32201ce7995b359 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 22 May 2026 15:56:29 +0100 Subject: [PATCH 3/9] refactor(Segment membership): Require environment on env badge Env is always known at every call site, so drop the ProjectStore fallback that resolved it from `membership.environment`. The prop is now required. beep boop --- .../segments/SegmentMembershipBadge.tsx | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/frontend/web/components/segments/SegmentMembershipBadge.tsx b/frontend/web/components/segments/SegmentMembershipBadge.tsx index 76feb3d61dbf..081060baccf9 100644 --- a/frontend/web/components/segments/SegmentMembershipBadge.tsx +++ b/frontend/web/components/segments/SegmentMembershipBadge.tsx @@ -1,7 +1,6 @@ import React, { FC } from 'react' import { Environment, SegmentMembership } from 'common/types/responses' -import ProjectStore from 'common/stores/project-store' import Tooltip from 'components/Tooltip' import UsersIcon from 'components/icons/UsersIcon' @@ -69,23 +68,16 @@ export const SegmentMembershipTotalBadge: FC = ({ memberships }) => type EnvProps = { membership: SegmentMembership - environment?: Environment + environment: Environment } export const SegmentMembershipEnvBadge: FC = ({ environment, membership, -}) => { - const envs = (ProjectStore.getEnvs() as Environment[] | null) || [] - const env = environment ?? envs.find((e) => e.id === membership.environment) - if (!env) { - return null - } - return ( - - ) -} +}) => ( + +) From edfb511ab66ac51e4f11e94ebc04d115ddacd7b2 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 22 May 2026 15:59:15 +0100 Subject: [PATCH 4/9] refactor(CreateSegmentUsersTabContent): Type renderEnvOption with shared option type Lifts the inline option shape out of EnvironmentSelect as EnvironmentSelectOption, then types the segment Identities-tab renderEnvOption argument directly instead of casting from `unknown`. beep boop --- frontend/web/components/EnvironmentSelect.tsx | 14 +++++++++----- .../modals/CreateSegmentUsersTabContent.tsx | 13 ++++--------- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/frontend/web/components/EnvironmentSelect.tsx b/frontend/web/components/EnvironmentSelect.tsx index 966129dabe0a..8636953ed48c 100644 --- a/frontend/web/components/EnvironmentSelect.tsx +++ b/frontend/web/components/EnvironmentSelect.tsx @@ -3,6 +3,12 @@ import { useGetEnvironmentsQuery } from 'common/services/useEnvironment' import { Props } from 'react-select' import { Environment } from 'common/types/responses' +export type EnvironmentSelectOption = { + value: string + label: string + environment: Environment | null +} + type EnvironmentSelectType = Partial> & { projectId: number value?: string @@ -73,11 +79,9 @@ const EnvironmentSelect: FC = ({ ? [{ environment: null, label: 'All Environments', value: '' }] : [] ).concat(environments)} - onChange={(value: { - value: string - label: string - environment: Environment - }) => onChange(value?.value || '', value?.environment)} + onChange={(value: EnvironmentSelectOption) => + onChange(value?.value || '', value?.environment) + } />
) diff --git a/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx b/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx index 1d665b780448..8025cd85caef 100644 --- a/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx +++ b/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx @@ -1,6 +1,8 @@ import React, { FC } from 'react' import moment from 'moment' -import EnvironmentSelect from 'components/EnvironmentSelect' +import EnvironmentSelect, { + EnvironmentSelectOption, +} from 'components/EnvironmentSelect' import PanelSearch from 'components/PanelSearch' import InfoMessage from 'components/InfoMessage' import InputGroup from 'components/base/forms/InputGroup' @@ -29,12 +31,6 @@ interface CreateSegmentUsersTabContentProps { memberships?: SegmentMembership[] } -type EnvOption = { - value: string - label: string - environment: Environment -} - type UserRowType = { id: string identifier: string @@ -105,8 +101,7 @@ const CreateSegmentUsersTabContent: React.FC< return map }, [memberships]) - const renderEnvOption = (data: unknown) => { - const { environment, label } = data as Partial + const renderEnvOption = ({ environment, label }: EnvironmentSelectOption) => { const membership = environment ? membershipByEnvId.get(environment.id) : undefined From 52bf43fd39104af12ae683a859eb322f46a54f46 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 22 May 2026 16:00:38 +0100 Subject: [PATCH 5/9] fix(e2e): Match /environments route with or without query string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous regex required a literal '?', so the route mock never fired when RTK Query called /projects//environments/ with no params — the captured env list stayed empty and the test failed before its first assertion. beep boop --- frontend/e2e/tests/segment-membership-test.pw.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/e2e/tests/segment-membership-test.pw.ts b/frontend/e2e/tests/segment-membership-test.pw.ts index 6a05f50e323a..14d9c4aa6206 100644 --- a/frontend/e2e/tests/segment-membership-test.pw.ts +++ b/frontend/e2e/tests/segment-membership-test.pw.ts @@ -21,7 +21,7 @@ test('Segment membership badges render in list, tab, and env select @oss', async const envs: Env[] = [] - await page.route(/\/projects\/\d+\/environments\/\?/, async (route) => { + await page.route(/\/projects\/\d+\/environments\/(\?|$)/, async (route) => { const response = await route.fetch() const body = await response.json() if (!envs.length && Array.isArray(body?.results)) { From 3b6c7d5284962a70d86dcf049f7bb8624bba5379 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 22 May 2026 18:35:57 +0100 Subject: [PATCH 6/9] fix(e2e): Use the actual /environments URL pattern in route mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/projects//environments/` was never a real endpoint — the frontend hits `/environments/?project=`. With the wrong regex the mock never captured the env list and the test threw at the first guard. beep boop --- frontend/e2e/tests/segment-membership-test.pw.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/e2e/tests/segment-membership-test.pw.ts b/frontend/e2e/tests/segment-membership-test.pw.ts index 14d9c4aa6206..3e8a8e2c5446 100644 --- a/frontend/e2e/tests/segment-membership-test.pw.ts +++ b/frontend/e2e/tests/segment-membership-test.pw.ts @@ -21,7 +21,7 @@ test('Segment membership badges render in list, tab, and env select @oss', async const envs: Env[] = [] - await page.route(/\/projects\/\d+\/environments\/(\?|$)/, async (route) => { + await page.route(/\/environments\/\?project=\d+/, async (route) => { const response = await route.fetch() const body = await response.json() if (!envs.length && Array.isArray(body?.results)) { From 2dfbb7ba64058ea728fa545a080d76c8ed7a546a Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 22 May 2026 18:53:46 +0100 Subject: [PATCH 7/9] fix(e2e): Handle /environments returning a raw array, not paged results The endpoint returns `[{...}, {...}]`, not `{ results: [...] }`. The guard checked `body.results`, which was always undefined, so the env list never populated and the segments mock never injected memberships. beep boop --- frontend/e2e/tests/segment-membership-test.pw.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/frontend/e2e/tests/segment-membership-test.pw.ts b/frontend/e2e/tests/segment-membership-test.pw.ts index 3e8a8e2c5446..e3ac27505d37 100644 --- a/frontend/e2e/tests/segment-membership-test.pw.ts +++ b/frontend/e2e/tests/segment-membership-test.pw.ts @@ -24,8 +24,9 @@ test('Segment membership badges render in list, tab, and env select @oss', async await page.route(/\/environments\/\?project=\d+/, async (route) => { const response = await route.fetch() const body = await response.json() - if (!envs.length && Array.isArray(body?.results)) { - body.results.slice(0, ENV_COUNTS.length).forEach((e: Env) => { + const list = Array.isArray(body) ? body : body?.results + if (!envs.length && Array.isArray(list)) { + list.slice(0, ENV_COUNTS.length).forEach((e: Env) => { envs.push({ id: e.id, name: e.name, api_key: e.api_key }) }) } From a4f7dff6a66857f00809eb6e6e59266c4835b3c5 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 22 May 2026 19:36:08 +0100 Subject: [PATCH 8/9] test(Segment membership): Drop the synthetic-data Playwright spec The spec stubbed `memberships` into staging segment responses via `page.route`, so all the value of the test came from data the test itself injected. Dropping it removes the synthetic-data plumbing from CI and lets the real coverage land alongside the backend change that populates memberships for real. beep boop --- .../e2e/tests/segment-membership-test.pw.ts | 120 ------------------ 1 file changed, 120 deletions(-) delete mode 100644 frontend/e2e/tests/segment-membership-test.pw.ts diff --git a/frontend/e2e/tests/segment-membership-test.pw.ts b/frontend/e2e/tests/segment-membership-test.pw.ts deleted file mode 100644 index e3ac27505d37..000000000000 --- a/frontend/e2e/tests/segment-membership-test.pw.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { test, expect } from '../test-setup' -import { log, createHelpers } from '../helpers' -import { E2E_USER, PASSWORD, E2E_SEGMENT_PROJECT_1 } from '../config' - -const TEST_SEGMENT = 'segment_membership_badge' -const ENV_COUNTS = [42, 17] - -type Env = { id: number; name: string; api_key?: string } - -test('Segment membership badges render in list, tab, and env select @oss', async ({ - page, -}) => { - const { - createSegment, - deleteSegment, - gotoProject, - gotoSegments, - login, - waitForElementVisible, - } = createHelpers(page) - - const envs: Env[] = [] - - await page.route(/\/environments\/\?project=\d+/, async (route) => { - const response = await route.fetch() - const body = await response.json() - const list = Array.isArray(body) ? body : body?.results - if (!envs.length && Array.isArray(list)) { - list.slice(0, ENV_COUNTS.length).forEach((e: Env) => { - envs.push({ id: e.id, name: e.name, api_key: e.api_key }) - }) - } - await route.fulfill({ response, json: body }) - }) - - const memberships = () => - envs.slice(0, ENV_COUNTS.length).map((e, i) => ({ - environment: e.id, - count: ENV_COUNTS[i], - last_synced_at: new Date().toISOString(), - })) - - await page.route(/\/projects\/\d+\/segments\/\?/, async (route) => { - const response = await route.fetch() - const body = await response.json() - if (envs.length && Array.isArray(body?.results) && body.results.length) { - const target = - body.results.find((s: { name: string }) => s.name === TEST_SEGMENT) ?? - body.results[0] - target.memberships = memberships() - } - await route.fulfill({ response, json: body }) - }) - - await page.route(/\/projects\/\d+\/segments\/\d+\/?(?:\?|$)/, async (route) => { - const response = await route.fetch() - const body = await response.json() - if (envs.length && body && typeof body === 'object') { - body.memberships = memberships() - } - await route.fulfill({ response, json: body }) - }) - - log('Login and create segment') - await login(E2E_USER, PASSWORD) - await gotoProject(E2E_SEGMENT_PROJECT_1) - await waitForElementVisible('#features-page') - await gotoSegments() - await createSegment(TEST_SEGMENT, [ - { name: 'plan', operator: 'EQUAL', value: 'growth' }, - ]) - - log('Reload segments list with mocked memberships') - await gotoSegments() - - if (!envs.length) { - throw new Error('Expected to capture project environments via route mock') - } - - log('Assert total badge renders with sum across envs') - const total = ENV_COUNTS.reduce((a, b) => a + b, 0) - const totalBadge = page - .locator('[data-test="segment-membership-total"]') - .filter({ hasText: `${total}` }) - await expect(totalBadge.first()).toBeVisible() - - log('Open segment edit page') - await page.getByText(TEST_SEGMENT).first().click() - - log('Switch to Identities tab — total badge sits next to label') - await page.getByRole('button', { name: /Identities/ }).click() - await expect( - page - .getByRole('button', { name: /Identities/ }) - .locator('[data-test="segment-membership-total"]'), - ).toBeVisible() - - log('Open environment select and assert per-env badge') - const select = page.locator('.react-select__control').first() - await select.click() - for (const env of envs.slice(0, ENV_COUNTS.length)) { - await expect( - page.locator(`[data-test="segment-membership-${env.api_key ?? ''}"]`).or( - page.locator('.react-select__option').filter({ hasText: env.name }), - ), - ).toHaveCount(1, { timeout: 5_000 }) - } - - log('Select first env — full timestamp appears below the select') - await page - .locator('.react-select__option') - .filter({ hasText: envs[0].name }) - .click() - await expect(page.getByText(/Last synced:/)).toBeVisible() - - log('Clean up test segment') - await page.goBack() - await gotoSegments() - await deleteSegment(TEST_SEGMENT) -}) From a9f61b5e42aad43075e0befe4abb686807d271e3 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Fri, 22 May 2026 19:39:37 +0100 Subject: [PATCH 9/9] refactor(Tooltip): Make delayShow overridable, drop chip hover to 100ms 500ms is the right default for tooltips on hover-as-help-text controls, but the membership chip carries the canonical count + sync time and people will hover it deliberately. 100ms feels responsive while still filtering pass-through hovers. Default stays at 500ms for every other caller. beep boop --- frontend/web/components/Tooltip.tsx | 4 +++- frontend/web/components/segments/SegmentMembershipBadge.tsx | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/frontend/web/components/Tooltip.tsx b/frontend/web/components/Tooltip.tsx index 7057d37110ea..c0be5e78b7f5 100644 --- a/frontend/web/components/Tooltip.tsx +++ b/frontend/web/components/Tooltip.tsx @@ -14,11 +14,13 @@ export type TooltipProps = { effect?: 'float' | 'solid' afterShow?: () => void renderInPortal?: boolean + delayShow?: number } const Tooltip: FC = ({ afterShow, children, + delayShow = 500, effect, place, plainText, @@ -53,7 +55,7 @@ const Tooltip: FC = ({ place={place || 'top'} float={effect === 'float'} afterShow={afterShow} - delayShow={500} + delayShow={delayShow} style={{ wordBreak: 'break-word' }} /> diff --git a/frontend/web/components/segments/SegmentMembershipBadge.tsx b/frontend/web/components/segments/SegmentMembershipBadge.tsx index 081060baccf9..ce4423d49071 100644 --- a/frontend/web/components/segments/SegmentMembershipBadge.tsx +++ b/frontend/web/components/segments/SegmentMembershipBadge.tsx @@ -29,6 +29,7 @@ type ChipProps = { const Chip: FC = ({ count, dataTest, tooltip }) => (