From 7b2242d49351cddbfa98ba15a1dae09dc6a7e392 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 24 Jun 2026 15:19:11 +0100 Subject: [PATCH] feat: Use new segment members api --- frontend/common/services/useIdentity.ts | 33 +--- frontend/common/services/useSegmentMembers.ts | 59 +++++++ frontend/common/transformCursorPaging.ts | 27 +++ frontend/common/types/requests.ts | 6 + frontend/common/types/responses.ts | 10 ++ .../web/components/modals/CreateSegment.tsx | 14 +- .../modals/CreateSegmentUsersTabContent.tsx | 160 ++++++++++-------- .../components/modals/SegmentMembersList.tsx | 95 +++++++++++ 8 files changed, 307 insertions(+), 97 deletions(-) create mode 100644 frontend/common/services/useSegmentMembers.ts create mode 100644 frontend/common/transformCursorPaging.ts create mode 100644 frontend/web/components/modals/SegmentMembersList.tsx diff --git a/frontend/common/services/useIdentity.ts b/frontend/common/services/useIdentity.ts index c08880695222..eecdd732bcda 100644 --- a/frontend/common/services/useIdentity.ts +++ b/frontend/common/services/useIdentity.ts @@ -2,6 +2,7 @@ import { Res } from 'common/types/responses' import { Req } from 'common/types/requests' import { service } from 'common/service' import transformCorePaging from 'common/transformCorePaging' +import transformCursorPaging from 'common/transformCursorPaging' import Utils from 'common/utils/utils' const getIdentityEndpoint = (environmentId: string, isEdge: boolean) => { @@ -87,34 +88,10 @@ export const identityService = service } }, transformResponse(baseQueryReturnValue: Res['identities'], meta, req) { - const { - isEdge, - page = 1, - page_size = 10, - pageType, - pages: _pages, - } = req - if (isEdge) { - // For edge, we create our own paging - let pages = _pages ? _pages.concat([]) : [] - const next_evaluated_key = baseQueryReturnValue.last_evaluated_key - if (pageType === 'NEXT') { - pages.push(next_evaluated_key) - } else if (pageType === 'PREVIOUS') { - pages.unshift() - } else { - pages = [] - } - + if (req.isEdge) { + // For edge, identities are cursor-paginated. return { - ...baseQueryReturnValue, - next: - baseQueryReturnValue.results.length < page_size - ? undefined - : '1', - pages, - // - previous: pages.length ? '1' : undefined, + ...transformCursorPaging(req, baseQueryReturnValue), results: baseQueryReturnValue.results?.map((v) => { if (v.id) { return v @@ -123,7 +100,7 @@ export const identityService = service ...v, id: v.identity_uuid, } - }), // + }), } } return transformCorePaging(req, baseQueryReturnValue) diff --git a/frontend/common/services/useSegmentMembers.ts b/frontend/common/services/useSegmentMembers.ts new file mode 100644 index 000000000000..8e2eb646aadc --- /dev/null +++ b/frontend/common/services/useSegmentMembers.ts @@ -0,0 +1,59 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' +import Utils from 'common/utils/utils' +import transformCursorPaging from 'common/transformCursorPaging' + +export const segmentMembersService = service + .enhanceEndpoints({ addTagTypes: ['SegmentMembers'] }) + .injectEndpoints({ + endpoints: (builder) => ({ + getSegmentMembers: builder.query< + Res['segmentMembers'], + Req['getSegmentMembers'] + >({ + providesTags: (_res, _err, arg) => [ + { id: arg.id, type: 'SegmentMembers' }, + ], + query: ({ environment, id, page_size = 10, pages, projectId, q }) => { + // The cursor for the current page is the last entry on the stack the + // component has stepped through; the first page sends no cursor. + const cursor = pages?.[pages.length - 1] + return { + url: `projects/${projectId}/segments/${id}/members/?${Utils.toParam( + { cursor, environment, limit: page_size, q }, + )}`, + } + }, + transformResponse: ( + res: Res['segmentMembers'], + _meta, + req: Req['getSegmentMembers'], + ) => transformCursorPaging(req, res), + }), + // END OF ENDPOINTS + }), + }) + +export async function getSegmentMembers( + store: any, + data: Req['getSegmentMembers'], + options?: Parameters< + typeof segmentMembersService.endpoints.getSegmentMembers.initiate + >[1], +) { + return store.dispatch( + segmentMembersService.endpoints.getSegmentMembers.initiate(data, options), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useGetSegmentMembersQuery, + // END OF EXPORTS +} = segmentMembersService + +/* Usage examples: +const { data, isLoading } = useGetSegmentMembersQuery({ id: 2, projectId: 1, environment: 3 }, {}) //get hook +segmentMembersService.endpoints.getSegmentMembers.select({ id: 2, projectId: 1, environment: 3 })(store.getState()) //access data from any function +*/ diff --git a/frontend/common/transformCursorPaging.ts b/frontend/common/transformCursorPaging.ts new file mode 100644 index 000000000000..a1f212768c1f --- /dev/null +++ b/frontend/common/transformCursorPaging.ts @@ -0,0 +1,27 @@ +import { PagedResponse } from './types/responses' + +export type CursorPagedRequest = { + page_size?: number + // Stack of cursors the calling component has stepped through to reach the + // current page. The last entry is the cursor for the current page; empty or + // undefined means the first page. + pages?: (string | undefined)[] +} + +// Normalises a cursor/keyset-paginated response into the `next`/`previous` +// sentinels that understands. The cursor stack (`pages`) is owned by +// the calling component and threaded through the request; this transform only +// derives prev/next availability: +// - `next`: a full page implies there may be more rows. +// - `previous`: a non-empty cursor stack means we are past the first page. +export default function transformCursorPaging>( + req: CursorPagedRequest, + res: R, +): R { + const pageSize = req.page_size ?? 10 + return { + ...res, + next: res.results.length < pageSize ? undefined : '1', + previous: req.pages?.length ? '1' : undefined, + } +} diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index b69f2c41cf22..b4a708d0f3a6 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -263,6 +263,12 @@ export type Req = { tag: Omit } getSegment: { projectId: number; id: number } + getSegmentMembers: PagedRequest<{ + projectId: number + id: number + environment: number + pages?: (string | undefined)[] + }> updateAccount: Account deleteAccount: { current_password: string diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index 54cf39ab8e69..c226fc3943b5 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -165,6 +165,15 @@ export type SegmentMembership = { count: number last_synced_at: string } +export type SegmentMember = { + identifier: string + identity_key: string + traits: Record | null +} +export type SegmentMembersResponse = PagedResponse & { + // Pass as `cursor` to fetch the next page; null when there are no more rows. + next_cursor: string | null +} export type Segment = { id: number rules: SegmentRule[] @@ -1260,6 +1269,7 @@ export type WarehouseConnection = { export type Res = { segments: PagedResponse segment: Segment + segmentMembers: SegmentMembersResponse auditLogs: PagedResponse organisationLicence: {} organisation: Organisation diff --git a/frontend/web/components/modals/CreateSegment.tsx b/frontend/web/components/modals/CreateSegment.tsx index e0d4ad64beb4..74bcee0513af 100644 --- a/frontend/web/components/modals/CreateSegment.tsx +++ b/frontend/web/components/modals/CreateSegment.tsx @@ -75,6 +75,7 @@ type CreateSegmentType = { onComplete?: (segment: Segment) => void readOnly?: boolean segment?: Segment + membersEnabled: boolean } type CreateSegmentError = { status: number @@ -103,6 +104,7 @@ const CreateSegment: FC = ({ identities, identitiesLoading, identity, + membersEnabled, onCancel, onComplete, page, @@ -597,6 +599,7 @@ const CreateSegment: FC = ({
= ({ searchInput={searchInput} setSearchInput={setSearchInput} memberships={segment.membership_counts} + membersEnabled={membersEnabled} />
@@ -740,6 +744,13 @@ const LoadingCreateSegment: FC = (props) => { const isEdge = Utils.getIsEdge() + // When membership inspection is enabled, the Identities tab uses the + // dedicated segment members endpoint, so the legacy identities list (and its + // request) is not needed. + const membersEnabled = Utils.getFlagsmithHasFeature( + 'segment_membership_inspection', + ) + const { data: identities, isLoading: identitiesLoading } = useGetIdentitiesQuery( { @@ -752,7 +763,7 @@ const LoadingCreateSegment: FC = (props) => { q: search, }, { - skip: !environmentId, + skip: !environmentId || membersEnabled, }, ) @@ -772,6 +783,7 @@ const LoadingCreateSegment: FC = (props) => { page={page} environmentId={environmentId} setEnvironmentId={setEnvironmentId} + membersEnabled={membersEnabled} /> ) } diff --git a/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx b/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx index 898a7abb1477..b1de6395895b 100644 --- a/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx +++ b/frontend/web/components/modals/CreateSegmentUsersTabContent.tsx @@ -16,9 +16,11 @@ import { } from 'common/services/useIdentitySegment' import { getStore } from 'common/store' import { SegmentMembershipEnvBadge } from 'components/segments/SegmentMembershipBadge' +import SegmentMembersList from './SegmentMembersList' interface CreateSegmentUsersTabContentProps { projectId: string | number + segmentId?: number environmentId: string setEnvironmentId: (environmentId: string) => void identitiesLoading: boolean @@ -29,6 +31,10 @@ interface CreateSegmentUsersTabContentProps { searchInput: string setSearchInput: (input: string) => void memberships?: SegmentMembership[] + // When the segment_membership_inspection feature is enabled, the dedicated + // cursor-paginated members endpoint is used instead of listing every + // identity and checking membership per row. + membersEnabled: boolean } type UserRowType = { @@ -86,11 +92,13 @@ const CreateSegmentUsersTabContent: React.FC< environmentId, identities, identitiesLoading, + membersEnabled, memberships, name, page, projectId, searchInput, + segmentId, setEnvironmentId, setPage, setSearchInput, @@ -127,17 +135,22 @@ const CreateSegmentUsersTabContent: React.FC< ) } - const selectedMembership = React.useMemo(() => { - if (!environmentId) return null - const env = envs.find((e) => e.api_key === environmentId) - return env ? membershipByEnvId.get(env.id) ?? null : null - }, [environmentId, membershipByEnvId, envs]) + const selectedEnv = React.useMemo( + () => envs.find((e) => e.api_key === environmentId) ?? null, + [environmentId, envs], + ) + + const selectedMembership = React.useMemo( + () => (selectedEnv ? membershipByEnvId.get(selectedEnv.id) ?? null : null), + [selectedEnv, membershipByEnvId], + ) return ( <> - This is a random sample of Identities who are either in or out of this - Segment based on the current Segment rules. + {membersEnabled + ? 'These are the Identities currently matching this Segment in the selected environment, based on the current Segment rules.' + : 'This is a random sample of Identities who are either in or out of this Segment based on the current Segment rules.'}
@@ -165,67 +178,78 @@ const CreateSegmentUsersTabContent: React.FC< } /> - { - setPage({ - number: page.number + 1, - pageType: 'NEXT', - pages: identities?.last_evaluated_key - ? (page.pages || []).concat([identities?.last_evaluated_key]) - : undefined, - }) - }} - prevPage={() => { - setPage({ - number: page.number - 1, - pageType: 'PREVIOUS', - pages: page.pages - ? Utils.removeElementFromArray( - page.pages, - page.pages.length - 1, - ) - : undefined, - }) - }} - goToPage={(newPage: number) => { - setPage({ - number: newPage, - pageType: undefined, - pages: undefined, - }) - }} - onRefresh={ - environmentId - ? () => - getStore().dispatch( - identitySegmentService.util.invalidateTags([ - 'IdentitySegment', - ]), - ) - : undefined - } - renderRow={({ id, identifier }, index) => ( - - )} - filterRow={() => true} - search={searchInput} - onChange={(e) => { - setSearchInput(Utils.safeParseEventValue(e)) - }} - /> + {membersEnabled && segmentId && selectedEnv ? ( + + ) : ( + { + setPage({ + number: page.number + 1, + pageType: 'NEXT', + pages: identities?.last_evaluated_key + ? (page.pages || []).concat([ + identities?.last_evaluated_key, + ]) + : undefined, + }) + }} + prevPage={() => { + setPage({ + number: page.number - 1, + pageType: 'PREVIOUS', + pages: page.pages + ? Utils.removeElementFromArray( + page.pages, + page.pages.length - 1, + ) + : undefined, + }) + }} + goToPage={(newPage: number) => { + setPage({ + number: newPage, + pageType: undefined, + pages: undefined, + }) + }} + onRefresh={ + environmentId + ? () => + getStore().dispatch( + identitySegmentService.util.invalidateTags([ + 'IdentitySegment', + ]), + ) + : undefined + } + renderRow={({ id, identifier }, index) => ( + + )} + filterRow={() => true} + search={searchInput} + onChange={(e) => { + setSearchInput(Utils.safeParseEventValue(e)) + }} + /> + )}
diff --git a/frontend/web/components/modals/SegmentMembersList.tsx b/frontend/web/components/modals/SegmentMembersList.tsx new file mode 100644 index 000000000000..5843f170cdcf --- /dev/null +++ b/frontend/web/components/modals/SegmentMembersList.tsx @@ -0,0 +1,95 @@ +import React, { FC, useEffect, useState } from 'react' +import PanelSearch from 'components/PanelSearch' +import Utils from 'common/utils/utils' +import useDebouncedSearch from 'common/useDebouncedSearch' +import { useGetSegmentMembersQuery } from 'common/services/useSegmentMembers' +import type { SegmentMember } from 'common/types/responses' + +const PAGE_SIZE = 10 + +type SegmentMembersListProps = { + projectId: string | number + segmentId: number + environmentId: number + // Total members for the selected environment, from the segment's + // membership_counts. The list endpoint is cursor-paginated and returns no + // count, so this drives the title total rather than the pager. + count?: number +} + +// Lists the identities currently matching a segment, using the cursor-paginated +// segment members endpoint. Unlike the legacy identities list, every row here +// is a confirmed member, so no per-row membership check is needed. Cursor paging +// mirrors the edge identities list: the cursor stack lives in `pages` and the +// response carries the `next`/`previous` flags (via `transformCursorPaging`). +const SegmentMembersList: FC = ({ + count, + environmentId, + projectId, + segmentId, +}) => { + const { search, searchInput, setSearchInput } = useDebouncedSearch('') + + // Cursor stack of the pages stepped through; the last entry is the cursor for + // the current page, an empty stack is the first page. + const [pages, setPages] = useState<(string | undefined)[]>([]) + + // Reset paging whenever the environment or search term changes. + useEffect(() => { + setPages([]) + }, [environmentId, search]) + + const { data, isFetching } = useGetSegmentMembersQuery( + { + environment: environmentId, + id: segmentId, + page_size: PAGE_SIZE, + pages, + projectId: Number(projectId), + q: search || undefined, + }, + { skip: !environmentId }, + ) + + return ( + { + setPages(data?.next_cursor ? pages.concat([data.next_cursor]) : pages) + }} + prevPage={() => { + setPages(Utils.removeElementFromArray(pages, pages.length - 1)) + }} + goToPage={() => { + // Cursor pagination cannot jump to an arbitrary page. + }} + renderRow={({ identifier }: SegmentMember, index: number) => ( + + +
{identifier}
+
+
+ )} + filterRow={() => true} + search={searchInput} + onChange={(e) => { + setSearchInput(Utils.safeParseEventValue(e)) + }} + /> + ) +} + +export default SegmentMembersList