From 006d371507596ba1ad9959fb6d7f5e097d28e2ad Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Apr 2026 11:29:52 +0200 Subject: [PATCH 01/29] Changed members routes to use feature-gated rendering ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This keeps the members list and import flow on the stable /members routes, preserves the Ember fallback behind the flag, and aligns the e2e suite layout around members and members-legacy. --- .../app-sidebar/member-sidebar-views.test.tsx | 67 +++++++++ .../app-sidebar/member-sidebar-views.ts | 8 +- .../app-sidebar/nav-content.helpers.test.ts | 15 +- .../layout/app-sidebar/nav-content.helpers.ts | 9 +- .../src/layout/app-sidebar/nav-content.tsx | 8 +- apps/admin/src/members-route-gate.tsx | 13 ++ apps/admin/src/routes.tsx | 30 +++- apps/posts/src/routes.tsx | 4 - .../import-members-modal.tsx | 20 ++- .../import-members/state.ts | 6 + .../import-members/upload.ts | 4 +- .../members/components/members-actions.tsx | 51 ++++++- .../members/import-members/modal.test.tsx | 61 ++++++++ .../members/import-members/upload.test.ts | 17 ++- .../views/members/members-actions.test.tsx | 101 ++++++++++++- e2e/helpers/pages/admin/members/index.ts | 2 +- ...s-forward-page.ts => members-list-page.ts} | 4 +- .../admin/members-forward/export.test.ts | 73 --------- .../disable-commenting.test.ts | 0 e2e/tests/admin/members-legacy/export.test.ts | 137 +++++++++++++++++ .../filter-actions.test.ts | 0 .../impersonation.test.ts | 0 .../import.test.ts | 4 +- .../member-activity-events.test.ts | 0 .../members.test.ts | 0 .../stripe-webhooks.test.ts | 0 .../bulk-actions.test.ts | 16 +- e2e/tests/admin/members/export.test.ts | 142 +++++------------- .../{members-forward => members}/list.test.ts | 16 +- .../multiselect-filters.test.ts | 14 +- .../saved-views.test.ts | 4 +- .../search-and-filter.test.ts | 12 +- .../tier-filter-search.test.ts | 0 33 files changed, 582 insertions(+), 256 deletions(-) create mode 100644 apps/admin/src/layout/app-sidebar/member-sidebar-views.test.tsx create mode 100644 apps/admin/src/members-route-gate.tsx rename e2e/helpers/pages/admin/members/{members-forward-page.ts => members-list-page.ts} (98%) delete mode 100644 e2e/tests/admin/members-forward/export.test.ts rename e2e/tests/admin/{members => members-legacy}/disable-commenting.test.ts (100%) create mode 100644 e2e/tests/admin/members-legacy/export.test.ts rename e2e/tests/admin/{members => members-legacy}/filter-actions.test.ts (100%) rename e2e/tests/admin/{members => members-legacy}/impersonation.test.ts (100%) rename e2e/tests/admin/{members => members-legacy}/import.test.ts (93%) rename e2e/tests/admin/{members => members-legacy}/member-activity-events.test.ts (100%) rename e2e/tests/admin/{members => members-legacy}/members.test.ts (100%) rename e2e/tests/admin/{members => members-legacy}/stripe-webhooks.test.ts (100%) rename e2e/tests/admin/{members-forward => members}/bulk-actions.test.ts (91%) rename e2e/tests/admin/{members-forward => members}/list.test.ts (80%) rename e2e/tests/admin/{members-forward => members}/multiselect-filters.test.ts (95%) rename e2e/tests/admin/{members-forward => members}/saved-views.test.ts (96%) rename e2e/tests/admin/{members-forward => members}/search-and-filter.test.ts (92%) rename e2e/tests/admin/{members-forward => members}/tier-filter-search.test.ts (100%) diff --git a/apps/admin/src/layout/app-sidebar/member-sidebar-views.test.tsx b/apps/admin/src/layout/app-sidebar/member-sidebar-views.test.tsx new file mode 100644 index 00000000000..5582a3f67fe --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/member-sidebar-views.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import {describe, expect, it, vi} from 'vitest'; +import {renderHook} from '@testing-library/react'; +import {type SharedView} from './shared-views'; +import {useMemberSidebarViews} from './member-sidebar-views'; + +const {mockUseLocation, mockUseSharedViews} = vi.hoisted(() => ({ + mockUseLocation: vi.fn(), + mockUseSharedViews: vi.fn<(route?: string) => SharedView[]>() +})); + +vi.mock('@tryghost/admin-x-framework', () => ({ + useLocation: mockUseLocation +})); + +vi.mock('./shared-views', () => ({ + useSharedViews: mockUseSharedViews +})); + +describe('useMemberSidebarViews', () => { + it('builds saved member view URLs on the members route', () => { + mockUseLocation.mockReturnValue({ + pathname: '/members', + search: '?filter=status%3Afree' + }); + mockUseSharedViews.mockReturnValue([ + { + name: 'Free members', + route: 'members', + filter: {filter: 'status:free'} + } + ]); + + const {result} = renderHook(() => useMemberSidebarViews()); + + expect(result.current).toEqual([ + { + key: 'Free members:status:free', + name: 'Free members', + to: 'members?filter=status%3Afree', + isActive: true + } + ]); + }); + + it('does not mark saved member views active off the base members route', () => { + mockUseLocation.mockReturnValue({ + pathname: '/members/import', + search: '?filter=status%3Afree' + }); + mockUseSharedViews.mockReturnValue([ + { + name: 'Free members', + route: 'members', + filter: {filter: 'status:free'} + } + ]); + + const {result} = renderHook(() => useMemberSidebarViews()); + + expect(result.current[0]).toMatchObject({ + to: 'members?filter=status%3Afree', + isActive: false + }); + }); +}); diff --git a/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts b/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts index 9fd93071885..343d65d0753 100644 --- a/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts +++ b/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts @@ -15,7 +15,7 @@ function isMemberSidebarView(view: SharedView): view is MemberSidebarView { } function getMemberViewUrl(filter: string) { - return `members-forward?${new URLSearchParams({filter}).toString()}`; + return `members?${new URLSearchParams({filter}).toString()}`; } function isMemberViewActive(currentSearch: string, filter: string) { @@ -25,7 +25,7 @@ function isMemberViewActive(currentSearch: string, filter: string) { export function useMemberSidebarViews() { const location = useLocation(); const sharedViews = useSharedViews('members'); - const isOnMembersForward = location.pathname === '/members-forward'; + const isOnMembersListRoute = location.pathname === '/members'; return useMemo(() => { return sharedViews @@ -34,7 +34,7 @@ export function useMemberSidebarViews() { key: `${view.name}:${view.filter.filter}`, name: view.name, to: getMemberViewUrl(view.filter.filter), - isActive: isOnMembersForward && isMemberViewActive(location.search, view.filter.filter) + isActive: isOnMembersListRoute && isMemberViewActive(location.search, view.filter.filter) })); - }, [isOnMembersForward, location.search, sharedViews]); + }, [isOnMembersListRoute, location.search, sharedViews]); } diff --git a/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts b/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts index 6e6e7df76e2..70bbb3f9625 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts +++ b/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts @@ -2,9 +2,8 @@ import {describe, expect, it} from 'vitest'; import {getMembersNavActiveRoutes, isMembersNavActive} from './nav-content.helpers'; describe('getMembersNavActiveRoutes', () => { - it('always includes members-forward alongside the legacy members routes', () => { + it('returns only the members routes that are still Ember-owned', () => { expect(getMembersNavActiveRoutes()).toEqual([ - 'members-forward', 'members', 'member', 'member.new' @@ -13,10 +12,10 @@ describe('getMembersNavActiveRoutes', () => { }); describe('isMembersNavActive', () => { - it('uses the legacy route active state when members forward is disabled', () => { + it('uses the legacy route active state when the feature flag is disabled', () => { expect(isMembersNavActive({ membersForwardEnabled: false, - isOnMembersForward: false, + isOnMembersRoute: false, hasActiveMemberView: false, isMembersExpanded: false, isLegacyMembersRouteActive: true @@ -26,7 +25,7 @@ describe('isMembersNavActive', () => { it('marks the base Members link active when a saved member view is active but collapsed', () => { expect(isMembersNavActive({ membersForwardEnabled: true, - isOnMembersForward: true, + isOnMembersRoute: true, hasActiveMemberView: true, isMembersExpanded: false, isLegacyMembersRouteActive: false @@ -36,17 +35,17 @@ describe('isMembersNavActive', () => { it('marks the base Members link inactive when a saved member view is active and expanded', () => { expect(isMembersNavActive({ membersForwardEnabled: true, - isOnMembersForward: true, + isOnMembersRoute: true, hasActiveMemberView: true, isMembersExpanded: true, isLegacyMembersRouteActive: false })).toBe(false); }); - it('falls back to the base Members link when no saved member view is active', () => { + it('marks the base Members link active on React-owned members routes when no saved member view is active', () => { expect(isMembersNavActive({ membersForwardEnabled: true, - isOnMembersForward: true, + isOnMembersRoute: true, hasActiveMemberView: false, isMembersExpanded: false, isLegacyMembersRouteActive: false diff --git a/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts b/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts index ba946c9c852..75c9519e064 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts +++ b/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts @@ -1,17 +1,16 @@ export function getMembersNavActiveRoutes(): string[] { - // TODO: Remove members-forward once the membersForward flag and legacy route split are gone. - return ['members-forward', 'members', 'member', 'member.new']; + return ['members', 'member', 'member.new']; } export function isMembersNavActive({ membersForwardEnabled, - isOnMembersForward, + isOnMembersRoute, hasActiveMemberView, isMembersExpanded, isLegacyMembersRouteActive }: { membersForwardEnabled: boolean; - isOnMembersForward: boolean; + isOnMembersRoute: boolean; hasActiveMemberView: boolean; isMembersExpanded: boolean; isLegacyMembersRouteActive: boolean; @@ -20,7 +19,7 @@ export function isMembersNavActive({ return isLegacyMembersRouteActive; } - if (isOnMembersForward) { + if (isOnMembersRoute) { if (!hasActiveMemberView) { return true; } diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 5ac5d5fc56f..3297a580ce0 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -86,12 +86,12 @@ function NavContent({ ...props }: React.ComponentProps) { const isPublishedPostsRouteActive = routing.isRouteActive('posts', {type: 'published'}); const hasActivePostChild = isDraftPostsRouteActive || isScheduledPostsRouteActive || isPublishedPostsRouteActive || postCustomViews.some(view => view.isActive); const postsExpanded = savedPostsExpanded; - const isOnMembersForward = location.pathname === '/members-forward'; - const hasActiveMemberView = isOnMembersForward && memberViews.some(view => view.isActive); + const isOnMembersRoute = location.pathname === '/members' || location.pathname === '/members/import'; + const hasActiveMemberView = memberViews.some(view => view.isActive); const membersExpanded = savedMembersExpanded; const membersNavActive = isMembersNavActive({ membersForwardEnabled, - isOnMembersForward, + isOnMembersRoute, hasActiveMemberView, isMembersExpanded: membersExpanded, isLegacyMembersRouteActive: routing.isRouteActive(getMembersNavActiveRoutes()) @@ -99,7 +99,7 @@ function NavContent({ ...props }: React.ComponentProps) { const postsRoute = routing.getRouteUrl('posts'); const isPostsRouteActive = routing.isRouteActive('posts'); const postsNavActive = isPostsRouteActive || (!postsExpanded && hasActivePostChild); - const membersRoute = membersForwardEnabled ? 'members-forward' : routing.getRouteUrl('members'); + const membersRoute = routing.getRouteUrl('members'); return ( diff --git a/apps/admin/src/members-route-gate.tsx b/apps/admin/src/members-route-gate.tsx new file mode 100644 index 00000000000..ee1ecd56a1b --- /dev/null +++ b/apps/admin/src/members-route-gate.tsx @@ -0,0 +1,13 @@ +import {Outlet} from "@tryghost/admin-x-framework"; +import {EmberFallback} from "./ember-bridge"; +import {useFeatureFlag} from "./hooks/use-feature-flag"; + +export function MembersRouteGate() { + const membersForwardEnabled = useFeatureFlag("membersForward"); + + if (!membersForwardEnabled) { + return ; + } + + return ; +} diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 6c0f7a3a9c2..f35bf26f9d0 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -15,6 +15,7 @@ import MyProfileRedirect from "./my-profile-redirect"; // Ember import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge"; import type { RouteHandle } from "./ember-bridge"; +import { MembersRouteGate } from "./members-route-gate"; import { NotFound } from "./not-found"; @@ -40,7 +41,8 @@ const EMBER_ROUTES: string[] = [ "/tags/new", "/explore/*", "/migrate/*", - "/members/*", + "/members/new", + "/members/:member_id", "/members-activity", "/designsandbox", "/mentions", @@ -53,6 +55,30 @@ const emberFallbackRoutes: RouteObject[] = EMBER_ROUTES.map(path => ({ Component: EmberFallback, handle: emberFallbackHandle, })); + +const membersRoute: RouteObject = { + path: "/members", + element: , + handle: emberFallbackHandle, + children: [ + { + index: true, + lazy: lazyComponent(() => import("@tryghost/posts/src/views/members/members")) + }, + { + path: "import", + lazy: lazyComponent(() => import("@tryghost/posts/src/views/members/members")) + } + ] +}; + +const membersForwardRedirectRoute: RouteObject = { + path: "/members-forward", + // TODO: Remove once the legacy Ember members list is deleted. + handle: emberFallbackHandle, + loader: () => redirect("/members") +}; + export const routes: RouteObject[] = [ { // ForceUpgradeGuard wraps all routes to redirect to /pro when in force upgrade mode. @@ -69,6 +95,8 @@ export const routes: RouteObject[] = [ Component: EmberFallback, handle: emberFallbackHandle, }, + membersRoute, + membersForwardRedirectRoute, { element: ( diff --git a/apps/posts/src/routes.tsx b/apps/posts/src/routes.tsx index 6c94c96e25e..3c75882fef0 100644 --- a/apps/posts/src/routes.tsx +++ b/apps/posts/src/routes.tsx @@ -69,10 +69,6 @@ export const routes: RouteObject[] = [ path: 'comments', lazy: lazyComponent(() => import('@views/comments/comments')) }, - { - path: 'members-forward', - lazy: lazyComponent(() => import('@views/members/members')) - }, // Error handling { diff --git a/apps/posts/src/views/members/components/bulk-action-modals/import-members-modal.tsx b/apps/posts/src/views/members/components/bulk-action-modals/import-members-modal.tsx index 2d425d7c667..05b0d11db7c 100644 --- a/apps/posts/src/views/members/components/bulk-action-modals/import-members-modal.tsx +++ b/apps/posts/src/views/members/components/bulk-action-modals/import-members-modal.tsx @@ -1,8 +1,8 @@ import {CompleteStep, ErrorStep, InitStep, MappingStep, ProcessingStep} from './import-members/components'; -import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@tryghost/shade/components'; +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, cn} from '@tryghost/shade'; +import {type ImportResponse} from './import-members/state'; import {MembersFieldMapping, detectFieldTypes} from './import-members/mapping'; import {buildImportResponse} from './import-members/upload'; -import {cn} from '@tryghost/shade/utils'; import {createInitialImportState, importReducer} from './import-members/reducer'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {parseCSV} from './import-members/csv'; @@ -12,13 +12,15 @@ import {useLabelPicker} from '@src/hooks/use-label-picker'; interface ImportMembersModalProps { open: boolean; onOpenChange: (open: boolean) => void; - onComplete?: () => void; + onComplete?: (importResponse?: ImportResponse) => void; + onClose?: (importResponse?: ImportResponse) => void; } export function ImportMembersModal({ open, onOpenChange, - onComplete + onComplete, + onClose }: ImportMembersModalProps) { const [state, dispatch] = useReducer(importReducer, undefined, createInitialImportState); const errorCsvUrlRef = useRef(null); @@ -29,9 +31,11 @@ export function ImportMembersModal({ }); const revokeErrorCsvUrl = useCallback(() => { - if (errorCsvUrlRef.current) { + if (errorCsvUrlRef.current && typeof URL.revokeObjectURL === 'function') { URL.revokeObjectURL(errorCsvUrlRef.current); errorCsvUrlRef.current = null; + } else if (errorCsvUrlRef.current) { + errorCsvUrlRef.current = null; } }, []); @@ -51,10 +55,12 @@ export function ImportMembersModal({ return; } if (!isOpen) { + const importResponse = state.importResponse ?? undefined; reset(); + onClose?.(importResponse); } onOpenChange(isOpen); - }, [onOpenChange, reset, state.status]); + }, [onClose, onOpenChange, reset, state.importResponse, state.status]); useEffect(() => { if (!state.file) { @@ -258,7 +264,7 @@ export function ImportMembersModal({ type: 'UPLOAD_COMPLETE', importResponse }); - onComplete?.(); + onComplete?.(importResponse); } catch { dispatch({ type: 'UPLOAD_ERROR', diff --git a/apps/posts/src/views/members/components/bulk-action-modals/import-members/state.ts b/apps/posts/src/views/members/components/bulk-action-modals/import-members/state.ts index 96423577495..b8b6afc3a9e 100644 --- a/apps/posts/src/views/members/components/bulk-action-modals/import-members/state.ts +++ b/apps/posts/src/views/members/components/bulk-action-modals/import-members/state.ts @@ -2,12 +2,18 @@ import {MembersFieldMapping} from './mapping'; export type ImportStatus = 'INIT' | 'MAPPING' | 'UPLOADING' | 'PROCESSING' | 'COMPLETE' | 'ERROR'; +export interface ImportLabel { + name: string; + slug: string; +} + export interface ImportResponse { importedCount: number; errorCount: number; errorCsvUrl: string; errorCsvName: string; errorList: Array<{message: string; count: number}>; + importLabel?: ImportLabel; } export interface ImportState { diff --git a/apps/posts/src/views/members/components/bulk-action-modals/import-members/upload.ts b/apps/posts/src/views/members/components/bulk-action-modals/import-members/upload.ts index 14da8ed223c..c85e1ca1f32 100644 --- a/apps/posts/src/views/members/components/bulk-action-modals/import-members/upload.ts +++ b/apps/posts/src/views/members/components/bulk-action-modals/import-members/upload.ts @@ -13,6 +13,7 @@ type UploadApiResponse = { }; import_label?: { name: string; + slug: string; }; }; }; @@ -49,6 +50,7 @@ export function buildImportResponse(importData: UploadApiResponse): ImportRespon errorCount, errorCsvUrl, errorCsvName, - errorList: Object.values(errorListMap) + errorList: Object.values(errorListMap), + importLabel: importData.meta.import_label }; } diff --git a/apps/posts/src/views/members/components/members-actions.tsx b/apps/posts/src/views/members/components/members-actions.tsx index c255535c60e..7c13f105bd0 100644 --- a/apps/posts/src/views/members/components/members-actions.tsx +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -1,12 +1,22 @@ import React, {useCallback, useState} from 'react'; import {AddLabelModal, DeleteModal, ImportMembersModal, RemoveLabelModal, UnsubscribeModal} from './bulk-action-modals'; -import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger} from '@tryghost/shade/components'; -import {LucideIcon} from '@tryghost/shade/utils'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + LucideIcon +} from '@tryghost/shade'; +import {type ImportResponse} from './bulk-action-modals/import-members/state'; import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; import {buildMemberOperationParams} from '../member-query-params'; +import {buildMembersUrl} from '../member-route'; import {toast} from 'sonner'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useBulkDeleteMembers, useBulkEditMembers} from '@tryghost/admin-x-framework/api/members'; +import {useLocation, useNavigate} from '@tryghost/admin-x-framework'; interface MembersActionsProps { hasFilterOrSearch: boolean; @@ -14,7 +24,7 @@ interface MembersActionsProps { nql?: string; search: string; canBulkDelete: boolean; - onImportComplete?: () => void; + onImportComplete?: (importResponse?: ImportResponse) => void; } async function exportMembers(filter?: string, search?: string): Promise { @@ -37,7 +47,10 @@ const MembersActions: React.FC = ({ canBulkDelete, onImportComplete }) => { - const [showImportModal, setShowImportModal] = useState(false); + const location = useLocation(); + const navigate = useNavigate(); + const isImportRoute = location.pathname === '/members/import'; + const currentSearch = location.search ?? ''; const [showAddLabelModal, setShowAddLabelModal] = useState(false); const [showRemoveLabelModal, setShowRemoveLabelModal] = useState(false); const [showUnsubscribeModal, setShowUnsubscribeModal] = useState(false); @@ -179,6 +192,27 @@ const MembersActions: React.FC = ({ } }, [nql, search]); + const handleImportModalOpenChange = useCallback(() => {}, []); + + const handleImportAction = useCallback(() => { + navigate(`/members/import${currentSearch}`); + }, [currentSearch, navigate]); + + const handleImportComplete = useCallback((importResponse?: ImportResponse) => { + onImportComplete?.(importResponse); + }, [onImportComplete]); + + const handleImportClose = useCallback((importResponse?: ImportResponse) => { + if (importResponse?.importLabel) { + navigate(buildMembersUrl({ + filter: `label:[${importResponse.importLabel.slug}]` + })); + return; + } + + navigate(`/members${currentSearch}`); + }, [currentSearch, navigate]); + return ( <> {/* Actions Dropdown */} @@ -190,7 +224,7 @@ const MembersActions: React.FC = ({ {/* Import */} - setShowImportModal(true)}> + Import members @@ -245,9 +279,10 @@ const MembersActions: React.FC = ({ {/* Modals */} { beforeEach(() => { vi.stubGlobal('FileReader', MockFileReader); vi.stubGlobal('fetch', vi.fn(async () => new Response(null, {status: 202}))); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + writable: true, + value: vi.fn(() => 'blob:mock/0') + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + writable: true, + value: vi.fn() + }); }); afterEach(() => { @@ -97,4 +107,55 @@ describe('ImportMembersModal', () => { expect(screen.getByRole('heading', {name: /import in progress/i})).toBeInTheDocument(); }); + + it('passes import label metadata to onComplete for completed uploads', async () => { + const onComplete = vi.fn(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + meta: { + stats: {imported: 1, invalid: []}, + import_label: { + name: 'Import April', + slug: 'import-april' + } + } + }), { + status: 200, + headers: {'Content-Type': 'application/json'} + }))); + + render( + {}} + /> + ); + + const dropTarget = screen.getByRole('button', {name: /select or drop a csv file/i}); + const csvFile = createFile('members.csv', 'text/csv', 'email,name\nmember@example.com,Member'); + + fireEvent.drop(dropTarget, { + dataTransfer: { + files: [csvFile], + items: [{ + kind: 'file', + type: csvFile.type, + getAsFile: () => csvFile + }], + types: ['Files'] + } + }); + + const importButton = await screen.findByRole('button', {name: /import 1 member/i}); + fireEvent.click(importButton); + + await waitFor(() => { + expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ + importLabel: { + name: 'Import April', + slug: 'import-april' + } + })); + }); + }); }); diff --git a/apps/posts/test/unit/views/members/import-members/upload.test.ts b/apps/posts/test/unit/views/members/import-members/upload.test.ts index 5bfc1e825dd..a7fb83ff0b8 100644 --- a/apps/posts/test/unit/views/members/import-members/upload.test.ts +++ b/apps/posts/test/unit/views/members/import-members/upload.test.ts @@ -19,13 +19,20 @@ describe('buildImportResponse', () => { const result = buildImportResponse({ meta: { stats: {imported: 5, invalid: []}, - import_label: {name: 'Import 2026-03-17'} + import_label: { + name: 'Import 2026-03-17', + slug: 'import-2026-03-17' + } } }); expect(result.importedCount).toBe(5); expect(result.errorCount).toBe(0); expect(result.errorList).toEqual([]); + expect(result.importLabel).toEqual({ + name: 'Import 2026-03-17', + slug: 'import-2026-03-17' + }); expect(result.errorCsvName).toBe('Import 2026-03-17 - Errors.csv'); expect(result.errorCsvUrl).toMatch(/^blob:/); }); @@ -41,7 +48,7 @@ describe('buildImportResponse', () => { {email: 'c@test.com', error: 'Validation (isEmail) failed for email'} ] }, - import_label: {name: 'Test Import'} + import_label: {name: 'Test Import', slug: 'test-import'} } }); @@ -66,7 +73,7 @@ describe('buildImportResponse', () => { {email: 'w', error: 'No such customer:cus_abc123'} ] }, - import_label: {name: 'Errors'} + import_label: {name: 'Errors', slug: 'errors'} } }); @@ -87,7 +94,7 @@ describe('buildImportResponse', () => { {email: 'a@test.com', error: 'Value in [members.email] cannot be blank.,Validation (isEmail) failed for email'} ] }, - import_label: {name: 'Test'} + import_label: {name: 'Test', slug: 'test'} } }); @@ -127,7 +134,7 @@ describe('buildImportResponse', () => { imported: 0, invalid: [{email: 'bad', error: 'Validation (isEmail) failed for email'}] }, - import_label: {name: 'Test'} + import_label: {name: 'Test', slug: 'test'} } }); diff --git a/apps/posts/test/unit/views/members/members-actions.test.tsx b/apps/posts/test/unit/views/members/members-actions.test.tsx index 6e6e3564e75..868d8792978 100644 --- a/apps/posts/test/unit/views/members/members-actions.test.tsx +++ b/apps/posts/test/unit/views/members/members-actions.test.tsx @@ -4,6 +4,15 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; import {render} from '@testing-library/react'; const importModalPropsRef: {current: Record | null} = {current: null}; +const {mockUseLocation, mockUseNavigate} = vi.hoisted(() => ({ + mockUseLocation: vi.fn(), + mockUseNavigate: vi.fn() +})); + +vi.mock('@tryghost/admin-x-framework', () => ({ + useLocation: mockUseLocation, + useNavigate: mockUseNavigate +})); vi.mock('@src/views/members/components/bulk-action-modals', () => ({ ImportMembersModal: (props: Record) => { @@ -37,6 +46,10 @@ vi.mock('@tryghost/admin-x-framework/api/members', () => ({ describe('MembersActions', () => { beforeEach(() => { importModalPropsRef.current = null; + mockUseLocation.mockReturnValue({ + pathname: '/members' + }); + mockUseNavigate.mockReturnValue(vi.fn()); }); it('passes onImportComplete to ImportMembersModal onComplete prop', () => { @@ -53,6 +66,92 @@ describe('MembersActions', () => { ); expect(importModalPropsRef.current).not.toBeNull(); - expect(importModalPropsRef.current?.onComplete).toBe(onImportComplete); + const handleImportComplete = importModalPropsRef.current?.onComplete as ((result?: {importLabel?: {name: string; slug: string}}) => void) | undefined; + + expect(handleImportComplete).toBeTypeOf('function'); + + handleImportComplete?.({ + importLabel: { + name: 'Import April', + slug: 'import-april' + } + }); + + expect(onImportComplete).toHaveBeenCalledWith({ + importLabel: { + name: 'Import April', + slug: 'import-april' + } + }); + }); + + it('opens the import modal when rendered on the import route', () => { + mockUseLocation.mockReturnValue({ + pathname: '/members/import' + }); + + render( + + ); + + expect(importModalPropsRef.current).not.toBeNull(); + expect(importModalPropsRef.current?.open).toBe(true); + }); + + it('navigates back to members when the import route modal closes', () => { + const navigate = vi.fn(); + mockUseLocation.mockReturnValue({ + pathname: '/members/import' + }); + mockUseNavigate.mockReturnValue(navigate); + + render( + + ); + + expect(importModalPropsRef.current).not.toBeNull(); + + const handleImportClose = importModalPropsRef.current?.onClose as ((importResponse?: {importLabel?: unknown}) => void) | undefined; + + expect(handleImportClose).toBeTypeOf('function'); + + handleImportClose?.(); + + expect(navigate).toHaveBeenCalledWith('/members'); + }); + + it('navigates to the imported label filter when the import route modal closes after a labeled import', () => { + const navigate = vi.fn(); + mockUseLocation.mockReturnValue({ + pathname: '/members/import' + }); + mockUseNavigate.mockReturnValue(navigate); + + render( + + ); + expect(importModalPropsRef.current).not.toBeNull(); + const handleImportClose = importModalPropsRef.current?.onClose as ((importResponse?: {importLabel?: {slug: string}}) => void) | undefined; + expect(handleImportClose).toBeTypeOf('function'); + handleImportClose?.({ + importLabel: {slug: 'import-2026-03-17'} + }); + expect(navigate).toHaveBeenCalledWith('/members?filter=label%3A%5Bimport-2026-03-17%5D'); }); }); diff --git a/e2e/helpers/pages/admin/members/index.ts b/e2e/helpers/pages/admin/members/index.ts index 2f7b699808f..13aeea4ba27 100644 --- a/e2e/helpers/pages/admin/members/index.ts +++ b/e2e/helpers/pages/admin/members/index.ts @@ -1,4 +1,4 @@ export * from './members-page'; -export * from './members-forward-page'; +export * from './members-list-page'; export * from './member-details-page'; export * from './members-import-modal'; diff --git a/e2e/helpers/pages/admin/members/members-forward-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts similarity index 98% rename from e2e/helpers/pages/admin/members/members-forward-page.ts rename to e2e/helpers/pages/admin/members/members-list-page.ts index c6347fab225..18ad03accbf 100644 --- a/e2e/helpers/pages/admin/members/members-forward-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -7,7 +7,7 @@ interface ExportedFile { content: string; } -export class MembersForwardPage extends AdminPage { +export class MembersListPage extends AdminPage { readonly membersList: Locator; readonly memberRows: Locator; readonly searchInput: Locator; @@ -21,7 +21,7 @@ export class MembersForwardPage extends AdminPage { constructor(page: Page) { super(page); - this.pageUrl = '/ghost/#/members-forward'; + this.pageUrl = '/ghost/#/members'; this.membersList = page.getByTestId('members-list'); this.memberRows = page.getByTestId('members-list-item'); diff --git a/e2e/tests/admin/members-forward/export.test.ts b/e2e/tests/admin/members-forward/export.test.ts deleted file mode 100644 index 0c67f4b2463..00000000000 --- a/e2e/tests/admin/members-forward/export.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import {MemberFactory, createMemberFactory} from '@/data-factory'; -import {MembersForwardPage} from '@/admin-pages'; -import {expect, test} from '@/helpers/playwright'; -import {usePerTestIsolation} from '@/helpers/playwright/isolation'; - -usePerTestIsolation(); - -const EXPECTED_CSV_HEADER_FIELDS = [ - 'id,', - 'email,', - 'name,', - 'note,', - 'subscribed_to_emails,', - 'complimentary_plan,', - 'stripe_customer_id,', - 'created_at,', - 'deleted_at,', - 'labels,', - 'tiers' -]; - -test.describe('Ghost Admin - Members Forward Export', () => { - test.use({labs: {membersForward: true}}); - - let memberFactory: MemberFactory; - - const membersFixture = [ - {name: 'Export Member 1', email: 'export1@example.com', note: 'First export test', labels: ['alpha']}, - {name: 'Export Member 2', email: 'export2@example.com', note: 'Second export test', labels: ['alpha']}, - {name: 'Export Member 3', email: 'export3@example.com', note: 'Third export test', labels: ['beta']} - ]; - - test.beforeEach(async ({page}) => { - memberFactory = createMemberFactory(page.request); - }); - - test('exports all members to a CSV with expected fields', async ({page}) => { - await memberFactory.createMany(membersFixture); - - const membersPage = new MembersForwardPage(page); - await membersPage.goto(); - await membersPage.openActionsMenu(); - - const {suggestedFilename, content} = await membersPage.exportMembers(); - - expect(content).toMatch(new RegExp(EXPECTED_CSV_HEADER_FIELDS.join(''))); - - for (const member of membersFixture) { - expect(content).toContain(member.name); - expect(content).toContain(member.email); - expect(content).toContain(member.note); - } - - expect(suggestedFilename).toMatch(/^members\.\d{4}-\d{2}-\d{2}\.csv$/); - }); - - test('exports only filtered members when a filter is active', async ({page}) => { - await memberFactory.createMany(membersFixture); - - const membersPage = new MembersForwardPage(page); - await page.goto('/ghost/#/members-forward?filter=label:alpha'); - await expect(membersPage.memberRows).toHaveCount(2); - - await membersPage.openActionsMenu(); - await expect(membersPage.getMenuItem(/Export 2 members/)).toBeVisible(); - - const {content} = await membersPage.exportMembers(); - - expect(content).toContain('export1@example.com'); - expect(content).toContain('export2@example.com'); - expect(content).not.toContain('export3@example.com'); - }); -}); diff --git a/e2e/tests/admin/members/disable-commenting.test.ts b/e2e/tests/admin/members-legacy/disable-commenting.test.ts similarity index 100% rename from e2e/tests/admin/members/disable-commenting.test.ts rename to e2e/tests/admin/members-legacy/disable-commenting.test.ts diff --git a/e2e/tests/admin/members-legacy/export.test.ts b/e2e/tests/admin/members-legacy/export.test.ts new file mode 100644 index 00000000000..ed33b0518da --- /dev/null +++ b/e2e/tests/admin/members-legacy/export.test.ts @@ -0,0 +1,137 @@ +import {expect, test} from '@/helpers/playwright'; +import {usePerTestIsolation} from '@/helpers/playwright/isolation'; + +import {MemberFactory, createMemberFactory} from '@/data-factory'; +import {MembersPage} from '@/helpers/pages'; + +usePerTestIsolation(); + +test.describe('Ghost Admin - Member Export', () => { + let memberFactory: MemberFactory; + + function extractDownloadedContentSpecifics(content: string) { + const contentIds = content.match(/[a-z0-9]{24}/gm); + const contentTimestamps = content.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/gm); + + return { + contentIds, + contentTimestamps + }; + } + + const downloadedContentFields = [ + 'id,', + 'email,', + 'name,', + 'note,', + 'subscribed_to_emails,', + 'complimentary_plan,', + 'stripe_customer_id,', + 'created_at,', + 'deleted_at,', + 'labels,', + 'tiers' + ]; + + const membersFixture = [ + { + name: 'Test Member 1', + email: 'test@member1.com', + note: 'This is a test member', + labels: ['old'] + }, + { + name: 'Test Member 2', + email: 'test@member2.com', + note: 'This is a test member', + labels: ['old'] + }, + { + name: 'Test Member 3', + email: 'test@member3.com', + note: 'This is a test member', + labels: ['old'] + }, + { + name: 'Sashi', + email: 'test@member4.com', + note: 'This is a test member', + labels: ['dog'] + }, + { + name: 'Mia', + email: 'test@member5.com', + note: 'This is a test member', + labels: ['dog'] + }, + { + name: 'Minki', + email: 'test@member6.com', + note: 'This is a test member', + labels: ['dog'] + } + ]; + + test.beforeEach(async ({page}) => { + memberFactory = createMemberFactory(page.request); + }); + + test('exports all members to CSV', async ({page}) => { + await memberFactory.createMany(membersFixture); + + const membersPage = new MembersPage(page); + await membersPage.goto(); + await membersPage.membersActionsButton.click(); + const {suggestedFilename, content} = await membersPage.exportMembers(); + const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); + + expect(content).toMatch(new RegExp(downloadedContentFields.join(''))); + + membersFixture.forEach((member) => { + expect(content).toContain(member.name); + expect(content).toContain(member.email); + expect(content).toContain(member.note); + expect(content).toContain(member.labels[0]); + }); + + expect(contentIds).toHaveLength(6); + expect(contentTimestamps).toHaveLength(6); + + expect(suggestedFilename.startsWith('members')).toBe(true); + expect(suggestedFilename.endsWith('.csv')).toBe(true); + }); + + test('exports filtered members by label to CSV', async ({page}) => { + await memberFactory.createMany(membersFixture); + const labelToFilterBy = 'dog'; + + const membersPage = new MembersPage(page); + await membersPage.goto(); + await membersPage.filterSection.applyLabel(labelToFilterBy); + await expect(membersPage.memberListItems).toHaveCount(3); + + await membersPage.membersActionsButton.click(); + await expect(membersPage.exportMembersButton).toContainText('Export selected members'); + + const {suggestedFilename, content} = await membersPage.exportMembers(); + const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); + + const fixture = membersFixture + .filter(member => member.labels[0] === 'dog'); + + expect(content).toMatch(new RegExp(downloadedContentFields.join(''))); + + fixture.forEach((member) => { + expect(content).toContain(member.name); + expect(content).toContain(member.email); + expect(content).toContain(member.note); + expect(content).toContain(labelToFilterBy); + }); + + expect(contentIds).toHaveLength(3); + expect(contentTimestamps).toHaveLength(3); + + expect(suggestedFilename.startsWith('members')).toBe(true); + expect(suggestedFilename.endsWith('.csv')).toBe(true); + }); +}); diff --git a/e2e/tests/admin/members/filter-actions.test.ts b/e2e/tests/admin/members-legacy/filter-actions.test.ts similarity index 100% rename from e2e/tests/admin/members/filter-actions.test.ts rename to e2e/tests/admin/members-legacy/filter-actions.test.ts diff --git a/e2e/tests/admin/members/impersonation.test.ts b/e2e/tests/admin/members-legacy/impersonation.test.ts similarity index 100% rename from e2e/tests/admin/members/impersonation.test.ts rename to e2e/tests/admin/members-legacy/impersonation.test.ts diff --git a/e2e/tests/admin/members/import.test.ts b/e2e/tests/admin/members-legacy/import.test.ts similarity index 93% rename from e2e/tests/admin/members/import.test.ts rename to e2e/tests/admin/members-legacy/import.test.ts index 3d56569e21d..4fd81abd429 100644 --- a/e2e/tests/admin/members/import.test.ts +++ b/e2e/tests/admin/members-legacy/import.test.ts @@ -7,7 +7,7 @@ import {expect, test} from '@/helpers/playwright'; test.describe('Ghost Admin - Members Import', () => { test('imports members from CSV via the UI', async ({page}) => { - const membersPage = new MembersPage(page, {route: 'members-forward'}); + const membersPage = new MembersPage(page, {route: 'members/import'}); const importModal = new MembersImportModal(page); const timestamp = Date.now(); @@ -27,8 +27,6 @@ test.describe('Ghost Admin - Members Import', () => { writeFileSync(csvPath, csvContent); await membersPage.goto(); - await membersPage.membersActionsButton.click(); - await page.getByRole('menuitem', {name: 'Import members'}).click(); await importModal.fileInput.setInputFiles(csvPath); diff --git a/e2e/tests/admin/members/member-activity-events.test.ts b/e2e/tests/admin/members-legacy/member-activity-events.test.ts similarity index 100% rename from e2e/tests/admin/members/member-activity-events.test.ts rename to e2e/tests/admin/members-legacy/member-activity-events.test.ts diff --git a/e2e/tests/admin/members/members.test.ts b/e2e/tests/admin/members-legacy/members.test.ts similarity index 100% rename from e2e/tests/admin/members/members.test.ts rename to e2e/tests/admin/members-legacy/members.test.ts diff --git a/e2e/tests/admin/members/stripe-webhooks.test.ts b/e2e/tests/admin/members-legacy/stripe-webhooks.test.ts similarity index 100% rename from e2e/tests/admin/members/stripe-webhooks.test.ts rename to e2e/tests/admin/members-legacy/stripe-webhooks.test.ts diff --git a/e2e/tests/admin/members-forward/bulk-actions.test.ts b/e2e/tests/admin/members/bulk-actions.test.ts similarity index 91% rename from e2e/tests/admin/members-forward/bulk-actions.test.ts rename to e2e/tests/admin/members/bulk-actions.test.ts index 07d5df4afac..043ba0d6047 100644 --- a/e2e/tests/admin/members-forward/bulk-actions.test.ts +++ b/e2e/tests/admin/members/bulk-actions.test.ts @@ -1,11 +1,11 @@ import {MemberFactory, createMemberFactory} from '@/data-factory'; -import {MembersForwardPage} from '@/admin-pages'; +import {MembersListPage} from '@/admin-pages'; import {expect, test} from '@/helpers/playwright'; import {usePerTestIsolation} from '@/helpers/playwright/isolation'; usePerTestIsolation(); -test.describe('Ghost Admin - Members Forward Bulk Actions', () => { +test.describe('Ghost Admin - Members Bulk Actions', () => { test.use({labs: {membersForward: true}}); let memberFactory: MemberFactory; @@ -20,7 +20,7 @@ test.describe('Ghost Admin - Members Forward Bulk Actions', () => { {name: 'Bulk Label 2', email: 'bulk2@example.com', labels: ['existing']} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(2); @@ -48,8 +48,10 @@ test.describe('Ghost Admin - Members Forward Bulk Actions', () => { {name: 'No Label', email: 'nolabel@example.com'} ]); - const membersPage = new MembersForwardPage(page); - await page.goto('/ghost/#/members-forward?filter=label:removable'); + const membersPage = new MembersListPage(page); + await membersPage.goto(); + + await membersPage.addFilter('Label', 'removable'); await expect(membersPage.memberRows).toHaveCount(2); await membersPage.openActionsMenu(); @@ -73,7 +75,7 @@ test.describe('Ghost Admin - Members Forward Bulk Actions', () => { {name: 'Sub Member 2', email: 'sub2@example.com'} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(2); @@ -94,7 +96,7 @@ test.describe('Ghost Admin - Members Forward Bulk Actions', () => { {name: 'Delete Me 2', email: 'delete2@example.com'} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(2); diff --git a/e2e/tests/admin/members/export.test.ts b/e2e/tests/admin/members/export.test.ts index ed33b0518da..50e4f3f0a8d 100644 --- a/e2e/tests/admin/members/export.test.ts +++ b/e2e/tests/admin/members/export.test.ts @@ -1,137 +1,75 @@ +import {MemberFactory, createMemberFactory} from '@/data-factory'; +import {MembersListPage} from '@/admin-pages'; import {expect, test} from '@/helpers/playwright'; import {usePerTestIsolation} from '@/helpers/playwright/isolation'; -import {MemberFactory, createMemberFactory} from '@/data-factory'; -import {MembersPage} from '@/helpers/pages'; - usePerTestIsolation(); -test.describe('Ghost Admin - Member Export', () => { - let memberFactory: MemberFactory; +const EXPECTED_CSV_HEADER_FIELDS = [ + 'id,', + 'email,', + 'name,', + 'note,', + 'subscribed_to_emails,', + 'complimentary_plan,', + 'stripe_customer_id,', + 'created_at,', + 'deleted_at,', + 'labels,', + 'tiers' +]; + +test.describe('Ghost Admin - Members Export', () => { + test.use({labs: {membersForward: true}}); - function extractDownloadedContentSpecifics(content: string) { - const contentIds = content.match(/[a-z0-9]{24}/gm); - const contentTimestamps = content.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/gm); - - return { - contentIds, - contentTimestamps - }; - } - - const downloadedContentFields = [ - 'id,', - 'email,', - 'name,', - 'note,', - 'subscribed_to_emails,', - 'complimentary_plan,', - 'stripe_customer_id,', - 'created_at,', - 'deleted_at,', - 'labels,', - 'tiers' - ]; + let memberFactory: MemberFactory; const membersFixture = [ - { - name: 'Test Member 1', - email: 'test@member1.com', - note: 'This is a test member', - labels: ['old'] - }, - { - name: 'Test Member 2', - email: 'test@member2.com', - note: 'This is a test member', - labels: ['old'] - }, - { - name: 'Test Member 3', - email: 'test@member3.com', - note: 'This is a test member', - labels: ['old'] - }, - { - name: 'Sashi', - email: 'test@member4.com', - note: 'This is a test member', - labels: ['dog'] - }, - { - name: 'Mia', - email: 'test@member5.com', - note: 'This is a test member', - labels: ['dog'] - }, - { - name: 'Minki', - email: 'test@member6.com', - note: 'This is a test member', - labels: ['dog'] - } + {name: 'Export Member 1', email: 'export1@example.com', note: 'First export test', labels: ['alpha']}, + {name: 'Export Member 2', email: 'export2@example.com', note: 'Second export test', labels: ['alpha']}, + {name: 'Export Member 3', email: 'export3@example.com', note: 'Third export test', labels: ['beta']} ]; test.beforeEach(async ({page}) => { memberFactory = createMemberFactory(page.request); }); - test('exports all members to CSV', async ({page}) => { + test('exports all members to a CSV with expected fields', async ({page}) => { await memberFactory.createMany(membersFixture); - const membersPage = new MembersPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); - await membersPage.membersActionsButton.click(); + await membersPage.openActionsMenu(); + const {suggestedFilename, content} = await membersPage.exportMembers(); - const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); - expect(content).toMatch(new RegExp(downloadedContentFields.join(''))); + expect(content).toMatch(new RegExp(EXPECTED_CSV_HEADER_FIELDS.join(''))); - membersFixture.forEach((member) => { + for (const member of membersFixture) { expect(content).toContain(member.name); expect(content).toContain(member.email); expect(content).toContain(member.note); - expect(content).toContain(member.labels[0]); - }); - - expect(contentIds).toHaveLength(6); - expect(contentTimestamps).toHaveLength(6); + } - expect(suggestedFilename.startsWith('members')).toBe(true); - expect(suggestedFilename.endsWith('.csv')).toBe(true); + expect(suggestedFilename).toMatch(/^members\.\d{4}-\d{2}-\d{2}\.csv$/); }); - test('exports filtered members by label to CSV', async ({page}) => { + test('exports only filtered members when a filter is active', async ({page}) => { await memberFactory.createMany(membersFixture); - const labelToFilterBy = 'dog'; - const membersPage = new MembersPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); - await membersPage.filterSection.applyLabel(labelToFilterBy); - await expect(membersPage.memberListItems).toHaveCount(3); - - await membersPage.membersActionsButton.click(); - await expect(membersPage.exportMembersButton).toContainText('Export selected members'); - - const {suggestedFilename, content} = await membersPage.exportMembers(); - const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); - const fixture = membersFixture - .filter(member => member.labels[0] === 'dog'); + await membersPage.addFilter('Label', 'alpha'); + await expect(membersPage.memberRows).toHaveCount(2); - expect(content).toMatch(new RegExp(downloadedContentFields.join(''))); - - fixture.forEach((member) => { - expect(content).toContain(member.name); - expect(content).toContain(member.email); - expect(content).toContain(member.note); - expect(content).toContain(labelToFilterBy); - }); + await membersPage.openActionsMenu(); + await expect(membersPage.getMenuItem(/Export 2 members/)).toBeVisible(); - expect(contentIds).toHaveLength(3); - expect(contentTimestamps).toHaveLength(3); + const {content} = await membersPage.exportMembers(); - expect(suggestedFilename.startsWith('members')).toBe(true); - expect(suggestedFilename.endsWith('.csv')).toBe(true); + expect(content).toContain('export1@example.com'); + expect(content).toContain('export2@example.com'); + expect(content).not.toContain('export3@example.com'); }); }); diff --git a/e2e/tests/admin/members-forward/list.test.ts b/e2e/tests/admin/members/list.test.ts similarity index 80% rename from e2e/tests/admin/members-forward/list.test.ts rename to e2e/tests/admin/members/list.test.ts index ff51af5da65..fd6f0189f8f 100644 --- a/e2e/tests/admin/members-forward/list.test.ts +++ b/e2e/tests/admin/members/list.test.ts @@ -1,11 +1,11 @@ import {MemberFactory, createMemberFactory} from '@/data-factory'; -import {MembersForwardPage} from '@/admin-pages'; +import {MembersListPage} from '@/admin-pages'; import {expect, test} from '@/helpers/playwright'; import {usePerTestIsolation} from '@/helpers/playwright/isolation'; usePerTestIsolation(); -test.describe('Ghost Admin - Members Forward List', () => { +test.describe('Ghost Admin - Members List', () => { test.use({labs: {membersForward: true}}); let memberFactory: MemberFactory; @@ -14,6 +14,12 @@ test.describe('Ghost Admin - Members Forward List', () => { memberFactory = createMemberFactory(page.request); }); + test('redirects the legacy members-forward route to members', async ({page}) => { + await page.goto('/ghost/#/members-forward'); + + await expect(page).toHaveURL(/\/ghost\/#\/members$/); + }); + test('displays members with name, email, status, and created date', async ({page}) => { await memberFactory.createMany([ {name: 'Alice Anderson', email: 'alice@example.com'}, @@ -21,7 +27,7 @@ test.describe('Ghost Admin - Members Forward List', () => { {name: 'Charlie Clark', email: 'charlie@example.com'} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(3); @@ -35,7 +41,7 @@ test.describe('Ghost Admin - Members Forward List', () => { }); test('shows empty state when there are no members', async ({page}) => { - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.emptyState).toBeVisible(); @@ -48,7 +54,7 @@ test.describe('Ghost Admin - Members Forward List', () => { email: 'detail@example.com' }); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await membersPage.getMemberByName('Detail Test Member').click(); diff --git a/e2e/tests/admin/members-forward/multiselect-filters.test.ts b/e2e/tests/admin/members/multiselect-filters.test.ts similarity index 95% rename from e2e/tests/admin/members-forward/multiselect-filters.test.ts rename to e2e/tests/admin/members/multiselect-filters.test.ts index 9ff36519e52..83298e0bf72 100644 --- a/e2e/tests/admin/members-forward/multiselect-filters.test.ts +++ b/e2e/tests/admin/members/multiselect-filters.test.ts @@ -1,5 +1,5 @@ import {Member, MemberFactory, createMemberFactory, createOfferFactory} from '@/data-factory'; -import {MembersForwardPage} from '@/admin-pages'; +import {MembersListPage} from '@/admin-pages'; import {PortalOfferPage} from '@/portal-pages'; import {PublicPage} from '@/public-pages'; import {SettingsService} from '@/helpers/services/settings/settings-service'; @@ -16,7 +16,7 @@ async function seedMembersAndNavigate( members: Partial[] ): Promise { await memberFactory.createMany(members); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(members.length); return membersPage; @@ -63,7 +63,7 @@ async function createOfferAndRedeem(page: Page, request: APIRequestContext, stri return {offer, suffix}; } -test.describe('Ghost Admin - Members Forward Label Multiselect Filter', () => { +test.describe('Ghost Admin - Members Label Multiselect Filter', () => { test.use({labs: {membersForward: true}}); let memberFactory: MemberFactory; @@ -158,7 +158,7 @@ test.describe('Ghost Admin - Members Forward Label Multiselect Filter', () => { {name: 'Other Member', email: 'other@example.com'} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await membersPage.addMultiselectFilter('Label', ['Delete-Me']); @@ -173,7 +173,7 @@ test.describe('Ghost Admin - Members Forward Label Multiselect Filter', () => { }); }); -test.describe('Ghost Admin - Members Forward Offer Multiselect Filter', () => { +test.describe('Ghost Admin - Members Offer Multiselect Filter', () => { test.use({labs: {membersForward: true}, stripeEnabled: true}); test('opens offer filter and selects an offer to filter members', async ({page, stripe}) => { @@ -187,7 +187,7 @@ test.describe('Ghost Admin - Members Forward Offer Multiselect Filter', () => { const memberFactory = createMemberFactory(page.request); await memberFactory.create({name: 'Free Member', email: 'free@example.com'}); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(2); @@ -205,7 +205,7 @@ test.describe('Ghost Admin - Members Forward Offer Multiselect Filter', () => { memberName: 'Searched Member' }); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await membersPage.filterButton.click(); diff --git a/e2e/tests/admin/members-forward/saved-views.test.ts b/e2e/tests/admin/members/saved-views.test.ts similarity index 96% rename from e2e/tests/admin/members-forward/saved-views.test.ts rename to e2e/tests/admin/members/saved-views.test.ts index cff1f251274..1c2970859b4 100644 --- a/e2e/tests/admin/members-forward/saved-views.test.ts +++ b/e2e/tests/admin/members/saved-views.test.ts @@ -44,7 +44,7 @@ async function saveCurrentView(page: Page, name: string) { await dialog.waitFor({state: 'hidden'}); } -test.describe('Ghost Admin - Members Forward Saved Views', () => { +test.describe('Ghost Admin - Members Saved Views', () => { test.use({labs: {membersForward: true}}); let memberFactory: MemberFactory; @@ -63,7 +63,7 @@ test.describe('Ghost Admin - Members Forward Saved Views', () => { }); const sidebar = new SidebarPage(page); - await page.goto('/ghost/#/members-forward'); + await page.goto('/ghost/#/members'); await addFilter(page, 'Name', 'active-nav'); await saveCurrentView(page, 'View A'); diff --git a/e2e/tests/admin/members-forward/search-and-filter.test.ts b/e2e/tests/admin/members/search-and-filter.test.ts similarity index 92% rename from e2e/tests/admin/members-forward/search-and-filter.test.ts rename to e2e/tests/admin/members/search-and-filter.test.ts index 5e52d90bc2c..f5cae98a164 100644 --- a/e2e/tests/admin/members-forward/search-and-filter.test.ts +++ b/e2e/tests/admin/members/search-and-filter.test.ts @@ -1,11 +1,11 @@ import {MemberFactory, createMemberFactory} from '@/data-factory'; -import {MembersForwardPage} from '@/admin-pages'; +import {MembersListPage} from '@/admin-pages'; import {expect, test} from '@/helpers/playwright'; import {usePerTestIsolation} from '@/helpers/playwright/isolation'; usePerTestIsolation(); -test.describe('Ghost Admin - Members Forward Search and Filter', () => { +test.describe('Ghost Admin - Members Search and Filter', () => { test.use({labs: {membersForward: true}}); let memberFactory: MemberFactory; @@ -21,7 +21,7 @@ test.describe('Ghost Admin - Members Forward Search and Filter', () => { {name: 'Another Member', email: 'another@example.com'} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(3); @@ -40,7 +40,7 @@ test.describe('Ghost Admin - Members Forward Search and Filter', () => { {name: 'No Label', email: 'nolabel@example.com'} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(3); @@ -58,7 +58,7 @@ test.describe('Ghost Admin - Members Forward Search and Filter', () => { {name: 'Charlie Gamma', email: 'charlie@gamma.com'} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(4); @@ -96,7 +96,7 @@ test.describe('Ghost Admin - Members Forward Search and Filter', () => { test('shows no results state when search matches nothing', async ({page}) => { await memberFactory.create({name: 'Existing Member', email: 'exists@example.com'}); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(1); diff --git a/e2e/tests/admin/members-forward/tier-filter-search.test.ts b/e2e/tests/admin/members/tier-filter-search.test.ts similarity index 100% rename from e2e/tests/admin/members-forward/tier-filter-search.test.ts rename to e2e/tests/admin/members/tier-filter-search.test.ts From bc05ca9e1a0aefb88cf10a9af3eaf2dc9fb75af0 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Apr 2026 13:28:30 +0200 Subject: [PATCH 02/29] Fixed member e2e routes after suite reshuffle ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This finishes the members and members-legacy e2e split by moving stale React tests off members-forward and aligning detail navigation with the current members list UI. --- e2e/helpers/pages/admin/members/members-list-page.ts | 8 ++++++++ e2e/tests/admin/members/list.test.ts | 2 +- e2e/tests/admin/members/saved-views.test.ts | 2 +- e2e/tests/admin/members/search-and-filter.test.ts | 10 +++++----- e2e/tests/admin/members/virtual-window.test.ts | 8 ++++---- 5 files changed, 19 insertions(+), 11 deletions(-) diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 18ad03accbf..205964a9006 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -39,6 +39,14 @@ export class MembersListPage extends AdminPage { return this.memberRows.filter({hasText: name}); } + getMemberLinkByName(name: string): Locator { + return this.getMemberByName(name).getByRole('link', {name, exact: true}); + } + + async openMemberByName(name: string): Promise { + await this.getMemberLinkByName(name).click(); + } + async openActionsMenu(): Promise { await this.actionsButton.click(); } diff --git a/e2e/tests/admin/members/list.test.ts b/e2e/tests/admin/members/list.test.ts index fd6f0189f8f..b2848b4e669 100644 --- a/e2e/tests/admin/members/list.test.ts +++ b/e2e/tests/admin/members/list.test.ts @@ -57,7 +57,7 @@ test.describe('Ghost Admin - Members List', () => { const membersPage = new MembersListPage(page); await membersPage.goto(); - await membersPage.getMemberByName('Detail Test Member').click(); + await membersPage.openMemberByName('Detail Test Member'); await expect(page).toHaveURL(new RegExp(`/members/${member.id}`)); }); diff --git a/e2e/tests/admin/members/saved-views.test.ts b/e2e/tests/admin/members/saved-views.test.ts index 1c2970859b4..986813cebbf 100644 --- a/e2e/tests/admin/members/saved-views.test.ts +++ b/e2e/tests/admin/members/saved-views.test.ts @@ -15,7 +15,7 @@ async function addFilter(page: Page, filterName: 'Name' | 'Email' | 'Label', val const existingFilter = params.get('filter'); params.set('filter', existingFilter ? `${existingFilter}+${labelFilter}` : labelFilter); - await page.goto(`/ghost/#/members-forward?${params.toString()}`); + await page.goto(`/ghost/#/members?${params.toString()}`); return; } diff --git a/e2e/tests/admin/members/search-and-filter.test.ts b/e2e/tests/admin/members/search-and-filter.test.ts index f5cae98a164..0b91b96334c 100644 --- a/e2e/tests/admin/members/search-and-filter.test.ts +++ b/e2e/tests/admin/members/search-and-filter.test.ts @@ -44,7 +44,7 @@ test.describe('Ghost Admin - Members Search and Filter', () => { await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(3); - await page.goto('/ghost/#/members-forward?filter=label:VIP'); + await page.goto('/ghost/#/members?filter=label:VIP'); await expect(membersPage.memberRows).toHaveCount(2); await expect(membersPage.getMemberByName('Labelled One')).toBeVisible(); await expect(membersPage.getMemberByName('Labelled Two')).toBeVisible(); @@ -65,7 +65,7 @@ test.describe('Ghost Admin - Members Search and Filter', () => { await membersPage.addFilter('Name', 'Alice'); await expect(membersPage.memberRows).toHaveCount(2); - await page.goto('/ghost/#/members-forward?filter=name:~%27Alice%27%2Blabel:Premium'); + await page.goto('/ghost/#/members?filter=name:~%27Alice%27%2Blabel:Premium'); await expect(membersPage.memberRows).toHaveCount(1); await expect(membersPage.getMemberByName('Alice Alpha')).toBeVisible(); @@ -81,14 +81,14 @@ test.describe('Ghost Admin - Members Search and Filter', () => { {name: 'No Label', email: 'nolabel@example.com'} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.memberRows).toHaveCount(4); - await page.goto('/ghost/#/members-forward?filter=label:VIP'); + await page.goto('/ghost/#/members?filter=label:VIP'); await expect(membersPage.memberRows).toHaveCount(2); - await page.goto('/ghost/#/members-forward?filter=label:VIP%2Blabel:Premium'); + await page.goto('/ghost/#/members?filter=label:VIP%2Blabel:Premium'); await expect(membersPage.memberRows).toHaveCount(1); await expect(membersPage.getMemberByName('Both Labels')).toBeVisible(); }); diff --git a/e2e/tests/admin/members/virtual-window.test.ts b/e2e/tests/admin/members/virtual-window.test.ts index 444638c3c2b..cc24d141c88 100644 --- a/e2e/tests/admin/members/virtual-window.test.ts +++ b/e2e/tests/admin/members/virtual-window.test.ts @@ -67,7 +67,7 @@ test.describe('Ghost Admin - Members Virtual Window', () => { await mockLargeMembersList(page, members); - const membersPage = new MembersPage(page, {route: 'members-forward'}); + const membersPage = new MembersPage(page, {route: 'members'}); const memberDetailsPage = new MemberDetailsPage(page); await membersPage.goto(); @@ -77,7 +77,7 @@ test.describe('Ghost Admin - Members Virtual Window', () => { await expect.poll(async () => { const historyState = await page.evaluate(() => window.history.state); - return historyState?.ghostVirtualListWindow?.['/members-forward::']; + return historyState?.ghostVirtualListWindow?.['/members::']; }).toBe(2000); const maxRenderedIndex = await membersPage.scrollUntilMaxRenderedIndexAtLeast(1000); @@ -94,12 +94,12 @@ test.describe('Ghost Admin - Members Virtual Window', () => { expect(targetRow.index).toBeGreaterThan(1000); - await targetRowLocator.click(); + await targetRowLocator.getByRole('link').click(); await expect(memberDetailsPage.nameInput).toBeVisible(); await page.goBack(); - await expect(page).toHaveURL(/\/ghost\/#\/members-forward$/); + await expect(page).toHaveURL(/\/ghost\/#\/members$/); await expect(membersPage.getMemberListItemByIndex(targetRow.index)).toContainText(targetRow.text ?? ''); await expect.poll(async () => { From 171015d43d0b08a40e025e4102d1829499af9c5c Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Apr 2026 13:52:28 +0200 Subject: [PATCH 03/29] Reduced duplicate members route tests ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release Kept route-specific members coverage and removed duplicate list, export, and import pass-through tests --- .../app-sidebar/member-sidebar-views.test.tsx | 67 ---------------- .../members/import-members/modal.test.tsx | 51 ------------ .../views/members/members-actions.test.tsx | 33 -------- e2e/tests/admin/members/export.test.ts | 36 +-------- e2e/tests/admin/members/list.test.ts | 27 +------ .../admin/members/search-and-filter.test.ts | 79 +------------------ 6 files changed, 3 insertions(+), 290 deletions(-) delete mode 100644 apps/admin/src/layout/app-sidebar/member-sidebar-views.test.tsx diff --git a/apps/admin/src/layout/app-sidebar/member-sidebar-views.test.tsx b/apps/admin/src/layout/app-sidebar/member-sidebar-views.test.tsx deleted file mode 100644 index 5582a3f67fe..00000000000 --- a/apps/admin/src/layout/app-sidebar/member-sidebar-views.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -// @vitest-environment jsdom - -import {describe, expect, it, vi} from 'vitest'; -import {renderHook} from '@testing-library/react'; -import {type SharedView} from './shared-views'; -import {useMemberSidebarViews} from './member-sidebar-views'; - -const {mockUseLocation, mockUseSharedViews} = vi.hoisted(() => ({ - mockUseLocation: vi.fn(), - mockUseSharedViews: vi.fn<(route?: string) => SharedView[]>() -})); - -vi.mock('@tryghost/admin-x-framework', () => ({ - useLocation: mockUseLocation -})); - -vi.mock('./shared-views', () => ({ - useSharedViews: mockUseSharedViews -})); - -describe('useMemberSidebarViews', () => { - it('builds saved member view URLs on the members route', () => { - mockUseLocation.mockReturnValue({ - pathname: '/members', - search: '?filter=status%3Afree' - }); - mockUseSharedViews.mockReturnValue([ - { - name: 'Free members', - route: 'members', - filter: {filter: 'status:free'} - } - ]); - - const {result} = renderHook(() => useMemberSidebarViews()); - - expect(result.current).toEqual([ - { - key: 'Free members:status:free', - name: 'Free members', - to: 'members?filter=status%3Afree', - isActive: true - } - ]); - }); - - it('does not mark saved member views active off the base members route', () => { - mockUseLocation.mockReturnValue({ - pathname: '/members/import', - search: '?filter=status%3Afree' - }); - mockUseSharedViews.mockReturnValue([ - { - name: 'Free members', - route: 'members', - filter: {filter: 'status:free'} - } - ]); - - const {result} = renderHook(() => useMemberSidebarViews()); - - expect(result.current[0]).toMatchObject({ - to: 'members?filter=status%3Afree', - isActive: false - }); - }); -}); diff --git a/apps/posts/test/unit/views/members/import-members/modal.test.tsx b/apps/posts/test/unit/views/members/import-members/modal.test.tsx index 90adfd3a6aa..89094277491 100644 --- a/apps/posts/test/unit/views/members/import-members/modal.test.tsx +++ b/apps/posts/test/unit/views/members/import-members/modal.test.tsx @@ -107,55 +107,4 @@ describe('ImportMembersModal', () => { expect(screen.getByRole('heading', {name: /import in progress/i})).toBeInTheDocument(); }); - - it('passes import label metadata to onComplete for completed uploads', async () => { - const onComplete = vi.fn(); - vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ - meta: { - stats: {imported: 1, invalid: []}, - import_label: { - name: 'Import April', - slug: 'import-april' - } - } - }), { - status: 200, - headers: {'Content-Type': 'application/json'} - }))); - - render( - {}} - /> - ); - - const dropTarget = screen.getByRole('button', {name: /select or drop a csv file/i}); - const csvFile = createFile('members.csv', 'text/csv', 'email,name\nmember@example.com,Member'); - - fireEvent.drop(dropTarget, { - dataTransfer: { - files: [csvFile], - items: [{ - kind: 'file', - type: csvFile.type, - getAsFile: () => csvFile - }], - types: ['Files'] - } - }); - - const importButton = await screen.findByRole('button', {name: /import 1 member/i}); - fireEvent.click(importButton); - - await waitFor(() => { - expect(onComplete).toHaveBeenCalledWith(expect.objectContaining({ - importLabel: { - name: 'Import April', - slug: 'import-april' - } - })); - }); - }); }); diff --git a/apps/posts/test/unit/views/members/members-actions.test.tsx b/apps/posts/test/unit/views/members/members-actions.test.tsx index 868d8792978..b5affc7044e 100644 --- a/apps/posts/test/unit/views/members/members-actions.test.tsx +++ b/apps/posts/test/unit/views/members/members-actions.test.tsx @@ -52,39 +52,6 @@ describe('MembersActions', () => { mockUseNavigate.mockReturnValue(vi.fn()); }); - it('passes onImportComplete to ImportMembersModal onComplete prop', () => { - const onImportComplete = vi.fn(); - - render( - - ); - - expect(importModalPropsRef.current).not.toBeNull(); - const handleImportComplete = importModalPropsRef.current?.onComplete as ((result?: {importLabel?: {name: string; slug: string}}) => void) | undefined; - - expect(handleImportComplete).toBeTypeOf('function'); - - handleImportComplete?.({ - importLabel: { - name: 'Import April', - slug: 'import-april' - } - }); - - expect(onImportComplete).toHaveBeenCalledWith({ - importLabel: { - name: 'Import April', - slug: 'import-april' - } - }); - }); - it('opens the import modal when rendered on the import route', () => { mockUseLocation.mockReturnValue({ pathname: '/members/import' diff --git a/e2e/tests/admin/members/export.test.ts b/e2e/tests/admin/members/export.test.ts index 50e4f3f0a8d..f74323780b8 100644 --- a/e2e/tests/admin/members/export.test.ts +++ b/e2e/tests/admin/members/export.test.ts @@ -5,20 +5,6 @@ import {usePerTestIsolation} from '@/helpers/playwright/isolation'; usePerTestIsolation(); -const EXPECTED_CSV_HEADER_FIELDS = [ - 'id,', - 'email,', - 'name,', - 'note,', - 'subscribed_to_emails,', - 'complimentary_plan,', - 'stripe_customer_id,', - 'created_at,', - 'deleted_at,', - 'labels,', - 'tiers' -]; - test.describe('Ghost Admin - Members Export', () => { test.use({labs: {membersForward: true}}); @@ -34,27 +20,7 @@ test.describe('Ghost Admin - Members Export', () => { memberFactory = createMemberFactory(page.request); }); - test('exports all members to a CSV with expected fields', async ({page}) => { - await memberFactory.createMany(membersFixture); - - const membersPage = new MembersListPage(page); - await membersPage.goto(); - await membersPage.openActionsMenu(); - - const {suggestedFilename, content} = await membersPage.exportMembers(); - - expect(content).toMatch(new RegExp(EXPECTED_CSV_HEADER_FIELDS.join(''))); - - for (const member of membersFixture) { - expect(content).toContain(member.name); - expect(content).toContain(member.email); - expect(content).toContain(member.note); - } - - expect(suggestedFilename).toMatch(/^members\.\d{4}-\d{2}-\d{2}\.csv$/); - }); - - test('exports only filtered members when a filter is active', async ({page}) => { + test('exports the filtered members from the React list route', async ({page}) => { await memberFactory.createMany(membersFixture); const membersPage = new MembersListPage(page); diff --git a/e2e/tests/admin/members/list.test.ts b/e2e/tests/admin/members/list.test.ts index b2848b4e669..b415bbb0778 100644 --- a/e2e/tests/admin/members/list.test.ts +++ b/e2e/tests/admin/members/list.test.ts @@ -20,7 +20,7 @@ test.describe('Ghost Admin - Members List', () => { await expect(page).toHaveURL(/\/ghost\/#\/members$/); }); - test('displays members with name, email, status, and created date', async ({page}) => { + test('renders the React members list on the members route', async ({page}) => { await memberFactory.createMany([ {name: 'Alice Anderson', email: 'alice@example.com'}, {name: 'Bob Baker', email: 'bob@example.com'}, @@ -34,31 +34,6 @@ test.describe('Ghost Admin - Members List', () => { await expect(membersPage.getMemberByName('Alice Anderson')).toBeVisible(); await expect(membersPage.getMemberByName('Bob Baker')).toBeVisible(); await expect(membersPage.getMemberByName('Charlie Clark')).toBeVisible(); - - // Each row shows the email and status await expect(membersPage.getMemberByName('Alice Anderson')).toContainText('alice@example.com'); - await expect(membersPage.getMemberByName('Alice Anderson')).toContainText('Free'); - }); - - test('shows empty state when there are no members', async ({page}) => { - const membersPage = new MembersListPage(page); - await membersPage.goto(); - - await expect(membersPage.emptyState).toBeVisible(); - await expect(membersPage.memberRows).toHaveCount(0); - }); - - test('navigates to member detail when clicking a row', async ({page}) => { - const member = await memberFactory.create({ - name: 'Detail Test Member', - email: 'detail@example.com' - }); - - const membersPage = new MembersListPage(page); - await membersPage.goto(); - - await membersPage.openMemberByName('Detail Test Member'); - - await expect(page).toHaveURL(new RegExp(`/members/${member.id}`)); }); }); diff --git a/e2e/tests/admin/members/search-and-filter.test.ts b/e2e/tests/admin/members/search-and-filter.test.ts index 0b91b96334c..aa84a0b4c6f 100644 --- a/e2e/tests/admin/members/search-and-filter.test.ts +++ b/e2e/tests/admin/members/search-and-filter.test.ts @@ -14,26 +14,7 @@ test.describe('Ghost Admin - Members Search and Filter', () => { memberFactory = createMemberFactory(page.request); }); - test('filters members by searching for a name and clears search to restore all', async ({page}) => { - await memberFactory.createMany([ - {name: 'Unique Searchable Name', email: 'unique@example.com'}, - {name: 'Other Member', email: 'other@example.com'}, - {name: 'Another Member', email: 'another@example.com'} - ]); - - const membersPage = new MembersListPage(page); - await membersPage.goto(); - await expect(membersPage.memberRows).toHaveCount(3); - - await membersPage.searchInput.fill('Unique Searchable'); - await expect(membersPage.memberRows).toHaveCount(1); - await expect(membersPage.getMemberByName('Unique Searchable Name')).toBeVisible(); - - await membersPage.searchInput.clear(); - await expect(membersPage.memberRows).toHaveCount(3); - }); - - test('filters members by label and updates the displayed count', async ({page}) => { + test('applies an existing label filter on the members route', async ({page}) => { await memberFactory.createMany([ {name: 'Labelled One', email: 'labelled1@example.com', labels: ['VIP']}, {name: 'Labelled Two', email: 'labelled2@example.com', labels: ['VIP']}, @@ -49,62 +30,4 @@ test.describe('Ghost Admin - Members Search and Filter', () => { await expect(membersPage.getMemberByName('Labelled One')).toBeVisible(); await expect(membersPage.getMemberByName('Labelled Two')).toBeVisible(); }); - - test('combines multiple filters to narrow results and clears all at once', async ({page}) => { - await memberFactory.createMany([ - {name: 'Alice Alpha', email: 'alice@alpha.com', labels: ['Premium']}, - {name: 'Alice Beta', email: 'alice@beta.com'}, - {name: 'Bob Alpha', email: 'bob@alpha.com', labels: ['Premium']}, - {name: 'Charlie Gamma', email: 'charlie@gamma.com'} - ]); - - const membersPage = new MembersListPage(page); - await membersPage.goto(); - await expect(membersPage.memberRows).toHaveCount(4); - - await membersPage.addFilter('Name', 'Alice'); - await expect(membersPage.memberRows).toHaveCount(2); - - await page.goto('/ghost/#/members?filter=name:~%27Alice%27%2Blabel:Premium'); - await expect(membersPage.memberRows).toHaveCount(1); - await expect(membersPage.getMemberByName('Alice Alpha')).toBeVisible(); - - await membersPage.clearFiltersButton.click(); - await expect(membersPage.memberRows).toHaveCount(4); - }); - - test('adds a second label filter without replacing the first', async ({page}) => { - await memberFactory.createMany([ - {name: 'Both Labels', email: 'both@example.com', labels: ['VIP', 'Premium']}, - {name: 'VIP Only', email: 'vip@example.com', labels: ['VIP']}, - {name: 'Premium Only', email: 'premium@example.com', labels: ['Premium']}, - {name: 'No Label', email: 'nolabel@example.com'} - ]); - - const membersPage = new MembersListPage(page); - await membersPage.goto(); - await expect(membersPage.memberRows).toHaveCount(4); - - await page.goto('/ghost/#/members?filter=label:VIP'); - await expect(membersPage.memberRows).toHaveCount(2); - - await page.goto('/ghost/#/members?filter=label:VIP%2Blabel:Premium'); - await expect(membersPage.memberRows).toHaveCount(1); - await expect(membersPage.getMemberByName('Both Labels')).toBeVisible(); - }); - - test('shows no results state when search matches nothing', async ({page}) => { - await memberFactory.create({name: 'Existing Member', email: 'exists@example.com'}); - - const membersPage = new MembersListPage(page); - await membersPage.goto(); - await expect(membersPage.memberRows).toHaveCount(1); - - await membersPage.searchInput.fill('nonexistentnamestring'); - await expect(membersPage.noResults).toBeVisible(); - await expect(membersPage.showAllButton).toBeVisible(); - - await membersPage.searchInput.clear(); - await expect(membersPage.memberRows).toHaveCount(1); - }); }); From 4ad2087254ce721c1d3784c3b2430d1c87e3a148 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Apr 2026 14:30:24 +0200 Subject: [PATCH 04/29] Simplified members nav active route handling ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release Removed the dead members route helper and inlined the fixed legacy active-route list in the sidebar --- .../layout/app-sidebar/nav-content.helpers.test.ts | 12 +----------- .../src/layout/app-sidebar/nav-content.helpers.ts | 4 ---- apps/admin/src/layout/app-sidebar/nav-content.tsx | 6 ++++-- 3 files changed, 5 insertions(+), 17 deletions(-) diff --git a/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts b/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts index 70bbb3f9625..b4ce2a157a0 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts +++ b/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts @@ -1,15 +1,5 @@ import {describe, expect, it} from 'vitest'; -import {getMembersNavActiveRoutes, isMembersNavActive} from './nav-content.helpers'; - -describe('getMembersNavActiveRoutes', () => { - it('returns only the members routes that are still Ember-owned', () => { - expect(getMembersNavActiveRoutes()).toEqual([ - 'members', - 'member', - 'member.new' - ]); - }); -}); +import {isMembersNavActive} from './nav-content.helpers'; describe('isMembersNavActive', () => { it('uses the legacy route active state when the feature flag is disabled', () => { diff --git a/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts b/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts index 75c9519e064..58e62d37ba6 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts +++ b/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts @@ -1,7 +1,3 @@ -export function getMembersNavActiveRoutes(): string[] { - return ['members', 'member', 'member.new']; -} - export function isMembersNavActive({ membersForwardEnabled, isOnMembersRoute, diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 3297a580ce0..570ad7cd358 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -11,11 +11,13 @@ import { useNavigationExpanded } from "./hooks/use-navigation-preferences"; import { NavCustomViews } from "./nav-custom-views"; import { NavMemberViews } from "./nav-member-views"; import { useMemberSidebarViews } from "./member-sidebar-views"; -import { getMembersNavActiveRoutes, isMembersNavActive } from "./nav-content.helpers"; +import { isMembersNavActive } from "./nav-content.helpers"; import { useCustomSidebarViews } from "./use-custom-sidebar-views"; import { useEmberRouting } from "@/ember-bridge"; import { useFeatureFlag } from "@/hooks/use-feature-flag"; +const LEGACY_MEMBERS_ACTIVE_ROUTES = ['members', 'member', 'member.new']; + function PostsNavItemContent({isActive, to}: {isActive: boolean; to: string}) { return ( <> @@ -94,7 +96,7 @@ function NavContent({ ...props }: React.ComponentProps) { isOnMembersRoute, hasActiveMemberView, isMembersExpanded: membersExpanded, - isLegacyMembersRouteActive: routing.isRouteActive(getMembersNavActiveRoutes()) + isLegacyMembersRouteActive: routing.isRouteActive(LEGACY_MEMBERS_ACTIVE_ROUTES) }); const postsRoute = routing.getRouteUrl('posts'); const isPostsRouteActive = routing.isRouteActive('posts'); From fb4107c253ce372c2fe13c8428f81e494d103bb4 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Apr 2026 15:12:46 +0200 Subject: [PATCH 05/29] Restored members React e2e coverage shape ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release Reverted the over-reduced React members e2e changes and kept only the route and page-object adaptations needed for this branch --- .../pages/admin/members/members-list-page.ts | 4 +- e2e/tests/admin/members/export.test.ts | 37 ++++++++- e2e/tests/admin/members/list.test.ts | 31 ++++++-- .../admin/members/search-and-filter.test.ts | 79 ++++++++++++++++++- 4 files changed, 140 insertions(+), 11 deletions(-) diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 205964a9006..4533b721076 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -36,7 +36,9 @@ export class MembersListPage extends AdminPage { } getMemberByName(name: string): Locator { - return this.memberRows.filter({hasText: name}); + return this.memberRows.filter({ + has: this.page.getByRole('link', {name, exact: true}) + }); } getMemberLinkByName(name: string): Locator { diff --git a/e2e/tests/admin/members/export.test.ts b/e2e/tests/admin/members/export.test.ts index f74323780b8..900ca4f63c2 100644 --- a/e2e/tests/admin/members/export.test.ts +++ b/e2e/tests/admin/members/export.test.ts @@ -5,6 +5,20 @@ import {usePerTestIsolation} from '@/helpers/playwright/isolation'; usePerTestIsolation(); +const EXPECTED_CSV_HEADER_FIELDS = [ + 'id,', + 'email,', + 'name,', + 'note,', + 'subscribed_to_emails,', + 'complimentary_plan,', + 'stripe_customer_id,', + 'created_at,', + 'deleted_at,', + 'labels,', + 'tiers' +]; + test.describe('Ghost Admin - Members Export', () => { test.use({labs: {membersForward: true}}); @@ -20,13 +34,32 @@ test.describe('Ghost Admin - Members Export', () => { memberFactory = createMemberFactory(page.request); }); - test('exports the filtered members from the React list route', async ({page}) => { + test('exports all members to a CSV with expected fields', async ({page}) => { await memberFactory.createMany(membersFixture); const membersPage = new MembersListPage(page); await membersPage.goto(); - await membersPage.addFilter('Label', 'alpha'); + await membersPage.openActionsMenu(); + + const {suggestedFilename, content} = await membersPage.exportMembers(); + + expect(content).toMatch(new RegExp(EXPECTED_CSV_HEADER_FIELDS.join(''))); + + for (const member of membersFixture) { + expect(content).toContain(member.name); + expect(content).toContain(member.email); + expect(content).toContain(member.note); + } + + expect(suggestedFilename).toMatch(/^members\.\d{4}-\d{2}-\d{2}\.csv$/); + }); + + test('exports only filtered members when a filter is active', async ({page}) => { + await memberFactory.createMany(membersFixture); + + const membersPage = new MembersListPage(page); + await page.goto('/ghost/#/members?filter=label:alpha'); await expect(membersPage.memberRows).toHaveCount(2); await membersPage.openActionsMenu(); diff --git a/e2e/tests/admin/members/list.test.ts b/e2e/tests/admin/members/list.test.ts index b415bbb0778..3a8a3f2b23a 100644 --- a/e2e/tests/admin/members/list.test.ts +++ b/e2e/tests/admin/members/list.test.ts @@ -14,13 +14,7 @@ test.describe('Ghost Admin - Members List', () => { memberFactory = createMemberFactory(page.request); }); - test('redirects the legacy members-forward route to members', async ({page}) => { - await page.goto('/ghost/#/members-forward'); - - await expect(page).toHaveURL(/\/ghost\/#\/members$/); - }); - - test('renders the React members list on the members route', async ({page}) => { + test('displays members with name, email, status, and created date', async ({page}) => { await memberFactory.createMany([ {name: 'Alice Anderson', email: 'alice@example.com'}, {name: 'Bob Baker', email: 'bob@example.com'}, @@ -35,5 +29,28 @@ test.describe('Ghost Admin - Members List', () => { await expect(membersPage.getMemberByName('Bob Baker')).toBeVisible(); await expect(membersPage.getMemberByName('Charlie Clark')).toBeVisible(); await expect(membersPage.getMemberByName('Alice Anderson')).toContainText('alice@example.com'); + await expect(membersPage.getMemberByName('Alice Anderson')).toContainText('Free'); + }); + + test('shows empty state when there are no members', async ({page}) => { + const membersPage = new MembersListPage(page); + await membersPage.goto(); + + await expect(membersPage.emptyState).toBeVisible(); + await expect(membersPage.memberRows).toHaveCount(0); + }); + + test('navigates to member detail when clicking a row', async ({page}) => { + const member = await memberFactory.create({ + name: 'Detail Test Member', + email: 'detail@example.com' + }); + + const membersPage = new MembersListPage(page); + await membersPage.goto(); + + await membersPage.openMemberByName('Detail Test Member'); + + await expect(page).toHaveURL(new RegExp(`/members/${member.id}`)); }); }); diff --git a/e2e/tests/admin/members/search-and-filter.test.ts b/e2e/tests/admin/members/search-and-filter.test.ts index aa84a0b4c6f..0b91b96334c 100644 --- a/e2e/tests/admin/members/search-and-filter.test.ts +++ b/e2e/tests/admin/members/search-and-filter.test.ts @@ -14,7 +14,26 @@ test.describe('Ghost Admin - Members Search and Filter', () => { memberFactory = createMemberFactory(page.request); }); - test('applies an existing label filter on the members route', async ({page}) => { + test('filters members by searching for a name and clears search to restore all', async ({page}) => { + await memberFactory.createMany([ + {name: 'Unique Searchable Name', email: 'unique@example.com'}, + {name: 'Other Member', email: 'other@example.com'}, + {name: 'Another Member', email: 'another@example.com'} + ]); + + const membersPage = new MembersListPage(page); + await membersPage.goto(); + await expect(membersPage.memberRows).toHaveCount(3); + + await membersPage.searchInput.fill('Unique Searchable'); + await expect(membersPage.memberRows).toHaveCount(1); + await expect(membersPage.getMemberByName('Unique Searchable Name')).toBeVisible(); + + await membersPage.searchInput.clear(); + await expect(membersPage.memberRows).toHaveCount(3); + }); + + test('filters members by label and updates the displayed count', async ({page}) => { await memberFactory.createMany([ {name: 'Labelled One', email: 'labelled1@example.com', labels: ['VIP']}, {name: 'Labelled Two', email: 'labelled2@example.com', labels: ['VIP']}, @@ -30,4 +49,62 @@ test.describe('Ghost Admin - Members Search and Filter', () => { await expect(membersPage.getMemberByName('Labelled One')).toBeVisible(); await expect(membersPage.getMemberByName('Labelled Two')).toBeVisible(); }); + + test('combines multiple filters to narrow results and clears all at once', async ({page}) => { + await memberFactory.createMany([ + {name: 'Alice Alpha', email: 'alice@alpha.com', labels: ['Premium']}, + {name: 'Alice Beta', email: 'alice@beta.com'}, + {name: 'Bob Alpha', email: 'bob@alpha.com', labels: ['Premium']}, + {name: 'Charlie Gamma', email: 'charlie@gamma.com'} + ]); + + const membersPage = new MembersListPage(page); + await membersPage.goto(); + await expect(membersPage.memberRows).toHaveCount(4); + + await membersPage.addFilter('Name', 'Alice'); + await expect(membersPage.memberRows).toHaveCount(2); + + await page.goto('/ghost/#/members?filter=name:~%27Alice%27%2Blabel:Premium'); + await expect(membersPage.memberRows).toHaveCount(1); + await expect(membersPage.getMemberByName('Alice Alpha')).toBeVisible(); + + await membersPage.clearFiltersButton.click(); + await expect(membersPage.memberRows).toHaveCount(4); + }); + + test('adds a second label filter without replacing the first', async ({page}) => { + await memberFactory.createMany([ + {name: 'Both Labels', email: 'both@example.com', labels: ['VIP', 'Premium']}, + {name: 'VIP Only', email: 'vip@example.com', labels: ['VIP']}, + {name: 'Premium Only', email: 'premium@example.com', labels: ['Premium']}, + {name: 'No Label', email: 'nolabel@example.com'} + ]); + + const membersPage = new MembersListPage(page); + await membersPage.goto(); + await expect(membersPage.memberRows).toHaveCount(4); + + await page.goto('/ghost/#/members?filter=label:VIP'); + await expect(membersPage.memberRows).toHaveCount(2); + + await page.goto('/ghost/#/members?filter=label:VIP%2Blabel:Premium'); + await expect(membersPage.memberRows).toHaveCount(1); + await expect(membersPage.getMemberByName('Both Labels')).toBeVisible(); + }); + + test('shows no results state when search matches nothing', async ({page}) => { + await memberFactory.create({name: 'Existing Member', email: 'exists@example.com'}); + + const membersPage = new MembersListPage(page); + await membersPage.goto(); + await expect(membersPage.memberRows).toHaveCount(1); + + await membersPage.searchInput.fill('nonexistentnamestring'); + await expect(membersPage.noResults).toBeVisible(); + await expect(membersPage.showAllButton).toBeVisible(); + + await membersPage.searchInput.clear(); + await expect(membersPage.memberRows).toHaveCount(1); + }); }); From ea2bff430116515608630dd9bd8b7a323ec62928 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Apr 2026 15:52:28 +0200 Subject: [PATCH 06/29] Fixed members e2e search selector ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The members page object was matching Ember's hidden search input from EmberRoot instead of the visible React control. --- e2e/helpers/pages/admin/members/members-list-page.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 4533b721076..47076c163ba 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -25,7 +25,10 @@ export class MembersListPage extends AdminPage { this.membersList = page.getByTestId('members-list'); this.memberRows = page.getByTestId('members-list-item'); - this.searchInput = page.getByLabel('Search members', {exact: true}); + this.searchInput = page.locator([ + 'input[aria-label="Search members"]:visible', + 'input[aria-label="Search members mobile"]:visible' + ].join(', ')).first(); this.actionsButton = page.getByTestId('members-actions'); this.newMemberButton = page.getByRole('link', {name: 'New member'}); this.filterButton = page.getByRole('button', {name: /^(Filter|Add filter)$/}); From 60dca51fee526c39c1b8dab9b26de947e41eb076 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 1 Apr 2026 16:25:10 +0200 Subject: [PATCH 07/29] Fixed members review issues and e2e scoping ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release Preserve members-forward query state, keep import on the React path, pin legacy member e2e coverage to Ember, and scope the React members page object away from hidden Ember rows. --- apps/admin/src/members-route-gate.tsx | 6 ++++-- apps/admin/src/routes.tsx | 5 ++++- .../members/components/members-header-search.tsx | 3 +++ .../views/members/import-members/modal.test.tsx | 13 +++++++++++++ .../pages/admin/members/members-list-page.ts | 7 ++----- .../members-legacy/disable-commenting.test.ts | 2 ++ e2e/tests/admin/members-legacy/export.test.ts | 2 ++ .../admin/members-legacy/filter-actions.test.ts | 2 ++ .../admin/members-legacy/impersonation.test.ts | 2 ++ e2e/tests/admin/members-legacy/import.test.ts | 10 +++++++--- .../members-legacy/member-activity-events.test.ts | 2 ++ e2e/tests/admin/members-legacy/members.test.ts | 2 ++ .../admin/members-legacy/stripe-webhooks.test.ts | 4 ++-- e2e/tests/admin/members/list.test.ts | 14 ++++++++++++++ 14 files changed, 61 insertions(+), 13 deletions(-) diff --git a/apps/admin/src/members-route-gate.tsx b/apps/admin/src/members-route-gate.tsx index ee1ecd56a1b..ec315638488 100644 --- a/apps/admin/src/members-route-gate.tsx +++ b/apps/admin/src/members-route-gate.tsx @@ -1,11 +1,13 @@ -import {Outlet} from "@tryghost/admin-x-framework"; +import {Outlet, useLocation} from "@tryghost/admin-x-framework"; import {EmberFallback} from "./ember-bridge"; import {useFeatureFlag} from "./hooks/use-feature-flag"; export function MembersRouteGate() { const membersForwardEnabled = useFeatureFlag("membersForward"); + const location = useLocation(); + const isMembersListRoute = location.pathname === "/members"; - if (!membersForwardEnabled) { + if (!membersForwardEnabled && isMembersListRoute) { return ; } diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index f35bf26f9d0..1745304561a 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -76,7 +76,10 @@ const membersForwardRedirectRoute: RouteObject = { path: "/members-forward", // TODO: Remove once the legacy Ember members list is deleted. handle: emberFallbackHandle, - loader: () => redirect("/members") + loader: ({request}) => { + const url = new URL(request.url); + return redirect(`/members${url.search}`); + } }; export const routes: RouteObject[] = [ diff --git a/apps/posts/src/views/members/components/members-header-search.tsx b/apps/posts/src/views/members/components/members-header-search.tsx index 4cef9907cc9..c276d882b35 100644 --- a/apps/posts/src/views/members/components/members-header-search.tsx +++ b/apps/posts/src/views/members/components/members-header-search.tsx @@ -15,6 +15,8 @@ const MembersHeaderSearch: React.FC = ({ autoFocus = false, ariaLabel = 'Search members' }) => { + const testId = ariaLabel === 'Search members mobile' ? 'members-mobile-search-input' : 'members-search-input'; + return ( @@ -24,6 +26,7 @@ const MembersHeaderSearch: React.FC = ({ aria-label={ariaLabel} autoFocus={autoFocus} className='!h-[34px]' + data-testid={testId} placeholder="Search members..." value={search} onChange={event => onSearchChange(event.target.value)} diff --git a/apps/posts/test/unit/views/members/import-members/modal.test.tsx b/apps/posts/test/unit/views/members/import-members/modal.test.tsx index 89094277491..eca027dbbe7 100644 --- a/apps/posts/test/unit/views/members/import-members/modal.test.tsx +++ b/apps/posts/test/unit/views/members/import-members/modal.test.tsx @@ -52,6 +52,9 @@ class MockFileReader { } } +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; + describe('ImportMembersModal', () => { beforeEach(() => { vi.stubGlobal('FileReader', MockFileReader); @@ -71,6 +74,16 @@ describe('ImportMembersModal', () => { afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); + Object.defineProperty(URL, 'createObjectURL', { + configurable: true, + writable: true, + value: originalCreateObjectURL + }); + Object.defineProperty(URL, 'revokeObjectURL', { + configurable: true, + writable: true, + value: originalRevokeObjectURL + }); }); it('calls onComplete when the upload response is accepted for background processing', async () => { diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 47076c163ba..202e68e24b0 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -24,11 +24,8 @@ export class MembersListPage extends AdminPage { this.pageUrl = '/ghost/#/members'; this.membersList = page.getByTestId('members-list'); - this.memberRows = page.getByTestId('members-list-item'); - this.searchInput = page.locator([ - 'input[aria-label="Search members"]:visible', - 'input[aria-label="Search members mobile"]:visible' - ].join(', ')).first(); + this.memberRows = this.membersList.getByTestId('members-list-item'); + this.searchInput = page.getByTestId('members-search-input'); this.actionsButton = page.getByTestId('members-actions'); this.newMemberButton = page.getByRole('link', {name: 'New member'}); this.filterButton = page.getByRole('button', {name: /^(Filter|Add filter)$/}); diff --git a/e2e/tests/admin/members-legacy/disable-commenting.test.ts b/e2e/tests/admin/members-legacy/disable-commenting.test.ts index b9ef85d92e8..7543cff3b05 100644 --- a/e2e/tests/admin/members-legacy/disable-commenting.test.ts +++ b/e2e/tests/admin/members-legacy/disable-commenting.test.ts @@ -4,6 +4,8 @@ import {SettingsService} from '@/helpers/services/settings/settings-service'; import {expect, test} from '@/helpers/playwright'; test.describe('Ghost Admin - Member Detail Disable Commenting', () => { + test.use({labs: {membersForward: false}}); + let memberFactory: MemberFactory; test.beforeEach(async ({page}) => { diff --git a/e2e/tests/admin/members-legacy/export.test.ts b/e2e/tests/admin/members-legacy/export.test.ts index ed33b0518da..fbe60bccf3d 100644 --- a/e2e/tests/admin/members-legacy/export.test.ts +++ b/e2e/tests/admin/members-legacy/export.test.ts @@ -7,6 +7,8 @@ import {MembersPage} from '@/helpers/pages'; usePerTestIsolation(); test.describe('Ghost Admin - Member Export', () => { + test.use({labs: {membersForward: false}}); + let memberFactory: MemberFactory; function extractDownloadedContentSpecifics(content: string) { diff --git a/e2e/tests/admin/members-legacy/filter-actions.test.ts b/e2e/tests/admin/members-legacy/filter-actions.test.ts index f7ff1e15b3d..7d3e894c32a 100644 --- a/e2e/tests/admin/members-legacy/filter-actions.test.ts +++ b/e2e/tests/admin/members-legacy/filter-actions.test.ts @@ -4,6 +4,8 @@ import {MemberFactory, createMemberFactory} from '@/data-factory'; import {MembersPage} from '@/admin-pages'; test.describe('Ghost Admin - Member Filter Actions', () => { + test.use({labs: {membersForward: false}}); + let memberFactory: MemberFactory; const membersFixture = [ diff --git a/e2e/tests/admin/members-legacy/impersonation.test.ts b/e2e/tests/admin/members-legacy/impersonation.test.ts index 5adbdd1303e..936f25ac5a4 100644 --- a/e2e/tests/admin/members-legacy/impersonation.test.ts +++ b/e2e/tests/admin/members-legacy/impersonation.test.ts @@ -3,6 +3,8 @@ import {MemberFactory, createMemberFactory} from '@/data-factory'; import {expect, test} from '@/helpers/playwright'; test.describe('Ghost Admin - Member Impersonation', () => { + test.use({labs: {membersForward: false}}); + let memberFactory: MemberFactory; test.beforeEach(async ({page}) => { diff --git a/e2e/tests/admin/members-legacy/import.test.ts b/e2e/tests/admin/members-legacy/import.test.ts index 4fd81abd429..8b95d82f194 100644 --- a/e2e/tests/admin/members-legacy/import.test.ts +++ b/e2e/tests/admin/members-legacy/import.test.ts @@ -6,8 +6,11 @@ import {MembersImportModal, MembersPage} from '@/helpers/pages'; import {expect, test} from '@/helpers/playwright'; test.describe('Ghost Admin - Members Import', () => { + test.use({labs: {membersForward: false}}); + test('imports members from CSV via the UI', async ({page}) => { - const membersPage = new MembersPage(page, {route: 'members/import'}); + const importPage = new MembersPage(page, {route: 'members/import'}); + const membersPage = new MembersPage(page); const importModal = new MembersImportModal(page); const timestamp = Date.now(); @@ -26,7 +29,7 @@ test.describe('Ghost Admin - Members Import', () => { const csvPath = join(tmpdir(), `members-import-${timestamp}.csv`); writeFileSync(csvPath, csvContent); - await membersPage.goto(); + await importPage.goto(); await importModal.fileInput.setInputFiles(csvPath); @@ -42,7 +45,8 @@ test.describe('Ghost Admin - Members Import', () => { // Close the modal and reload to see the imported members in the list await importModal.closeButton.click(); - await membersPage.goto(); + + await expect(page).toHaveURL(/\/members$/); await expect(membersPage.getMemberByName('Alice Test')).toBeVisible({timeout: 30000}); await expect(membersPage.getMemberByName('Bob Test')).toBeVisible(); diff --git a/e2e/tests/admin/members-legacy/member-activity-events.test.ts b/e2e/tests/admin/members-legacy/member-activity-events.test.ts index f19463e0777..36efc309a68 100644 --- a/e2e/tests/admin/members-legacy/member-activity-events.test.ts +++ b/e2e/tests/admin/members-legacy/member-activity-events.test.ts @@ -19,6 +19,8 @@ async function waitForWelcomeEmailReceivedEvent(request: APIRequestContext, memb } test.describe('Ghost Admin - Member Activity Events', () => { + test.use({labs: {membersForward: false}}); + let emailClient: EmailClient; test.beforeEach(async () => { diff --git a/e2e/tests/admin/members-legacy/members.test.ts b/e2e/tests/admin/members-legacy/members.test.ts index 8b27cd7ce85..f5f1ee509bf 100644 --- a/e2e/tests/admin/members-legacy/members.test.ts +++ b/e2e/tests/admin/members-legacy/members.test.ts @@ -6,6 +6,8 @@ import {usePerTestIsolation} from '@/helpers/playwright/isolation'; usePerTestIsolation(); test.describe('Ghost Admin - Members', () => { + test.use({labs: {membersForward: false}}); + let memberFactory: MemberFactory; test.beforeEach(async ({page}) => { diff --git a/e2e/tests/admin/members-legacy/stripe-webhooks.test.ts b/e2e/tests/admin/members-legacy/stripe-webhooks.test.ts index 27a97f41085..af03539f13d 100644 --- a/e2e/tests/admin/members-legacy/stripe-webhooks.test.ts +++ b/e2e/tests/admin/members-legacy/stripe-webhooks.test.ts @@ -28,7 +28,7 @@ async function waitForPaymentEvent(request: APIRequestContext, memberId: string, } test.describe('Ghost Admin - Stripe Webhooks', () => { - test.use({stripeEnabled: true}); + test.use({labs: {membersForward: false}, stripeEnabled: true}); test('member created via webhooks - has paid status', async ({stripe, page}) => { const membersService = new MembersService(page.request); @@ -42,7 +42,7 @@ test.describe('Ghost Admin - Stripe Webhooks', () => { }); test.describe('Ghost Admin - Stripe Subscription Lifecycle', () => { - test.use({stripeEnabled: true}); + test.use({labs: {membersForward: false}, stripeEnabled: true}); test('subscription canceled at period end - member remains paid', async ({stripe, page}) => { const membersService = new MembersService(page.request); diff --git a/e2e/tests/admin/members/list.test.ts b/e2e/tests/admin/members/list.test.ts index 3a8a3f2b23a..1764a632660 100644 --- a/e2e/tests/admin/members/list.test.ts +++ b/e2e/tests/admin/members/list.test.ts @@ -40,6 +40,20 @@ test.describe('Ghost Admin - Members List', () => { await expect(membersPage.memberRows).toHaveCount(0); }); + test('preserves filters when redirecting from members-forward', async ({page}) => { + await memberFactory.createMany([ + {name: 'VIP Member', email: 'vip@example.com', labels: ['VIP']}, + {name: 'Regular Member', email: 'regular@example.com'} + ]); + + const membersPage = new MembersListPage(page); + await page.goto('/ghost/#/members-forward?filter=label:VIP'); + + await expect(page).toHaveURL(/\/members\?filter=label:VIP$/); + await expect(membersPage.memberRows).toHaveCount(1); + await expect(membersPage.getMemberByName('VIP Member')).toBeVisible(); + }); + test('navigates to member detail when clicking a row', async ({page}) => { const member = await memberFactory.create({ name: 'Detail Test Member', From c62d580e79da511461eb66a3ad2913e004da89d6 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Thu, 2 Apr 2026 13:17:59 +0200 Subject: [PATCH 08/29] Fixed members route regressions after rebase ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This restores the members import return context, fixes the React virtual-window test on the shared route, and addresses the remaining review comments pulled back in during the rebase. --- apps/admin/src/members-route-gate.test.tsx | 45 +++++++++++++++++++ apps/admin/src/members-route-gate.tsx | 3 +- .../virtual-table/virtual-list-window.test.ts | 18 ++++---- .../views/members/members-actions.test.tsx | 11 +++-- .../members-legacy/disable-commenting.test.ts | 2 + .../admin/members/tier-filter-search.test.ts | 6 +-- .../admin/members/virtual-window.test.ts | 7 +-- 7 files changed, 72 insertions(+), 20 deletions(-) create mode 100644 apps/admin/src/members-route-gate.test.tsx diff --git a/apps/admin/src/members-route-gate.test.tsx b/apps/admin/src/members-route-gate.test.tsx new file mode 100644 index 00000000000..d959d62ed9d --- /dev/null +++ b/apps/admin/src/members-route-gate.test.tsx @@ -0,0 +1,45 @@ +import {render, screen} from '@testing-library/react'; +import React from 'react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {MembersRouteGate} from './members-route-gate'; + +const {mockUseLocation, mockUseFeatureFlag} = vi.hoisted(() => ({ + mockUseLocation: vi.fn(), + mockUseFeatureFlag: vi.fn() +})); + +vi.mock('@tryghost/admin-x-framework', () => ({ + Outlet: () => React.createElement('div', {'data-testid': 'outlet'}), + useLocation: mockUseLocation +})); + +vi.mock('./ember-bridge', () => ({ + EmberFallback: () => React.createElement('div', {'data-testid': 'ember-fallback'}) +})); + +vi.mock('./hooks/use-feature-flag', () => ({ + useFeatureFlag: mockUseFeatureFlag +})); + +describe('MembersRouteGate', () => { + beforeEach(() => { + mockUseFeatureFlag.mockReturnValue(false); + mockUseLocation.mockReturnValue({pathname: '/members'}); + }); + + it('delegates /members/ to Ember when the flag is disabled', () => { + mockUseLocation.mockReturnValue({pathname: '/members/'}); + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + it('keeps /members/import on React even when the flag is disabled', () => { + mockUseLocation.mockReturnValue({pathname: '/members/import'}); + + render(); + + expect(screen.getByTestId('outlet')).toBeInTheDocument(); + }); +}); diff --git a/apps/admin/src/members-route-gate.tsx b/apps/admin/src/members-route-gate.tsx index ec315638488..51ebb1865c4 100644 --- a/apps/admin/src/members-route-gate.tsx +++ b/apps/admin/src/members-route-gate.tsx @@ -5,7 +5,8 @@ import {useFeatureFlag} from "./hooks/use-feature-flag"; export function MembersRouteGate() { const membersForwardEnabled = useFeatureFlag("membersForward"); const location = useLocation(); - const isMembersListRoute = location.pathname === "/members"; + const normalizedPath = location.pathname.replace(/\/+$/, "") || "/"; + const isMembersListRoute = normalizedPath === "/members"; if (!membersForwardEnabled && isMembersListRoute) { return ; diff --git a/apps/posts/src/components/virtual-table/virtual-list-window.test.ts b/apps/posts/src/components/virtual-table/virtual-list-window.test.ts index 408e70d2a65..83686703838 100644 --- a/apps/posts/src/components/virtual-table/virtual-list-window.test.ts +++ b/apps/posts/src/components/virtual-table/virtual-list-window.test.ts @@ -24,11 +24,11 @@ describe('virtual-list-window', () => { it('restores the unlocked window from the current history entry on remount', () => { window.history.replaceState({ ghostVirtualListWindow: { - '/members-forward::?filter=members': 2000 + '/members::?filter=members': 2000 } }, ''); - const wrapper = createWrapper('/members-forward?filter=members'); + const wrapper = createWrapper('/members?filter=members'); const {result, unmount} = renderHook(() => useVirtualListWindow(5000), {wrapper}); expect(result.current.visibleItemCount).toBe(2000); @@ -44,7 +44,7 @@ describe('virtual-list-window', () => { window.history.replaceState({}, ''); const {result} = renderHook(() => useVirtualListWindow(5000), { - wrapper: createWrapper('/members-forward?filter=members') + wrapper: createWrapper('/members?filter=members') }); act(() => { @@ -53,14 +53,14 @@ describe('virtual-list-window', () => { expect(window.history.state).toMatchObject({ ghostVirtualListWindow: { - '/members-forward::?filter=members': 2000 + '/members::?filter=members': 2000 } }); }); it('caps the visible window at 1,000 rows by default', () => { const {result} = renderHook(() => useVirtualListWindow(1500), { - wrapper: createWrapper('/members-forward?filter=members') + wrapper: createWrapper('/members?filter=members') }); expect(result.current).toMatchObject({ @@ -71,7 +71,7 @@ describe('virtual-list-window', () => { it('shows all items when the total is below the cap', () => { const {result} = renderHook(() => useVirtualListWindow(125), { - wrapper: createWrapper('/members-forward?filter=members') + wrapper: createWrapper('/members?filter=members') }); expect(result.current).toMatchObject({ @@ -82,7 +82,7 @@ describe('virtual-list-window', () => { it('unlocks the next 1,000 rows each time load more is requested', () => { const {result} = renderHook(() => useVirtualListWindow(5000), { - wrapper: createWrapper('/members-forward?filter=members') + wrapper: createWrapper('/members?filter=members') }); expect(result.current.visibleItemCount).toBe(1000); @@ -97,12 +97,12 @@ describe('virtual-list-window', () => { it('ignores invalid persisted unlocked counts', () => { window.history.replaceState({ ghostVirtualListWindow: { - '/members-forward::?filter=members': Number.NaN + '/members::?filter=members': Number.NaN } }, ''); const {result} = renderHook(() => useVirtualListWindow(5000), { - wrapper: createWrapper('/members-forward?filter=members') + wrapper: createWrapper('/members?filter=members') }); expect(result.current.visibleItemCount).toBe(1000); diff --git a/apps/posts/test/unit/views/members/members-actions.test.tsx b/apps/posts/test/unit/views/members/members-actions.test.tsx index b5affc7044e..af6a818ed17 100644 --- a/apps/posts/test/unit/views/members/members-actions.test.tsx +++ b/apps/posts/test/unit/views/members/members-actions.test.tsx @@ -47,7 +47,8 @@ describe('MembersActions', () => { beforeEach(() => { importModalPropsRef.current = null; mockUseLocation.mockReturnValue({ - pathname: '/members' + pathname: '/members', + search: '' }); mockUseNavigate.mockReturnValue(vi.fn()); }); @@ -74,7 +75,8 @@ describe('MembersActions', () => { it('navigates back to members when the import route modal closes', () => { const navigate = vi.fn(); mockUseLocation.mockReturnValue({ - pathname: '/members/import' + pathname: '/members/import', + search: '?filter=label%3AVIP&search=alice' }); mockUseNavigate.mockReturnValue(navigate); @@ -95,13 +97,14 @@ describe('MembersActions', () => { handleImportClose?.(); - expect(navigate).toHaveBeenCalledWith('/members'); + expect(navigate).toHaveBeenCalledWith('/members?filter=label%3AVIP&search=alice'); }); it('navigates to the imported label filter when the import route modal closes after a labeled import', () => { const navigate = vi.fn(); mockUseLocation.mockReturnValue({ - pathname: '/members/import' + pathname: '/members/import', + search: '?filter=label%3AVIP&search=alice' }); mockUseNavigate.mockReturnValue(navigate); diff --git a/e2e/tests/admin/members-legacy/disable-commenting.test.ts b/e2e/tests/admin/members-legacy/disable-commenting.test.ts index 7543cff3b05..6bff8279004 100644 --- a/e2e/tests/admin/members-legacy/disable-commenting.test.ts +++ b/e2e/tests/admin/members-legacy/disable-commenting.test.ts @@ -172,6 +172,8 @@ test.describe('Ghost Admin - Member Detail Disable Commenting', () => { }); test.describe('Ghost Admin - Disable Commenting Cache Invalidation', () => { + test.use({labs: {membersForward: false}}); + let memberFactory: MemberFactory; let postFactory: PostFactory; let commentFactory: CommentFactory; diff --git a/e2e/tests/admin/members/tier-filter-search.test.ts b/e2e/tests/admin/members/tier-filter-search.test.ts index cd85561f0ed..d8a9cc9a9cd 100644 --- a/e2e/tests/admin/members/tier-filter-search.test.ts +++ b/e2e/tests/admin/members/tier-filter-search.test.ts @@ -1,12 +1,12 @@ import {MemberFactory, TierFactory, createMemberFactory, createTierFactory} from '@/data-factory'; -import {MembersForwardPage} from '@/admin-pages'; +import {MembersListPage} from '@/admin-pages'; import {SettingsService} from '@/helpers/services/settings/settings-service'; import {expect, test} from '@/helpers/playwright'; import {usePerTestIsolation} from '@/helpers/playwright/isolation'; usePerTestIsolation(); -test.describe('Ghost Admin - Members Forward Tier Filter Search', () => { +test.describe('Ghost Admin - Members Tier Filter Search', () => { test.use({labs: {membersForward: true}}); let memberFactory: MemberFactory; @@ -30,7 +30,7 @@ test.describe('Ghost Admin - Members Forward Tier Filter Search', () => { {name: 'Free Member', email: 'free@example.com'} ]); - const membersPage = new MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await page.reload({waitUntil: 'load'}); await expect(membersPage.memberRows).toHaveCount(3); diff --git a/e2e/tests/admin/members/virtual-window.test.ts b/e2e/tests/admin/members/virtual-window.test.ts index cc24d141c88..c39167c041c 100644 --- a/e2e/tests/admin/members/virtual-window.test.ts +++ b/e2e/tests/admin/members/virtual-window.test.ts @@ -80,12 +80,13 @@ test.describe('Ghost Admin - Members Virtual Window', () => { return historyState?.ghostVirtualListWindow?.['/members::']; }).toBe(2000); + const reactMemberRows = page.getByTestId('members-list').getByTestId('members-list-item'); const maxRenderedIndex = await membersPage.scrollUntilMaxRenderedIndexAtLeast(1000); expect(maxRenderedIndex).toBeGreaterThan(1000); - const renderedCount = await membersPage.memberListItems.count(); - const targetRowLocator = membersPage.memberListItems.nth(Math.max(0, renderedCount - 5)); + const renderedCount = await reactMemberRows.count(); + const targetRowLocator = reactMemberRows.nth(Math.max(0, renderedCount - 5)); const targetRow = { index: Number(await targetRowLocator.getAttribute('data-index')), text: await targetRowLocator.textContent(), @@ -100,7 +101,7 @@ test.describe('Ghost Admin - Members Virtual Window', () => { await page.goBack(); await expect(page).toHaveURL(/\/ghost\/#\/members$/); - await expect(membersPage.getMemberListItemByIndex(targetRow.index)).toContainText(targetRow.text ?? ''); + await expect(page.getByTestId('members-list').locator(`[data-testid="members-list-item"][data-index="${targetRow.index}"]`)).toContainText(targetRow.text ?? ''); await expect.poll(async () => { const scrollTop = await membersPage.getScrollParentScrollTop(); From 473f2580d9df0c5a47d92c93cf0d53b97b86a544 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Thu, 2 Apr 2026 13:42:23 +0200 Subject: [PATCH 09/29] Fixed members e2e helper scoping after rebase ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This scopes the React members page object away from hidden Ember controls and updates the multiselect filter suite to the current helper names after rebasing onto main. --- .../pages/admin/members/members-list-page.ts | 20 ++++++++++--------- .../admin/members/multiselect-filters.test.ts | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 202e68e24b0..4be72d4a5b3 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -8,6 +8,7 @@ interface ExportedFile { } export class MembersListPage extends AdminPage { + readonly membersPage: Locator; readonly membersList: Locator; readonly memberRows: Locator; readonly searchInput: Locator; @@ -23,16 +24,17 @@ export class MembersListPage extends AdminPage { super(page); this.pageUrl = '/ghost/#/members'; - this.membersList = page.getByTestId('members-list'); + this.membersPage = page.getByTestId('members-page'); + this.membersList = this.membersPage.getByTestId('members-list'); this.memberRows = this.membersList.getByTestId('members-list-item'); - this.searchInput = page.getByTestId('members-search-input'); - this.actionsButton = page.getByTestId('members-actions'); - this.newMemberButton = page.getByRole('link', {name: 'New member'}); - this.filterButton = page.getByRole('button', {name: /^(Filter|Add filter)$/}); - this.clearFiltersButton = page.getByRole('button', {name: 'Clear'}); - this.emptyState = page.getByText('No members yet'); - this.noResults = page.getByText('No matching members found.'); - this.showAllButton = page.getByRole('button', {name: 'Show all members'}); + this.searchInput = this.membersPage.getByTestId('members-search-input'); + this.actionsButton = this.membersPage.getByTestId('members-actions'); + this.newMemberButton = this.membersPage.getByRole('link', {name: 'New member'}); + this.filterButton = this.membersPage.getByRole('button', {name: /^(Filter|Add filter)$/}); + this.clearFiltersButton = this.membersPage.getByRole('button', {name: 'Clear'}); + this.emptyState = this.membersPage.getByText('No members yet'); + this.noResults = this.membersPage.getByText('No matching members found.'); + this.showAllButton = this.membersPage.getByRole('button', {name: 'Show all members'}); } getMemberByName(name: string): Locator { diff --git a/e2e/tests/admin/members/multiselect-filters.test.ts b/e2e/tests/admin/members/multiselect-filters.test.ts index 83298e0bf72..23d675dcb5e 100644 --- a/e2e/tests/admin/members/multiselect-filters.test.ts +++ b/e2e/tests/admin/members/multiselect-filters.test.ts @@ -14,7 +14,7 @@ async function seedMembersAndNavigate( memberFactory: MemberFactory, page: Page, members: Partial[] -): Promise { +): Promise { await memberFactory.createMany(members); const membersPage = new MembersListPage(page); await membersPage.goto(); From 892eac624b6c9eb9a1403b16077f1c7d915b076e Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Thu, 2 Apr 2026 13:57:48 +0200 Subject: [PATCH 10/29] Fixed members-forward redirect assertion ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The app now canonicalizes the legacy label filter into the encoded React route form, so the redirect test needs to assert the normalized URL instead of the raw legacy query string. --- e2e/tests/admin/members/list.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/tests/admin/members/list.test.ts b/e2e/tests/admin/members/list.test.ts index 1764a632660..7a2e500cc0d 100644 --- a/e2e/tests/admin/members/list.test.ts +++ b/e2e/tests/admin/members/list.test.ts @@ -49,7 +49,7 @@ test.describe('Ghost Admin - Members List', () => { const membersPage = new MembersListPage(page); await page.goto('/ghost/#/members-forward?filter=label:VIP'); - await expect(page).toHaveURL(/\/members\?filter=label:VIP$/); + await expect(page).toHaveURL(/\/members\?filter=label%3A%5BVIP%5D$/); await expect(membersPage.memberRows).toHaveCount(1); await expect(membersPage.getMemberByName('VIP Member')).toBeVisible(); }); From a02a97b3dc36c60868a4888a9ebe65533baa7f22 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Thu, 2 Apr 2026 14:43:14 +0200 Subject: [PATCH 11/29] Fixed members legacy e2e failures ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release --- .../members-legacy/filter-actions.test.ts | 3 ++ e2e/tests/admin/members-legacy/import.test.ts | 41 ++++++++++++------- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/e2e/tests/admin/members-legacy/filter-actions.test.ts b/e2e/tests/admin/members-legacy/filter-actions.test.ts index 7d3e894c32a..d4bd4875734 100644 --- a/e2e/tests/admin/members-legacy/filter-actions.test.ts +++ b/e2e/tests/admin/members-legacy/filter-actions.test.ts @@ -1,8 +1,11 @@ import {expect, test} from '@/helpers/playwright'; +import {usePerTestIsolation} from '@/helpers/playwright/isolation'; import {MemberFactory, createMemberFactory} from '@/data-factory'; import {MembersPage} from '@/admin-pages'; +usePerTestIsolation(); + test.describe('Ghost Admin - Member Filter Actions', () => { test.use({labs: {membersForward: false}}); diff --git a/e2e/tests/admin/members-legacy/import.test.ts b/e2e/tests/admin/members-legacy/import.test.ts index 8b95d82f194..1cd3231b21c 100644 --- a/e2e/tests/admin/members-legacy/import.test.ts +++ b/e2e/tests/admin/members-legacy/import.test.ts @@ -2,16 +2,14 @@ import {join} from 'path'; import {tmpdir} from 'os'; import {writeFileSync} from 'fs'; -import {MembersImportModal, MembersPage} from '@/helpers/pages'; +import {MembersListPage} from '@/admin-pages'; import {expect, test} from '@/helpers/playwright'; test.describe('Ghost Admin - Members Import', () => { - test.use({labs: {membersForward: false}}); + test.use({labs: {membersForward: true}}); test('imports members from CSV via the UI', async ({page}) => { - const importPage = new MembersPage(page, {route: 'members/import'}); - const membersPage = new MembersPage(page); - const importModal = new MembersImportModal(page); + const membersPage = new MembersListPage(page); const timestamp = Date.now(); const emails = [ @@ -29,24 +27,37 @@ test.describe('Ghost Admin - Members Import', () => { const csvPath = join(tmpdir(), `members-import-${timestamp}.csv`); writeFileSync(csvPath, csvContent); - await importPage.goto(); + await membersPage.goto(); + await membersPage.openActionsMenu(); + await membersPage.getMenuItem('Import members').click(); - await importModal.fileInput.setInputFiles(csvPath); + const importDialog = page.getByRole('dialog', {name: 'Import members'}); + const fileInput = importDialog.locator('input[type="file"]').first(); + const importButton = importDialog.getByRole('button', {name: /import \d+ members?/i}); + const importHeading = page.getByRole('heading', {name: /import (in progress|complete)/i}); + const closeButton = page.getByRole('button', {name: /got it|view members/i}); + const getMappingValue = (fieldName: string) => { + return importDialog.getByRole('row', { + name: new RegExp(`^${fieldName}\\b`, 'i') + }).getByRole('combobox'); + }; + + await fileInput.setInputFiles(csvPath); // Verify all three fields were auto-detected - await expect(importModal.importButton).toBeVisible(); - await expect(importModal.getMappingValue('email')).toHaveText('Email'); - await expect(importModal.getMappingValue('name')).toHaveText('Name'); - await expect(importModal.getMappingValue('note')).toHaveText('Note'); + await expect(importButton).toBeVisible(); + await expect(getMappingValue('email')).toHaveText('Email'); + await expect(getMappingValue('name')).toHaveText('Name'); + await expect(getMappingValue('note')).toHaveText('Note'); - await importModal.importButton.click(); + await importButton.click(); - await expect(importModal.importHeading).toBeVisible({timeout: 15000}); + await expect(importHeading).toBeVisible({timeout: 15000}); // Close the modal and reload to see the imported members in the list - await importModal.closeButton.click(); + await closeButton.click(); - await expect(page).toHaveURL(/\/members$/); + await expect(page).toHaveURL(/\/members(\?.*)?$/); await expect(membersPage.getMemberByName('Alice Test')).toBeVisible({timeout: 30000}); await expect(membersPage.getMemberByName('Bob Test')).toBeVisible(); From 866378d8afb1992970910cc7379e895938176467 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Thu, 2 Apr 2026 15:20:43 +0200 Subject: [PATCH 12/29] Prevented dual members import modals ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release Gate /members/import with the same membersForward split as the rest of the members surface, and have Ember opt out to react-fallback when React owns that route so only one import modal can mount. --- apps/admin/src/members-route-gate.test.tsx | 11 ++++- apps/admin/src/members-route-gate.tsx | 7 +--- .../admin/members/members-import-modal.ts | 8 ++-- e2e/tests/admin/members-legacy/import.test.ts | 41 +++++++------------ ghost/admin/app/routes/members/import.js | 13 +++++- ghost/admin/app/services/feature.js | 1 + .../tests/acceptance/members/import-test.js | 14 ++++++- 7 files changed, 56 insertions(+), 39 deletions(-) diff --git a/apps/admin/src/members-route-gate.test.tsx b/apps/admin/src/members-route-gate.test.tsx index d959d62ed9d..17b7fcdeda4 100644 --- a/apps/admin/src/members-route-gate.test.tsx +++ b/apps/admin/src/members-route-gate.test.tsx @@ -35,7 +35,16 @@ describe('MembersRouteGate', () => { expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); }); - it('keeps /members/import on React even when the flag is disabled', () => { + it('delegates /members/import to Ember when the flag is disabled', () => { + mockUseLocation.mockReturnValue({pathname: '/members/import'}); + + render(); + + expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); + }); + + it('renders React routes when the flag is enabled', () => { + mockUseFeatureFlag.mockReturnValue(true); mockUseLocation.mockReturnValue({pathname: '/members/import'}); render(); diff --git a/apps/admin/src/members-route-gate.tsx b/apps/admin/src/members-route-gate.tsx index 51ebb1865c4..ee1ecd56a1b 100644 --- a/apps/admin/src/members-route-gate.tsx +++ b/apps/admin/src/members-route-gate.tsx @@ -1,14 +1,11 @@ -import {Outlet, useLocation} from "@tryghost/admin-x-framework"; +import {Outlet} from "@tryghost/admin-x-framework"; import {EmberFallback} from "./ember-bridge"; import {useFeatureFlag} from "./hooks/use-feature-flag"; export function MembersRouteGate() { const membersForwardEnabled = useFeatureFlag("membersForward"); - const location = useLocation(); - const normalizedPath = location.pathname.replace(/\/+$/, "") || "/"; - const isMembersListRoute = normalizedPath === "/members"; - if (!membersForwardEnabled && isMembersListRoute) { + if (!membersForwardEnabled) { return ; } diff --git a/e2e/helpers/pages/admin/members/members-import-modal.ts b/e2e/helpers/pages/admin/members/members-import-modal.ts index e7f19f6c829..a2dbd9a30ff 100644 --- a/e2e/helpers/pages/admin/members/members-import-modal.ts +++ b/e2e/helpers/pages/admin/members/members-import-modal.ts @@ -10,10 +10,10 @@ export class MembersImportModal { constructor(page: Page) { this.page = page; - this.fileInput = page.locator('input[type="file"]'); - this.importButton = page.getByRole('button', {name: /import \d+ members?/i}); - this.importHeading = page.getByRole('heading', {name: /import (in progress|complete)/i}); - this.closeButton = page.getByRole('button', {name: /got it|view members/i}); + this.fileInput = page.locator('[data-test-fileinput="members-csv"] input[type="file"]').first(); + this.importButton = page.locator('[data-test-button="perform-import"]'); + this.importHeading = page.locator('[data-test-modal="import-members"]').getByRole('heading', {name: /import (in progress|complete)/i}); + this.closeButton = page.locator('[data-test-button="close-import-members"]'); } getMappingRow(fieldName: string): Locator { diff --git a/e2e/tests/admin/members-legacy/import.test.ts b/e2e/tests/admin/members-legacy/import.test.ts index 1cd3231b21c..8b95d82f194 100644 --- a/e2e/tests/admin/members-legacy/import.test.ts +++ b/e2e/tests/admin/members-legacy/import.test.ts @@ -2,14 +2,16 @@ import {join} from 'path'; import {tmpdir} from 'os'; import {writeFileSync} from 'fs'; -import {MembersListPage} from '@/admin-pages'; +import {MembersImportModal, MembersPage} from '@/helpers/pages'; import {expect, test} from '@/helpers/playwright'; test.describe('Ghost Admin - Members Import', () => { - test.use({labs: {membersForward: true}}); + test.use({labs: {membersForward: false}}); test('imports members from CSV via the UI', async ({page}) => { - const membersPage = new MembersListPage(page); + const importPage = new MembersPage(page, {route: 'members/import'}); + const membersPage = new MembersPage(page); + const importModal = new MembersImportModal(page); const timestamp = Date.now(); const emails = [ @@ -27,37 +29,24 @@ test.describe('Ghost Admin - Members Import', () => { const csvPath = join(tmpdir(), `members-import-${timestamp}.csv`); writeFileSync(csvPath, csvContent); - await membersPage.goto(); - await membersPage.openActionsMenu(); - await membersPage.getMenuItem('Import members').click(); + await importPage.goto(); - const importDialog = page.getByRole('dialog', {name: 'Import members'}); - const fileInput = importDialog.locator('input[type="file"]').first(); - const importButton = importDialog.getByRole('button', {name: /import \d+ members?/i}); - const importHeading = page.getByRole('heading', {name: /import (in progress|complete)/i}); - const closeButton = page.getByRole('button', {name: /got it|view members/i}); - const getMappingValue = (fieldName: string) => { - return importDialog.getByRole('row', { - name: new RegExp(`^${fieldName}\\b`, 'i') - }).getByRole('combobox'); - }; - - await fileInput.setInputFiles(csvPath); + await importModal.fileInput.setInputFiles(csvPath); // Verify all three fields were auto-detected - await expect(importButton).toBeVisible(); - await expect(getMappingValue('email')).toHaveText('Email'); - await expect(getMappingValue('name')).toHaveText('Name'); - await expect(getMappingValue('note')).toHaveText('Note'); + await expect(importModal.importButton).toBeVisible(); + await expect(importModal.getMappingValue('email')).toHaveText('Email'); + await expect(importModal.getMappingValue('name')).toHaveText('Name'); + await expect(importModal.getMappingValue('note')).toHaveText('Note'); - await importButton.click(); + await importModal.importButton.click(); - await expect(importHeading).toBeVisible({timeout: 15000}); + await expect(importModal.importHeading).toBeVisible({timeout: 15000}); // Close the modal and reload to see the imported members in the list - await closeButton.click(); + await importModal.closeButton.click(); - await expect(page).toHaveURL(/\/members(\?.*)?$/); + await expect(page).toHaveURL(/\/members$/); await expect(membersPage.getMemberByName('Alice Test')).toBeVisible({timeout: 30000}); await expect(membersPage.getMemberByName('Bob Test')).toBeVisible(); diff --git a/ghost/admin/app/routes/members/import.js b/ghost/admin/app/routes/members/import.js index f0455c18fac..0b5f1a05e43 100644 --- a/ghost/admin/app/routes/members/import.js +++ b/ghost/admin/app/routes/members/import.js @@ -1,3 +1,14 @@ import MembersManagementRoute from '../members-management'; +import {inject as service} from '@ember/service'; -export default class MembersImportRoute extends MembersManagementRoute {} +export default class MembersImportRoute extends MembersManagementRoute { + @service feature; + + beforeModel() { + super.beforeModel(...arguments); + + if (this.feature.membersForward) { + return this.replaceWith('react-fallback', 'members/import'); + } + } +} diff --git a/ghost/admin/app/services/feature.js b/ghost/admin/app/services/feature.js index 02d8dbf93b1..3a31ff56914 100644 --- a/ghost/admin/app/services/feature.js +++ b/ghost/admin/app/services/feature.js @@ -67,6 +67,7 @@ export default class FeatureService extends Service { @feature('editorExcerpt') editorExcerpt; @feature('transistor') transistor; @feature('tagsX') tagsX; + @feature('membersForward') membersForward; @feature('commentModeration') commentModeration; _user = null; diff --git a/ghost/admin/tests/acceptance/members/import-test.js b/ghost/admin/tests/acceptance/members/import-test.js index 1e11fcd6e2e..ed73fe1962a 100644 --- a/ghost/admin/tests/acceptance/members/import-test.js +++ b/ghost/admin/tests/acceptance/members/import-test.js @@ -1,6 +1,7 @@ import {Response} from 'miragejs'; import {authenticateSession} from 'ember-simple-auth/test-support'; -import {click, currentURL, find, findAll} from '@ember/test-helpers'; +import {click, currentRouteName, currentURL, find, findAll} from '@ember/test-helpers'; +import {enableLabsFlag} from '../../helpers/labs-flag'; import {expect} from 'chai'; import {fileUpload} from '../../helpers/file-upload'; import {setupApplicationTest} from 'ember-mocha'; @@ -110,6 +111,15 @@ testemail@example.com,Test Email,This is a test template for importing your memb expect(apiLabels).to.equal(label1.name); }); + + it('opts out of the Ember import route when membersForward is enabled', async function () { + enableLabsFlag(this.server, 'membersForward'); + + await visit('/members/import'); + + expect(currentRouteName()).to.equal('react-fallback'); + expect(find('[data-test-modal="import-members"]'), 'members import modal').to.not.exist; + }); }); describe ('super editors functions', function () { beforeEach(async function () { @@ -226,4 +236,4 @@ testemail@example.com,Test Email,This is a test template for importing your memb expect(currentURL()).to.equal('/site'); }); }); -}); \ No newline at end of file +}); From 44321fae3e3d4b6b8a97c7114e85b8411f379d18 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 08:43:55 +0200 Subject: [PATCH 13/29] Fixed shade imports after rebase ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The rebase conflict resolution pulled top-level shade imports into apps/posts, which broke lint and the posts build in CI. This restores the layered components/utils imports expected by the package. --- .../bulk-action-modals/import-members-modal.tsx | 3 ++- .../src/views/members/components/members-actions.tsx | 11 ++--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/apps/posts/src/views/members/components/bulk-action-modals/import-members-modal.tsx b/apps/posts/src/views/members/components/bulk-action-modals/import-members-modal.tsx index 05b0d11db7c..ef57ec69b15 100644 --- a/apps/posts/src/views/members/components/bulk-action-modals/import-members-modal.tsx +++ b/apps/posts/src/views/members/components/bulk-action-modals/import-members-modal.tsx @@ -1,8 +1,9 @@ import {CompleteStep, ErrorStep, InitStep, MappingStep, ProcessingStep} from './import-members/components'; -import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, cn} from '@tryghost/shade'; +import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@tryghost/shade/components'; import {type ImportResponse} from './import-members/state'; import {MembersFieldMapping, detectFieldTypes} from './import-members/mapping'; import {buildImportResponse} from './import-members/upload'; +import {cn} from '@tryghost/shade/utils'; import {createInitialImportState, importReducer} from './import-members/reducer'; import {getGhostPaths} from '@tryghost/admin-x-framework/helpers'; import {parseCSV} from './import-members/csv'; diff --git a/apps/posts/src/views/members/components/members-actions.tsx b/apps/posts/src/views/members/components/members-actions.tsx index 7c13f105bd0..81add3bdec5 100644 --- a/apps/posts/src/views/members/components/members-actions.tsx +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -1,15 +1,8 @@ import React, {useCallback, useState} from 'react'; import {AddLabelModal, DeleteModal, ImportMembersModal, RemoveLabelModal, UnsubscribeModal} from './bulk-action-modals'; -import { - Button, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, - LucideIcon -} from '@tryghost/shade'; +import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger} from '@tryghost/shade/components'; import {type ImportResponse} from './bulk-action-modals/import-members/state'; +import {LucideIcon} from '@tryghost/shade/utils'; import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; import {buildMemberOperationParams} from '../member-query-params'; import {buildMembersUrl} from '../member-route'; From 5723454816fd9f31ae24568f94dccb55e6e147d7 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 09:04:46 +0200 Subject: [PATCH 14/29] Added members route access control ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This restores the legacy canManageMembers redirect at the React /members route boundary while keeping MembersRouteGate focused on feature-flag routing only. --- apps/admin/src/members-route.test.tsx | 82 +++++++++++++++++++++++++++ apps/admin/src/members-route.tsx | 22 +++++++ apps/admin/src/routes.tsx | 4 +- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 apps/admin/src/members-route.test.tsx create mode 100644 apps/admin/src/members-route.tsx diff --git a/apps/admin/src/members-route.test.tsx b/apps/admin/src/members-route.test.tsx new file mode 100644 index 00000000000..d58855aa622 --- /dev/null +++ b/apps/admin/src/members-route.test.tsx @@ -0,0 +1,82 @@ +import {render, screen} from '@testing-library/react'; +import React from 'react'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {MembersRoute} from './members-route'; + +const {mockCanManageMembers, mockUseCurrentUser} = vi.hoisted(() => ({ + mockCanManageMembers: vi.fn(), + mockUseCurrentUser: vi.fn() +})); + +vi.mock('@tryghost/admin-x-framework', () => ({ + Navigate: ({replace, to}: {replace?: boolean; to: string}) => React.createElement('div', { + 'data-replace': String(Boolean(replace)), + 'data-testid': 'navigate', + 'data-to': to + }) +})); + +vi.mock('@tryghost/admin-x-framework/api/current-user', () => ({ + useCurrentUser: mockUseCurrentUser +})); + +vi.mock('@tryghost/admin-x-framework/api/users', () => ({ + canManageMembers: mockCanManageMembers +})); + +vi.mock('./members-route-gate', () => ({ + MembersRouteGate: () => React.createElement('div', {'data-testid': 'members-route-gate'}) +})); + +describe('MembersRoute', () => { + beforeEach(() => { + mockCanManageMembers.mockReturnValue(true); + mockUseCurrentUser.mockReturnValue({ + data: { + id: '1', + roles: [{name: 'Administrator'}] + }, + isError: false, + isLoading: false + }); + }); + + it('renders the members route gate for authorized users', () => { + render(); + + expect(screen.getByTestId('members-route-gate')).toBeInTheDocument(); + }); + + it('redirects users without member permissions to home', () => { + mockCanManageMembers.mockReturnValue(false); + + render(); + + expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/'); + expect(screen.getByTestId('navigate')).toHaveAttribute('data-replace', 'true'); + }); + + it('renders nothing while the current user is still loading', () => { + mockUseCurrentUser.mockReturnValue({ + data: undefined, + isError: false, + isLoading: true + }); + + const {container} = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('redirects to home when the current user is unavailable after loading', () => { + mockUseCurrentUser.mockReturnValue({ + data: undefined, + isError: false, + isLoading: false + }); + + render(); + + expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/'); + }); +}); diff --git a/apps/admin/src/members-route.tsx b/apps/admin/src/members-route.tsx new file mode 100644 index 00000000000..bc01a046359 --- /dev/null +++ b/apps/admin/src/members-route.tsx @@ -0,0 +1,22 @@ +import {Navigate} from "@tryghost/admin-x-framework"; +import {useCurrentUser} from "@tryghost/admin-x-framework/api/current-user"; +import {canManageMembers} from "@tryghost/admin-x-framework/api/users"; +import {MembersRouteGate} from "./members-route-gate"; + +export function MembersRoute() { + const {data: currentUser, isError, isLoading} = useCurrentUser(); + + if (!currentUser) { + if (isError || !isLoading) { + return ; + } + + return null; + } + + if (!canManageMembers(currentUser)) { + return ; + } + + return ; +} diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 1745304561a..86992ee5347 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -15,7 +15,7 @@ import MyProfileRedirect from "./my-profile-redirect"; // Ember import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge"; import type { RouteHandle } from "./ember-bridge"; -import { MembersRouteGate } from "./members-route-gate"; +import { MembersRoute } from "./members-route"; import { NotFound } from "./not-found"; @@ -58,7 +58,7 @@ const emberFallbackRoutes: RouteObject[] = EMBER_ROUTES.map(path => ({ const membersRoute: RouteObject = { path: "/members", - element: , + element: , handle: emberFallbackHandle, children: [ { From b075b69ca14e6a8ea87cfe0577bb33dbb989d07a Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 09:51:29 +0200 Subject: [PATCH 15/29] Fixed legacy import mapping assertions ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The legacy import e2e test was asserting select text, which is unstable in CI because the native select exposes the full option list text. Assert the selected mapping values instead. --- e2e/tests/admin/members-legacy/import.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/tests/admin/members-legacy/import.test.ts b/e2e/tests/admin/members-legacy/import.test.ts index 8b95d82f194..1745556b3f9 100644 --- a/e2e/tests/admin/members-legacy/import.test.ts +++ b/e2e/tests/admin/members-legacy/import.test.ts @@ -35,9 +35,9 @@ test.describe('Ghost Admin - Members Import', () => { // Verify all three fields were auto-detected await expect(importModal.importButton).toBeVisible(); - await expect(importModal.getMappingValue('email')).toHaveText('Email'); - await expect(importModal.getMappingValue('name')).toHaveText('Name'); - await expect(importModal.getMappingValue('note')).toHaveText('Note'); + await expect(importModal.getMappingValue('email')).toHaveValue('email'); + await expect(importModal.getMappingValue('name')).toHaveValue('name'); + await expect(importModal.getMappingValue('note')).toHaveValue('note'); await importModal.importButton.click(); From 996f9a6f5bb0aa65060e68f7f8642ec4a4cb4c10 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 10:39:00 +0200 Subject: [PATCH 16/29] Fixed legacy import redirect assertion ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The legacy import flow now returns to the members list filtered by the generated import label. Update the e2e assertion to accept the members route with query params and verify the import label filter is applied. --- e2e/tests/admin/members-legacy/import.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/tests/admin/members-legacy/import.test.ts b/e2e/tests/admin/members-legacy/import.test.ts index 1745556b3f9..3a7dfa76567 100644 --- a/e2e/tests/admin/members-legacy/import.test.ts +++ b/e2e/tests/admin/members-legacy/import.test.ts @@ -46,7 +46,8 @@ test.describe('Ghost Admin - Members Import', () => { // Close the modal and reload to see the imported members in the list await importModal.closeButton.click(); - await expect(page).toHaveURL(/\/members$/); + await expect(page).toHaveURL(/\/members(\?.*)?$/); + await expect.poll(() => new URL(page.url()).searchParams.get('filter')).toMatch(/^label:\[import-/); await expect(membersPage.getMemberByName('Alice Test')).toBeVisible({timeout: 30000}); await expect(membersPage.getMemberByName('Bob Test')).toBeVisible(); From 108437247fb98048ce685ab5169a0679f4a54620 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 11:12:39 +0200 Subject: [PATCH 17/29] Fixed legacy import hash-route assertion ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The members import flow stores its filter in the hash route, so URL.searchParams does not expose it. Match the full hash URL instead of parsing top-level search params in the legacy import e2e test. --- e2e/tests/admin/members-legacy/import.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/tests/admin/members-legacy/import.test.ts b/e2e/tests/admin/members-legacy/import.test.ts index 3a7dfa76567..40f66098178 100644 --- a/e2e/tests/admin/members-legacy/import.test.ts +++ b/e2e/tests/admin/members-legacy/import.test.ts @@ -46,8 +46,7 @@ test.describe('Ghost Admin - Members Import', () => { // Close the modal and reload to see the imported members in the list await importModal.closeButton.click(); - await expect(page).toHaveURL(/\/members(\?.*)?$/); - await expect.poll(() => new URL(page.url()).searchParams.get('filter')).toMatch(/^label:\[import-/); + await expect(page).toHaveURL(/#\/members\?filter=label%3A%5Bimport-/); await expect(membersPage.getMemberByName('Alice Test')).toBeVisible({timeout: 30000}); await expect(membersPage.getMemberByName('Bob Test')).toBeVisible(); From c708e8508421d5dba8b00edf546aa915f82bbb0e Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 16:02:44 +0200 Subject: [PATCH 18/29] Fixed members import route handoff ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release Preserve query params and permission handling when Ember hands members import off to React, and replace history when closing the route-backed import modal. --- .../members/components/members-actions.tsx | 4 ++-- .../unit/views/members/members-actions.test.tsx | 4 ++-- ghost/admin/app/routes/members/import.js | 14 +++++++++++--- .../tests/acceptance/members/import-test.js | 17 +++++++++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/posts/src/views/members/components/members-actions.tsx b/apps/posts/src/views/members/components/members-actions.tsx index 81add3bdec5..fb61f184a2a 100644 --- a/apps/posts/src/views/members/components/members-actions.tsx +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -199,11 +199,11 @@ const MembersActions: React.FC = ({ if (importResponse?.importLabel) { navigate(buildMembersUrl({ filter: `label:[${importResponse.importLabel.slug}]` - })); + }), {replace: true}); return; } - navigate(`/members${currentSearch}`); + navigate(`/members${currentSearch}`, {replace: true}); }, [currentSearch, navigate]); return ( diff --git a/apps/posts/test/unit/views/members/members-actions.test.tsx b/apps/posts/test/unit/views/members/members-actions.test.tsx index af6a818ed17..9748c631b86 100644 --- a/apps/posts/test/unit/views/members/members-actions.test.tsx +++ b/apps/posts/test/unit/views/members/members-actions.test.tsx @@ -97,7 +97,7 @@ describe('MembersActions', () => { handleImportClose?.(); - expect(navigate).toHaveBeenCalledWith('/members?filter=label%3AVIP&search=alice'); + expect(navigate).toHaveBeenCalledWith('/members?filter=label%3AVIP&search=alice', {replace: true}); }); it('navigates to the imported label filter when the import route modal closes after a labeled import', () => { @@ -122,6 +122,6 @@ describe('MembersActions', () => { handleImportClose?.({ importLabel: {slug: 'import-2026-03-17'} }); - expect(navigate).toHaveBeenCalledWith('/members?filter=label%3A%5Bimport-2026-03-17%5D'); + expect(navigate).toHaveBeenCalledWith('/members?filter=label%3A%5Bimport-2026-03-17%5D', {replace: true}); }); }); diff --git a/ghost/admin/app/routes/members/import.js b/ghost/admin/app/routes/members/import.js index 0b5f1a05e43..95064398ebc 100644 --- a/ghost/admin/app/routes/members/import.js +++ b/ghost/admin/app/routes/members/import.js @@ -3,12 +3,20 @@ import {inject as service} from '@ember/service'; export default class MembersImportRoute extends MembersManagementRoute { @service feature; + @service router; - beforeModel() { - super.beforeModel(...arguments); + beforeModel(transition) { + const nextTransition = super.beforeModel(...arguments); + + if (nextTransition) { + return nextTransition; + } if (this.feature.membersForward) { - return this.replaceWith('react-fallback', 'members/import'); + const queryString = new URLSearchParams(transition?.to?.queryParams || {}).toString(); + const path = queryString ? `/members/import?${queryString}` : '/members/import'; + + return this.router.replaceWith(path); } } } diff --git a/ghost/admin/tests/acceptance/members/import-test.js b/ghost/admin/tests/acceptance/members/import-test.js index ed73fe1962a..1252b8699af 100644 --- a/ghost/admin/tests/acceptance/members/import-test.js +++ b/ghost/admin/tests/acceptance/members/import-test.js @@ -120,6 +120,15 @@ testemail@example.com,Test Email,This is a test template for importing your memb expect(currentRouteName()).to.equal('react-fallback'); expect(find('[data-test-modal="import-members"]'), 'members import modal').to.not.exist; }); + + it('preserves query params when membersForward is enabled', async function () { + enableLabsFlag(this.server, 'membersForward'); + + await visit('/members/import?filter=label%3AVIP&search=alice'); + + expect(currentRouteName()).to.equal('react-fallback'); + expect(currentURL()).to.equal('/members/import?filter=label%3AVIP&search=alice'); + }); }); describe ('super editors functions', function () { beforeEach(async function () { @@ -235,5 +244,13 @@ testemail@example.com,Test Email,This is a test template for importing your memb expect(currentURL()).to.equal('/site'); }); + + it('Editor cannot access members import when membersForward is enabled', async function () { + enableLabsFlag(this.server, 'membersForward'); + + await visit('/members/import?filter=label%3AVIP&search=alice'); + + expect(currentURL()).to.equal('/site'); + }); }); }); From 9f9400b837e1767b282ab027209dbeaf834ce834 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 17:33:13 +0200 Subject: [PATCH 19/29] Fixed members saved views e2e selectors ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release Scopes the saved views test to the visible React members controls so it does not click the hidden Ember filter button during the membersForward rollout. --- e2e/tests/admin/members/saved-views.test.ts | 36 ++++++++++----------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/e2e/tests/admin/members/saved-views.test.ts b/e2e/tests/admin/members/saved-views.test.ts index 986813cebbf..0c9f3d5f017 100644 --- a/e2e/tests/admin/members/saved-views.test.ts +++ b/e2e/tests/admin/members/saved-views.test.ts @@ -1,43 +1,42 @@ import {MemberFactory, createMemberFactory} from '@/data-factory'; -import {SidebarPage} from '@/admin-pages'; +import {MembersListPage, SidebarPage} from '@/admin-pages'; import {expect, test} from '@/helpers/playwright/fixture'; -import type {Page} from '@playwright/test'; function escapeNqlString(value: string): string { return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`; } -async function addFilter(page: Page, filterName: 'Name' | 'Email' | 'Label', value: string) { +async function addFilter(membersPage: MembersListPage, filterName: 'Name' | 'Email' | 'Label', value: string) { if (filterName === 'Label') { - const url = new URL(page.url()); + const url = new URL(membersPage.page.url()); const params = new URLSearchParams(url.search); const labelFilter = `label:${escapeNqlString(value)}`; const existingFilter = params.get('filter'); params.set('filter', existingFilter ? `${existingFilter}+${labelFilter}` : labelFilter); - await page.goto(`/ghost/#/members?${params.toString()}`); + await membersPage.page.goto(`/ghost/#/members?${params.toString()}`); return; } - await page.getByRole('button', {name: /^(Filter|Add filter)$/}).click(); - await page.getByRole('option', {name: filterName, exact: true}).click(); + await membersPage.filterButton.click(); + await membersPage.page.getByRole('option', {name: filterName, exact: true}).click(); if (filterName === 'Name') { - await page.getByRole('textbox', {name: 'Enter name...'}).fill(value); + await membersPage.page.getByRole('textbox', {name: 'Enter name...'}).fill(value); return; } if (filterName === 'Email') { - await page.getByRole('textbox', {name: 'Enter email...'}).fill(value); + await membersPage.page.getByRole('textbox', {name: 'Enter email...'}).fill(value); return; } - await page.getByRole('option', {name: value, exact: true}).click(); + await membersPage.page.getByRole('option', {name: value, exact: true}).click(); } -async function saveCurrentView(page: Page, name: string) { - await page.getByRole('button', {name: 'Save view'}).click(); - const dialog = page.getByRole('dialog'); +async function saveCurrentView(membersPage: MembersListPage, name: string) { + await membersPage.membersPage.getByRole('button', {name: 'Save view'}).click(); + const dialog = membersPage.page.getByRole('dialog'); await dialog.waitFor({state: 'visible'}); await dialog.getByRole('textbox', {name: 'View name'}).fill(name); await dialog.getByRole('button', {name: 'Save'}).click(); @@ -63,20 +62,21 @@ test.describe('Ghost Admin - Members Saved Views', () => { }); const sidebar = new SidebarPage(page); + const membersPage = new MembersListPage(page); await page.goto('/ghost/#/members'); - await addFilter(page, 'Name', 'active-nav'); - await saveCurrentView(page, 'View A'); + await addFilter(membersPage, 'Name', 'active-nav'); + await saveCurrentView(membersPage, 'View A'); await expect(sidebar.getNavLink('View A')).toHaveAttribute('aria-current', 'page'); - await addFilter(page, 'Email', 'example.com'); - await saveCurrentView(page, 'View B'); + await addFilter(membersPage, 'Email', 'example.com'); + await saveCurrentView(membersPage, 'View B'); await expect(sidebar.getNavLink('View B')).toHaveAttribute('aria-current', 'page'); await expect(sidebar.getNavLink('View A')).not.toHaveAttribute('aria-current', 'page'); - await addFilter(page, 'Label', 'Active Nav Label'); + await addFilter(membersPage, 'Label', 'Active Nav Label'); await expect(sidebar.getNavLink('View A')).not.toHaveAttribute('aria-current', 'page'); await expect(sidebar.getNavLink('View B')).not.toHaveAttribute('aria-current', 'page'); From 90aea9111aafc93960c16336583a73f4ce9e5f39 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 21:04:27 +0200 Subject: [PATCH 20/29] Simplified members sidebar active state ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This drops the members-specific helper layer and aligns the parent/child highlight behavior with the existing collapsible sidebar pattern while keeping React and Ember members routes distinct. --- .../app-sidebar/nav-content.helpers.test.ts | 44 ------------------- .../layout/app-sidebar/nav-content.helpers.ts | 27 ------------ .../src/layout/app-sidebar/nav-content.tsx | 19 ++++---- 3 files changed, 8 insertions(+), 82 deletions(-) delete mode 100644 apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts delete mode 100644 apps/admin/src/layout/app-sidebar/nav-content.helpers.ts diff --git a/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts b/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts deleted file mode 100644 index b4ce2a157a0..00000000000 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {describe, expect, it} from 'vitest'; -import {isMembersNavActive} from './nav-content.helpers'; - -describe('isMembersNavActive', () => { - it('uses the legacy route active state when the feature flag is disabled', () => { - expect(isMembersNavActive({ - membersForwardEnabled: false, - isOnMembersRoute: false, - hasActiveMemberView: false, - isMembersExpanded: false, - isLegacyMembersRouteActive: true - })).toBe(true); - }); - - it('marks the base Members link active when a saved member view is active but collapsed', () => { - expect(isMembersNavActive({ - membersForwardEnabled: true, - isOnMembersRoute: true, - hasActiveMemberView: true, - isMembersExpanded: false, - isLegacyMembersRouteActive: false - })).toBe(true); - }); - - it('marks the base Members link inactive when a saved member view is active and expanded', () => { - expect(isMembersNavActive({ - membersForwardEnabled: true, - isOnMembersRoute: true, - hasActiveMemberView: true, - isMembersExpanded: true, - isLegacyMembersRouteActive: false - })).toBe(false); - }); - - it('marks the base Members link active on React-owned members routes when no saved member view is active', () => { - expect(isMembersNavActive({ - membersForwardEnabled: true, - isOnMembersRoute: true, - hasActiveMemberView: false, - isMembersExpanded: false, - isLegacyMembersRouteActive: false - })).toBe(true); - }); -}); diff --git a/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts b/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts deleted file mode 100644 index 58e62d37ba6..00000000000 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts +++ /dev/null @@ -1,27 +0,0 @@ -export function isMembersNavActive({ - membersForwardEnabled, - isOnMembersRoute, - hasActiveMemberView, - isMembersExpanded, - isLegacyMembersRouteActive -}: { - membersForwardEnabled: boolean; - isOnMembersRoute: boolean; - hasActiveMemberView: boolean; - isMembersExpanded: boolean; - isLegacyMembersRouteActive: boolean; -}): boolean { - if (!membersForwardEnabled) { - return isLegacyMembersRouteActive; - } - - if (isOnMembersRoute) { - if (!hasActiveMemberView) { - return true; - } - - return !isMembersExpanded; - } - - return isLegacyMembersRouteActive; -} diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 570ad7cd358..2ce152b7e1a 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -11,12 +11,11 @@ import { useNavigationExpanded } from "./hooks/use-navigation-preferences"; import { NavCustomViews } from "./nav-custom-views"; import { NavMemberViews } from "./nav-member-views"; import { useMemberSidebarViews } from "./member-sidebar-views"; -import { isMembersNavActive } from "./nav-content.helpers"; import { useCustomSidebarViews } from "./use-custom-sidebar-views"; import { useEmberRouting } from "@/ember-bridge"; import { useFeatureFlag } from "@/hooks/use-feature-flag"; -const LEGACY_MEMBERS_ACTIVE_ROUTES = ['members', 'member', 'member.new']; +const LEGACY_MEMBERS_SUBROUTES = ['member', 'member.new', 'members-activity']; function PostsNavItemContent({isActive, to}: {isActive: boolean; to: string}) { return ( @@ -80,6 +79,9 @@ function NavContent({ ...props }: React.ComponentProps) { const routing = useEmberRouting(); const commentModerationEnabled = useFeatureFlag('commentModeration'); const membersForwardEnabled = useFeatureFlag('membersForward'); + const normalizedPathname = location.pathname.replace(/\/+$/, '') || '/'; + const isReactMembersListRouteActive = normalizedPathname === '/members'; + const isReactMembersImportRouteActive = normalizedPathname === '/members/import'; const showTags = currentUser && canManageTags(currentUser); const showMembers = currentUser && canManageMembers(currentUser); @@ -88,19 +90,14 @@ function NavContent({ ...props }: React.ComponentProps) { const isPublishedPostsRouteActive = routing.isRouteActive('posts', {type: 'published'}); const hasActivePostChild = isDraftPostsRouteActive || isScheduledPostsRouteActive || isPublishedPostsRouteActive || postCustomViews.some(view => view.isActive); const postsExpanded = savedPostsExpanded; - const isOnMembersRoute = location.pathname === '/members' || location.pathname === '/members/import'; - const hasActiveMemberView = memberViews.some(view => view.isActive); + const hasActiveMemberChild = membersForwardEnabled && hasMemberViews && memberViews.some(view => view.isActive); const membersExpanded = savedMembersExpanded; - const membersNavActive = isMembersNavActive({ - membersForwardEnabled, - isOnMembersRoute, - hasActiveMemberView, - isMembersExpanded: membersExpanded, - isLegacyMembersRouteActive: routing.isRouteActive(LEGACY_MEMBERS_ACTIVE_ROUTES) - }); + const isLegacyMembersRouteActive = routing.isRouteActive(LEGACY_MEMBERS_SUBROUTES) || (!membersForwardEnabled && routing.isRouteActive('members')); + const isMembersBaseRouteActive = isLegacyMembersRouteActive || isReactMembersImportRouteActive || (isReactMembersListRouteActive && !hasActiveMemberChild); const postsRoute = routing.getRouteUrl('posts'); const isPostsRouteActive = routing.isRouteActive('posts'); const postsNavActive = isPostsRouteActive || (!postsExpanded && hasActivePostChild); + const membersNavActive = isMembersBaseRouteActive || (!membersExpanded && hasActiveMemberChild); const membersRoute = routing.getRouteUrl('members'); return ( From 607038c571d016fb793c39e0babfe67c182bdcf2 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 21:07:57 +0200 Subject: [PATCH 21/29] Fixed members import modal ownership gating ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This keeps the React import modal closed unless the import route is actually React-owned, which avoids the duplicate Ember and React import modals on /members/import. --- .../members/components/members-actions.tsx | 7 +- .../views/members/members-actions.test.tsx | 92 ++++++++++++++++++- 2 files changed, 95 insertions(+), 4 deletions(-) diff --git a/apps/posts/src/views/members/components/members-actions.tsx b/apps/posts/src/views/members/components/members-actions.tsx index fb61f184a2a..bec7575124d 100644 --- a/apps/posts/src/views/members/components/members-actions.tsx +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -7,6 +7,7 @@ import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; import {buildMemberOperationParams} from '../member-query-params'; import {buildMembersUrl} from '../member-route'; import {toast} from 'sonner'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useBulkDeleteMembers, useBulkEditMembers} from '@tryghost/admin-x-framework/api/members'; import {useLocation, useNavigate} from '@tryghost/admin-x-framework'; @@ -44,6 +45,10 @@ const MembersActions: React.FC = ({ const navigate = useNavigate(); const isImportRoute = location.pathname === '/members/import'; const currentSearch = location.search ?? ''; + const {data: configData} = useBrowseConfig(); + const membersForwardEnabled = configData?.config?.labs?.membersForward === true; + const inAdminForwardEnabled = configData?.config?.labs?.inAdminForward === true; + const isReactImportRoute = isImportRoute && (membersForwardEnabled || inAdminForwardEnabled); const [showAddLabelModal, setShowAddLabelModal] = useState(false); const [showRemoveLabelModal, setShowRemoveLabelModal] = useState(false); const [showUnsubscribeModal, setShowUnsubscribeModal] = useState(false); @@ -272,7 +277,7 @@ const MembersActions: React.FC = ({ {/* Modals */} | null} = {current: null}; -const {mockUseLocation, mockUseNavigate} = vi.hoisted(() => ({ +const {mockUseLocation, mockUseNavigate, mockUseBrowseConfig} = vi.hoisted(() => ({ mockUseLocation: vi.fn(), - mockUseNavigate: vi.fn() + mockUseNavigate: vi.fn(), + mockUseBrowseConfig: vi.fn() })); vi.mock('@tryghost/admin-x-framework', () => ({ @@ -25,6 +26,10 @@ vi.mock('@src/views/members/components/bulk-action-modals', () => ({ DeleteModal: () => React.createElement('div') })); +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: mockUseBrowseConfig +})); + vi.mock('@tryghost/admin-x-framework/api/newsletters', () => ({ useBrowseNewsletters: () => ({ data: {newsletters: []}, @@ -51,12 +56,75 @@ describe('MembersActions', () => { search: '' }); mockUseNavigate.mockReturnValue(vi.fn()); + mockUseBrowseConfig.mockReturnValue({ + data: { + config: { + labs: {} + } + } + }); + }); + + it('does not open the import modal on the import route when neither React ownership flag is enabled', () => { + mockUseLocation.mockReturnValue({ + pathname: '/members/import' + }); + + render( + + ); + + expect(importModalPropsRef.current).not.toBeNull(); + expect(importModalPropsRef.current?.open).toBe(false); + }); + + it('opens the import modal when membersForward is enabled on the import route', () => { + mockUseLocation.mockReturnValue({ + pathname: '/members/import' + }); + mockUseBrowseConfig.mockReturnValue({ + data: { + config: { + labs: { + membersForward: true + } + } + } + }); + + render( + + ); + + expect(importModalPropsRef.current).not.toBeNull(); + expect(importModalPropsRef.current?.open).toBe(true); }); - it('opens the import modal when rendered on the import route', () => { + it('opens the import modal when inAdminForward is enabled on the import route', () => { mockUseLocation.mockReturnValue({ pathname: '/members/import' }); + mockUseBrowseConfig.mockReturnValue({ + data: { + config: { + labs: { + inAdminForward: true + } + } + } + }); render( { search: '?filter=label%3AVIP&search=alice' }); mockUseNavigate.mockReturnValue(navigate); + mockUseBrowseConfig.mockReturnValue({ + data: { + config: { + labs: { + membersForward: true + } + } + } + }); render( { search: '?filter=label%3AVIP&search=alice' }); mockUseNavigate.mockReturnValue(navigate); + mockUseBrowseConfig.mockReturnValue({ + data: { + config: { + labs: { + membersForward: true + } + } + } + }); render( Date: Tue, 7 Apr 2026 21:16:59 +0200 Subject: [PATCH 22/29] Removed stale inAdminForward import gating ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The React members import modal should follow the actual route ownership in this branch, which is controlled only by membersForward. --- .../members/components/members-actions.tsx | 3 +- .../views/members/members-actions.test.tsx | 30 +------------------ 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/apps/posts/src/views/members/components/members-actions.tsx b/apps/posts/src/views/members/components/members-actions.tsx index bec7575124d..d8fa5b299f9 100644 --- a/apps/posts/src/views/members/components/members-actions.tsx +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -47,8 +47,7 @@ const MembersActions: React.FC = ({ const currentSearch = location.search ?? ''; const {data: configData} = useBrowseConfig(); const membersForwardEnabled = configData?.config?.labs?.membersForward === true; - const inAdminForwardEnabled = configData?.config?.labs?.inAdminForward === true; - const isReactImportRoute = isImportRoute && (membersForwardEnabled || inAdminForwardEnabled); + const isReactImportRoute = isImportRoute && membersForwardEnabled; const [showAddLabelModal, setShowAddLabelModal] = useState(false); const [showRemoveLabelModal, setShowRemoveLabelModal] = useState(false); const [showUnsubscribeModal, setShowUnsubscribeModal] = useState(false); diff --git a/apps/posts/test/unit/views/members/members-actions.test.tsx b/apps/posts/test/unit/views/members/members-actions.test.tsx index 4d5a6e78a9e..4e7033f8afa 100644 --- a/apps/posts/test/unit/views/members/members-actions.test.tsx +++ b/apps/posts/test/unit/views/members/members-actions.test.tsx @@ -65,7 +65,7 @@ describe('MembersActions', () => { }); }); - it('does not open the import modal on the import route when neither React ownership flag is enabled', () => { + it('does not open the import modal on the import route when membersForward is disabled', () => { mockUseLocation.mockReturnValue({ pathname: '/members/import' }); @@ -112,34 +112,6 @@ describe('MembersActions', () => { expect(importModalPropsRef.current?.open).toBe(true); }); - it('opens the import modal when inAdminForward is enabled on the import route', () => { - mockUseLocation.mockReturnValue({ - pathname: '/members/import' - }); - mockUseBrowseConfig.mockReturnValue({ - data: { - config: { - labs: { - inAdminForward: true - } - } - } - }); - - render( - - ); - - expect(importModalPropsRef.current).not.toBeNull(); - expect(importModalPropsRef.current?.open).toBe(true); - }); - it('navigates back to members when the import route modal closes', () => { const navigate = vi.fn(); mockUseLocation.mockReturnValue({ From 00d337aa01de2c40e9984612589f4c4aa12ba184 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 21:26:35 +0200 Subject: [PATCH 23/29] Removed trivial members route gate test ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The access-control coverage stays in MembersRoute while the feature-flag gate test only reasserted a simple branch in MembersRouteGate. --- apps/admin/src/members-route-gate.test.tsx | 54 ---------------------- 1 file changed, 54 deletions(-) delete mode 100644 apps/admin/src/members-route-gate.test.tsx diff --git a/apps/admin/src/members-route-gate.test.tsx b/apps/admin/src/members-route-gate.test.tsx deleted file mode 100644 index 17b7fcdeda4..00000000000 --- a/apps/admin/src/members-route-gate.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import {render, screen} from '@testing-library/react'; -import React from 'react'; -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {MembersRouteGate} from './members-route-gate'; - -const {mockUseLocation, mockUseFeatureFlag} = vi.hoisted(() => ({ - mockUseLocation: vi.fn(), - mockUseFeatureFlag: vi.fn() -})); - -vi.mock('@tryghost/admin-x-framework', () => ({ - Outlet: () => React.createElement('div', {'data-testid': 'outlet'}), - useLocation: mockUseLocation -})); - -vi.mock('./ember-bridge', () => ({ - EmberFallback: () => React.createElement('div', {'data-testid': 'ember-fallback'}) -})); - -vi.mock('./hooks/use-feature-flag', () => ({ - useFeatureFlag: mockUseFeatureFlag -})); - -describe('MembersRouteGate', () => { - beforeEach(() => { - mockUseFeatureFlag.mockReturnValue(false); - mockUseLocation.mockReturnValue({pathname: '/members'}); - }); - - it('delegates /members/ to Ember when the flag is disabled', () => { - mockUseLocation.mockReturnValue({pathname: '/members/'}); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('delegates /members/import to Ember when the flag is disabled', () => { - mockUseLocation.mockReturnValue({pathname: '/members/import'}); - - render(); - - expect(screen.getByTestId('ember-fallback')).toBeInTheDocument(); - }); - - it('renders React routes when the flag is enabled', () => { - mockUseFeatureFlag.mockReturnValue(true); - mockUseLocation.mockReturnValue({pathname: '/members/import'}); - - render(); - - expect(screen.getByTestId('outlet')).toBeInTheDocument(); - }); -}); From db66fc1ae7c56f03aeb2d67f96354a9c694d3b79 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 21:31:44 +0200 Subject: [PATCH 24/29] Reduced duplicate members test churn ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This tightens the members action unit coverage, shares the export assertions across the legacy and React member list suites, and moves saved-view interactions onto the page objects instead of protected internals. --- .../views/members/members-actions.test.tsx | 131 ++++++--------- .../pages/admin/members/members-list-page.ts | 17 ++ .../pages/admin/members/members-page.ts | 12 ++ e2e/tests/admin/members-legacy/export.test.ts | 141 +--------------- e2e/tests/admin/members/export.test.ts | 76 +-------- e2e/tests/admin/members/saved-views.test.ts | 34 +--- .../admin/members/shared/export-suite.ts | 153 ++++++++++++++++++ e2e/tsconfig.json | 1 + 8 files changed, 241 insertions(+), 324 deletions(-) create mode 100644 e2e/tests/admin/members/shared/export-suite.ts diff --git a/apps/posts/test/unit/views/members/members-actions.test.tsx b/apps/posts/test/unit/views/members/members-actions.test.tsx index 4e7033f8afa..634bcc1122e 100644 --- a/apps/posts/test/unit/views/members/members-actions.test.tsx +++ b/apps/posts/test/unit/views/members/members-actions.test.tsx @@ -48,65 +48,60 @@ vi.mock('@tryghost/admin-x-framework/api/members', () => ({ }) })); +const defaultProps = { + hasFilterOrSearch: false, + memberCount: 10, + search: '', + canBulkDelete: true +} as const; + +const setLocation = (pathname: string, search = '') => { + mockUseLocation.mockReturnValue({pathname, search}); +}; + +const setMembersForward = (enabled: boolean) => { + mockUseBrowseConfig.mockReturnValue({ + data: { + config: { + labs: { + membersForward: enabled + } + } + } + }); +}; + +const renderMembersActions = (props: Partial> = {}) => { + return render( + + ); +}; + describe('MembersActions', () => { beforeEach(() => { importModalPropsRef.current = null; - mockUseLocation.mockReturnValue({ - pathname: '/members', - search: '' - }); + setLocation('/members'); mockUseNavigate.mockReturnValue(vi.fn()); - mockUseBrowseConfig.mockReturnValue({ - data: { - config: { - labs: {} - } - } - }); + setMembersForward(false); }); it('does not open the import modal on the import route when membersForward is disabled', () => { - mockUseLocation.mockReturnValue({ - pathname: '/members/import' - }); + setLocation('/members/import'); - render( - - ); + renderMembersActions({onImportComplete: vi.fn()}); expect(importModalPropsRef.current).not.toBeNull(); expect(importModalPropsRef.current?.open).toBe(false); }); it('opens the import modal when membersForward is enabled on the import route', () => { - mockUseLocation.mockReturnValue({ - pathname: '/members/import' - }); - mockUseBrowseConfig.mockReturnValue({ - data: { - config: { - labs: { - membersForward: true - } - } - } - }); + setLocation('/members/import'); + setMembersForward(true); - render( - - ); + renderMembersActions({onImportComplete: vi.fn()}); expect(importModalPropsRef.current).not.toBeNull(); expect(importModalPropsRef.current?.open).toBe(true); @@ -114,29 +109,11 @@ describe('MembersActions', () => { it('navigates back to members when the import route modal closes', () => { const navigate = vi.fn(); - mockUseLocation.mockReturnValue({ - pathname: '/members/import', - search: '?filter=label%3AVIP&search=alice' - }); + setLocation('/members/import', '?filter=label%3AVIP&search=alice'); mockUseNavigate.mockReturnValue(navigate); - mockUseBrowseConfig.mockReturnValue({ - data: { - config: { - labs: { - membersForward: true - } - } - } - }); + setMembersForward(true); - render( - - ); + renderMembersActions(); expect(importModalPropsRef.current).not.toBeNull(); @@ -151,29 +128,11 @@ describe('MembersActions', () => { it('navigates to the imported label filter when the import route modal closes after a labeled import', () => { const navigate = vi.fn(); - mockUseLocation.mockReturnValue({ - pathname: '/members/import', - search: '?filter=label%3AVIP&search=alice' - }); + setLocation('/members/import', '?filter=label%3AVIP&search=alice'); mockUseNavigate.mockReturnValue(navigate); - mockUseBrowseConfig.mockReturnValue({ - data: { - config: { - labs: { - membersForward: true - } - } - } - }); + setMembersForward(true); - render( - - ); + renderMembersActions(); expect(importModalPropsRef.current).not.toBeNull(); const handleImportClose = importModalPropsRef.current?.onClose as ((importResponse?: {importLabel?: {slug: string}}) => void) | undefined; expect(handleImportClose).toBeTypeOf('function'); diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 4be72d4a5b3..2ad2b67711f 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -55,6 +55,23 @@ export class MembersListPage extends AdminPage { await this.actionsButton.click(); } + async applyLabelFilter(labelName: string): Promise { + await this.addSearchableFilter('Label', labelName, labelName); + } + + async getVisibleMemberCount(): Promise { + return await this.memberRows.count(); + } + + async saveCurrentView(name: string): Promise { + await this.membersPage.getByRole('button', {name: 'Save view'}).click(); + const dialog = this.page.getByRole('dialog'); + await dialog.waitFor({state: 'visible'}); + await dialog.getByRole('textbox', {name: 'View name'}).fill(name); + await dialog.getByRole('button', {name: 'Save'}).click(); + await dialog.waitFor({state: 'hidden'}); + } + getMenuItem(name: string | RegExp): Locator { return this.page.getByRole('menuitem', {name}); } diff --git a/e2e/helpers/pages/admin/members/members-page.ts b/e2e/helpers/pages/admin/members/members-page.ts index 947feb554d7..bdf32481199 100644 --- a/e2e/helpers/pages/admin/members/members-page.ts +++ b/e2e/helpers/pages/admin/members/members-page.ts @@ -138,6 +138,18 @@ export class MembersPage extends AdminPage { await this.memberListItems.filter({hasText: email}).click(); } + async openActionsMenu(): Promise { + await this.membersActionsButton.click(); + } + + async applyLabelFilter(labelName: string): Promise { + await this.filterSection.applyLabel(labelName); + } + + async getVisibleMemberCount(): Promise { + return await this.memberListItems.count(); + } + async getMaxRenderedIndex(): Promise { return await this.memberListItems.evaluateAll((rows) => { return rows.reduce((maxIndex, row) => { diff --git a/e2e/tests/admin/members-legacy/export.test.ts b/e2e/tests/admin/members-legacy/export.test.ts index fbe60bccf3d..a038d1cdef2 100644 --- a/e2e/tests/admin/members-legacy/export.test.ts +++ b/e2e/tests/admin/members-legacy/export.test.ts @@ -1,139 +1,8 @@ -import {expect, test} from '@/helpers/playwright'; -import {usePerTestIsolation} from '@/helpers/playwright/isolation'; - -import {MemberFactory, createMemberFactory} from '@/data-factory'; import {MembersPage} from '@/helpers/pages'; +import {runMembersExportTests} from '@/tests/admin/members/shared/export-suite'; -usePerTestIsolation(); - -test.describe('Ghost Admin - Member Export', () => { - test.use({labs: {membersForward: false}}); - - let memberFactory: MemberFactory; - - function extractDownloadedContentSpecifics(content: string) { - const contentIds = content.match(/[a-z0-9]{24}/gm); - const contentTimestamps = content.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/gm); - - return { - contentIds, - contentTimestamps - }; - } - - const downloadedContentFields = [ - 'id,', - 'email,', - 'name,', - 'note,', - 'subscribed_to_emails,', - 'complimentary_plan,', - 'stripe_customer_id,', - 'created_at,', - 'deleted_at,', - 'labels,', - 'tiers' - ]; - - const membersFixture = [ - { - name: 'Test Member 1', - email: 'test@member1.com', - note: 'This is a test member', - labels: ['old'] - }, - { - name: 'Test Member 2', - email: 'test@member2.com', - note: 'This is a test member', - labels: ['old'] - }, - { - name: 'Test Member 3', - email: 'test@member3.com', - note: 'This is a test member', - labels: ['old'] - }, - { - name: 'Sashi', - email: 'test@member4.com', - note: 'This is a test member', - labels: ['dog'] - }, - { - name: 'Mia', - email: 'test@member5.com', - note: 'This is a test member', - labels: ['dog'] - }, - { - name: 'Minki', - email: 'test@member6.com', - note: 'This is a test member', - labels: ['dog'] - } - ]; - - test.beforeEach(async ({page}) => { - memberFactory = createMemberFactory(page.request); - }); - - test('exports all members to CSV', async ({page}) => { - await memberFactory.createMany(membersFixture); - - const membersPage = new MembersPage(page); - await membersPage.goto(); - await membersPage.membersActionsButton.click(); - const {suggestedFilename, content} = await membersPage.exportMembers(); - const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); - - expect(content).toMatch(new RegExp(downloadedContentFields.join(''))); - - membersFixture.forEach((member) => { - expect(content).toContain(member.name); - expect(content).toContain(member.email); - expect(content).toContain(member.note); - expect(content).toContain(member.labels[0]); - }); - - expect(contentIds).toHaveLength(6); - expect(contentTimestamps).toHaveLength(6); - - expect(suggestedFilename.startsWith('members')).toBe(true); - expect(suggestedFilename.endsWith('.csv')).toBe(true); - }); - - test('exports filtered members by label to CSV', async ({page}) => { - await memberFactory.createMany(membersFixture); - const labelToFilterBy = 'dog'; - - const membersPage = new MembersPage(page); - await membersPage.goto(); - await membersPage.filterSection.applyLabel(labelToFilterBy); - await expect(membersPage.memberListItems).toHaveCount(3); - - await membersPage.membersActionsButton.click(); - await expect(membersPage.exportMembersButton).toContainText('Export selected members'); - - const {suggestedFilename, content} = await membersPage.exportMembers(); - const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); - - const fixture = membersFixture - .filter(member => member.labels[0] === 'dog'); - - expect(content).toMatch(new RegExp(downloadedContentFields.join(''))); - - fixture.forEach((member) => { - expect(content).toContain(member.name); - expect(content).toContain(member.email); - expect(content).toContain(member.note); - expect(content).toContain(labelToFilterBy); - }); - - expect(contentIds).toHaveLength(3); - expect(contentTimestamps).toHaveLength(3); - - expect(suggestedFilename.startsWith('members')).toBe(true); - expect(suggestedFilename.endsWith('.csv')).toBe(true); - }); +runMembersExportTests({ + suiteName: 'Ghost Admin - Member Export', + labs: {membersForward: false}, + createPage: page => new MembersPage(page) }); diff --git a/e2e/tests/admin/members/export.test.ts b/e2e/tests/admin/members/export.test.ts index 900ca4f63c2..3c2e898579f 100644 --- a/e2e/tests/admin/members/export.test.ts +++ b/e2e/tests/admin/members/export.test.ts @@ -1,74 +1,8 @@ -import {MemberFactory, createMemberFactory} from '@/data-factory'; import {MembersListPage} from '@/admin-pages'; -import {expect, test} from '@/helpers/playwright'; -import {usePerTestIsolation} from '@/helpers/playwright/isolation'; +import {runMembersExportTests} from '@/tests/admin/members/shared/export-suite'; -usePerTestIsolation(); - -const EXPECTED_CSV_HEADER_FIELDS = [ - 'id,', - 'email,', - 'name,', - 'note,', - 'subscribed_to_emails,', - 'complimentary_plan,', - 'stripe_customer_id,', - 'created_at,', - 'deleted_at,', - 'labels,', - 'tiers' -]; - -test.describe('Ghost Admin - Members Export', () => { - test.use({labs: {membersForward: true}}); - - let memberFactory: MemberFactory; - - const membersFixture = [ - {name: 'Export Member 1', email: 'export1@example.com', note: 'First export test', labels: ['alpha']}, - {name: 'Export Member 2', email: 'export2@example.com', note: 'Second export test', labels: ['alpha']}, - {name: 'Export Member 3', email: 'export3@example.com', note: 'Third export test', labels: ['beta']} - ]; - - test.beforeEach(async ({page}) => { - memberFactory = createMemberFactory(page.request); - }); - - test('exports all members to a CSV with expected fields', async ({page}) => { - await memberFactory.createMany(membersFixture); - - const membersPage = new MembersListPage(page); - await membersPage.goto(); - - await membersPage.openActionsMenu(); - - const {suggestedFilename, content} = await membersPage.exportMembers(); - - expect(content).toMatch(new RegExp(EXPECTED_CSV_HEADER_FIELDS.join(''))); - - for (const member of membersFixture) { - expect(content).toContain(member.name); - expect(content).toContain(member.email); - expect(content).toContain(member.note); - } - - expect(suggestedFilename).toMatch(/^members\.\d{4}-\d{2}-\d{2}\.csv$/); - }); - - test('exports only filtered members when a filter is active', async ({page}) => { - await memberFactory.createMany(membersFixture); - - const membersPage = new MembersListPage(page); - await page.goto('/ghost/#/members?filter=label:alpha'); - await expect(membersPage.memberRows).toHaveCount(2); - - await membersPage.openActionsMenu(); - await expect(membersPage.getMenuItem(/Export 2 members/)).toBeVisible(); - - const {content} = await membersPage.exportMembers(); - - expect(content).toContain('export1@example.com'); - expect(content).toContain('export2@example.com'); - expect(content).not.toContain('export3@example.com'); - }); +runMembersExportTests({ + suiteName: 'Ghost Admin - Members Export', + labs: {membersForward: true}, + createPage: page => new MembersListPage(page) }); diff --git a/e2e/tests/admin/members/saved-views.test.ts b/e2e/tests/admin/members/saved-views.test.ts index 0c9f3d5f017..7dfd65276be 100644 --- a/e2e/tests/admin/members/saved-views.test.ts +++ b/e2e/tests/admin/members/saved-views.test.ts @@ -2,45 +2,17 @@ import {MemberFactory, createMemberFactory} from '@/data-factory'; import {MembersListPage, SidebarPage} from '@/admin-pages'; import {expect, test} from '@/helpers/playwright/fixture'; -function escapeNqlString(value: string): string { - return `'${value.replace(/\\/g, '\\\\').replace(/'/g, '\\\'')}'`; -} - async function addFilter(membersPage: MembersListPage, filterName: 'Name' | 'Email' | 'Label', value: string) { if (filterName === 'Label') { - const url = new URL(membersPage.page.url()); - const params = new URLSearchParams(url.search); - const labelFilter = `label:${escapeNqlString(value)}`; - const existingFilter = params.get('filter'); - - params.set('filter', existingFilter ? `${existingFilter}+${labelFilter}` : labelFilter); - await membersPage.page.goto(`/ghost/#/members?${params.toString()}`); - return; - } - - await membersPage.filterButton.click(); - await membersPage.page.getByRole('option', {name: filterName, exact: true}).click(); - - if (filterName === 'Name') { - await membersPage.page.getByRole('textbox', {name: 'Enter name...'}).fill(value); - return; - } - - if (filterName === 'Email') { - await membersPage.page.getByRole('textbox', {name: 'Enter email...'}).fill(value); + await membersPage.applyLabelFilter(value); return; } - await membersPage.page.getByRole('option', {name: value, exact: true}).click(); + await membersPage.addFilter(filterName, value); } async function saveCurrentView(membersPage: MembersListPage, name: string) { - await membersPage.membersPage.getByRole('button', {name: 'Save view'}).click(); - const dialog = membersPage.page.getByRole('dialog'); - await dialog.waitFor({state: 'visible'}); - await dialog.getByRole('textbox', {name: 'View name'}).fill(name); - await dialog.getByRole('button', {name: 'Save'}).click(); - await dialog.waitFor({state: 'hidden'}); + await membersPage.saveCurrentView(name); } test.describe('Ghost Admin - Members Saved Views', () => { diff --git a/e2e/tests/admin/members/shared/export-suite.ts b/e2e/tests/admin/members/shared/export-suite.ts new file mode 100644 index 00000000000..42e2eb5e3ef --- /dev/null +++ b/e2e/tests/admin/members/shared/export-suite.ts @@ -0,0 +1,153 @@ +import {Page} from '@playwright/test'; + +import {MemberFactory, createMemberFactory} from '@/data-factory'; +import {expect, test} from '@/helpers/playwright'; +import {usePerTestIsolation} from '@/helpers/playwright/isolation'; + +interface ExportedFile { + suggestedFilename: string; + content: string; +} + +interface MembersExportPage { + goto(): Promise; + openActionsMenu(): Promise; + exportMembers(): Promise; + applyLabelFilter(labelName: string): Promise; + getVisibleMemberCount(): Promise; +} + +interface RunMembersExportTestsOptions { + suiteName: string; + labs: Record; + createPage: (page: Page) => MembersExportPage; +} + +const DOWNLOADED_CONTENT_FIELDS = [ + 'id,', + 'email,', + 'name,', + 'note,', + 'subscribed_to_emails,', + 'complimentary_plan,', + 'stripe_customer_id,', + 'created_at,', + 'deleted_at,', + 'labels,', + 'tiers' +]; + +const MEMBERS_FIXTURE = [ + { + name: 'Test Member 1', + email: 'test@member1.com', + note: 'This is a test member', + labels: ['old'] + }, + { + name: 'Test Member 2', + email: 'test@member2.com', + note: 'This is a test member', + labels: ['old'] + }, + { + name: 'Test Member 3', + email: 'test@member3.com', + note: 'This is a test member', + labels: ['old'] + }, + { + name: 'Sashi', + email: 'test@member4.com', + note: 'This is a test member', + labels: ['dog'] + }, + { + name: 'Mia', + email: 'test@member5.com', + note: 'This is a test member', + labels: ['dog'] + }, + { + name: 'Minki', + email: 'test@member6.com', + note: 'This is a test member', + labels: ['dog'] + } +]; + +function extractDownloadedContentSpecifics(content: string) { + const contentIds = content.match(/[a-z0-9]{24}/gm); + const contentTimestamps = content.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/gm); + + return { + contentIds, + contentTimestamps + }; +} + +function assertExportedMembers(content: string, membersFixture: typeof MEMBERS_FIXTURE) { + expect(content).toMatch(new RegExp(DOWNLOADED_CONTENT_FIELDS.join(''))); + + membersFixture.forEach((member) => { + expect(content).toContain(member.name); + expect(content).toContain(member.email); + expect(content).toContain(member.note); + expect(content).toContain(member.labels[0]); + }); +} + +usePerTestIsolation(); + +export function runMembersExportTests({suiteName, labs, createPage}: RunMembersExportTestsOptions) { + test.describe(suiteName, () => { + test.use({labs}); + + let memberFactory: MemberFactory; + + test.beforeEach(async ({page}) => { + memberFactory = createMemberFactory(page.request); + }); + + test('exports all members to CSV', async ({page}) => { + await memberFactory.createMany(MEMBERS_FIXTURE); + + const membersPage = createPage(page); + await membersPage.goto(); + await membersPage.openActionsMenu(); + + const {suggestedFilename, content} = await membersPage.exportMembers(); + const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); + + assertExportedMembers(content, MEMBERS_FIXTURE); + + expect(contentIds).toHaveLength(MEMBERS_FIXTURE.length); + expect(contentTimestamps).toHaveLength(MEMBERS_FIXTURE.length); + expect(suggestedFilename.startsWith('members')).toBe(true); + expect(suggestedFilename.endsWith('.csv')).toBe(true); + }); + + test('exports filtered members by label to CSV', async ({page}) => { + await memberFactory.createMany(MEMBERS_FIXTURE); + + const labelToFilterBy = 'dog'; + const filteredMembers = MEMBERS_FIXTURE.filter(member => member.labels[0] === labelToFilterBy); + + const membersPage = createPage(page); + await membersPage.goto(); + await membersPage.applyLabelFilter(labelToFilterBy); + await expect.poll(async () => await membersPage.getVisibleMemberCount()).toBe(filteredMembers.length); + await membersPage.openActionsMenu(); + + const {suggestedFilename, content} = await membersPage.exportMembers(); + const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); + + assertExportedMembers(content, filteredMembers); + + expect(contentIds).toHaveLength(filteredMembers.length); + expect(contentTimestamps).toHaveLength(filteredMembers.length); + expect(suggestedFilename.startsWith('members')).toBe(true); + expect(suggestedFilename.endsWith('.csv')).toBe(true); + }); + }); +} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 6c1f7795846..f9bcf294afd 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -33,6 +33,7 @@ "@/public-pages": ["./helpers/pages/public/index"], "@/portal-pages": ["./helpers/pages/portal/index"], "@/helpers/*": ["./helpers/*"], + "@/tests/*": ["./tests/*"], "@/data-factory": ["./data-factory/index.ts"], "@/data-factory/*": ["./data-factory/*"] }, /* Specify a set of entries that re-map imports to additional lookup locations. */ From 1557afb292460ef3b34fc05cc061cd09f36e2ba8 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 21:48:58 +0200 Subject: [PATCH 25/29] Cleaned members e2e duplication ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This removes the low-value route gate test, tightens members action coverage, aligns the member list page objects on a shared surface, and keeps one parameterized export suite instead of separate old and new implementations. --- .../pages/admin/members/members-list-page.ts | 12 +- .../pages/admin/members/members-page.ts | 8 +- e2e/tests/admin/members-legacy/export.test.ts | 8 - e2e/tests/admin/members/export.test.ts | 161 +++++++++++++++++- .../admin/members/shared/export-suite.ts | 153 ----------------- e2e/tsconfig.json | 1 - 6 files changed, 165 insertions(+), 178 deletions(-) delete mode 100644 e2e/tests/admin/members-legacy/export.test.ts delete mode 100644 e2e/tests/admin/members/shared/export-suite.ts diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 2ad2b67711f..02f0ee9344b 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -2,12 +2,20 @@ import {AdminPage} from '@/admin-pages'; import {Download, Locator, Page} from '@playwright/test'; import {readFileSync} from 'node:fs'; -interface ExportedFile { +export interface ExportedFile { suggestedFilename: string; content: string; } -export class MembersListPage extends AdminPage { +export interface MembersListSurface { + goto(): Promise; + openActionsMenu(): Promise; + applyLabelFilter(labelName: string): Promise; + getVisibleMemberCount(): Promise; + exportMembers(): Promise; +} + +export class MembersListPage extends AdminPage implements MembersListSurface { readonly membersPage: Locator; readonly membersList: Locator; readonly memberRows: Locator; diff --git a/e2e/helpers/pages/admin/members/members-page.ts b/e2e/helpers/pages/admin/members/members-page.ts index bdf32481199..3fbe40af0ed 100644 --- a/e2e/helpers/pages/admin/members/members-page.ts +++ b/e2e/helpers/pages/admin/members/members-page.ts @@ -2,11 +2,7 @@ import {AdminPage} from '@/admin-pages'; import {BasePage} from '@/helpers/pages'; import {Download, JSHandle, Locator, Page} from '@playwright/test'; import {readFileSync} from 'fs'; - -export interface ExportedFile { - suggestedFilename: string; - content: string -} +import type {ExportedFile, MembersListSurface} from './members-list-page'; class FilterSection extends BasePage { readonly actionsButton: Locator; @@ -104,7 +100,7 @@ class SettingsSection extends BasePage { } } -export class MembersPage extends AdminPage { +export class MembersPage extends AdminPage implements MembersListSurface { readonly newMemberButton: Locator; public readonly loadMoreButton: Locator; public readonly membersListScrollRoot: Locator; diff --git a/e2e/tests/admin/members-legacy/export.test.ts b/e2e/tests/admin/members-legacy/export.test.ts deleted file mode 100644 index a038d1cdef2..00000000000 --- a/e2e/tests/admin/members-legacy/export.test.ts +++ /dev/null @@ -1,8 +0,0 @@ -import {MembersPage} from '@/helpers/pages'; -import {runMembersExportTests} from '@/tests/admin/members/shared/export-suite'; - -runMembersExportTests({ - suiteName: 'Ghost Admin - Member Export', - labs: {membersForward: false}, - createPage: page => new MembersPage(page) -}); diff --git a/e2e/tests/admin/members/export.test.ts b/e2e/tests/admin/members/export.test.ts index 3c2e898579f..558c5afe4fc 100644 --- a/e2e/tests/admin/members/export.test.ts +++ b/e2e/tests/admin/members/export.test.ts @@ -1,8 +1,153 @@ -import {MembersListPage} from '@/admin-pages'; -import {runMembersExportTests} from '@/tests/admin/members/shared/export-suite'; - -runMembersExportTests({ - suiteName: 'Ghost Admin - Members Export', - labs: {membersForward: true}, - createPage: page => new MembersListPage(page) -}); +import {Page} from '@playwright/test'; + +import {MemberFactory, createMemberFactory} from '@/data-factory'; +import {MembersListPage, type MembersListSurface} from '@/admin-pages'; +import {MembersPage} from '@/helpers/pages'; +import {expect, test} from '@/helpers/playwright'; +import {usePerTestIsolation} from '@/helpers/playwright/isolation'; + +usePerTestIsolation(); + +const DOWNLOADED_CONTENT_FIELDS = [ + 'id,', + 'email,', + 'name,', + 'note,', + 'subscribed_to_emails,', + 'complimentary_plan,', + 'stripe_customer_id,', + 'created_at,', + 'deleted_at,', + 'labels,', + 'tiers' +]; + +const MEMBERS_FIXTURE = [ + { + name: 'Test Member 1', + email: 'test@member1.com', + note: 'This is a test member', + labels: ['old'] + }, + { + name: 'Test Member 2', + email: 'test@member2.com', + note: 'This is a test member', + labels: ['old'] + }, + { + name: 'Test Member 3', + email: 'test@member3.com', + note: 'This is a test member', + labels: ['old'] + }, + { + name: 'Sashi', + email: 'test@member4.com', + note: 'This is a test member', + labels: ['dog'] + }, + { + name: 'Mia', + email: 'test@member5.com', + note: 'This is a test member', + labels: ['dog'] + }, + { + name: 'Minki', + email: 'test@member6.com', + note: 'This is a test member', + labels: ['dog'] + } +]; + +function extractDownloadedContentSpecifics(content: string) { + const contentIds = content.match(/[a-z0-9]{24}/gm); + const contentTimestamps = content.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/gm); + + return { + contentIds, + contentTimestamps + }; +} + +function assertExportedMembers(content: string, membersFixture: typeof MEMBERS_FIXTURE) { + expect(content).toMatch(new RegExp(DOWNLOADED_CONTENT_FIELDS.join(''))); + + membersFixture.forEach((member) => { + expect(content).toContain(member.name); + expect(content).toContain(member.email); + expect(content).toContain(member.note); + expect(content).toContain(member.labels[0]); + }); +} + +const variations: Array<{ + createPage: (page: Page) => MembersListSurface; + membersForward: boolean; + name: string; +}> = [ + { + name: 'React members list', + membersForward: true, + createPage: page => new MembersListPage(page) + }, + { + name: 'legacy Ember members list', + membersForward: false, + createPage: page => new MembersPage(page) + } +]; + +for (const variation of variations) { + test.describe(`Ghost Admin - Members Export (${variation.name})`, () => { + test.use({labs: {membersForward: variation.membersForward}}); + + let memberFactory: MemberFactory; + + test.beforeEach(async ({page}) => { + memberFactory = createMemberFactory(page.request); + }); + + test('exports all members to CSV', async ({page}) => { + await memberFactory.createMany(MEMBERS_FIXTURE); + + const membersPage = variation.createPage(page); + await membersPage.goto(); + await membersPage.openActionsMenu(); + + const {suggestedFilename, content} = await membersPage.exportMembers(); + const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); + + assertExportedMembers(content, MEMBERS_FIXTURE); + + expect(contentIds).toHaveLength(MEMBERS_FIXTURE.length); + expect(contentTimestamps).toHaveLength(MEMBERS_FIXTURE.length); + expect(suggestedFilename.startsWith('members')).toBe(true); + expect(suggestedFilename.endsWith('.csv')).toBe(true); + }); + + test('exports filtered members by label to CSV', async ({page}) => { + await memberFactory.createMany(MEMBERS_FIXTURE); + + const labelToFilterBy = 'dog'; + const filteredMembers = MEMBERS_FIXTURE.filter(member => member.labels[0] === labelToFilterBy); + + const membersPage = variation.createPage(page); + await membersPage.goto(); + await membersPage.applyLabelFilter(labelToFilterBy); + await expect.poll(async () => await membersPage.getVisibleMemberCount()).toBe(filteredMembers.length); + await membersPage.openActionsMenu(); + + const {suggestedFilename, content} = await membersPage.exportMembers(); + const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); + + assertExportedMembers(content, filteredMembers); + + expect(contentIds).toHaveLength(filteredMembers.length); + expect(contentTimestamps).toHaveLength(filteredMembers.length); + expect(suggestedFilename.startsWith('members')).toBe(true); + expect(suggestedFilename.endsWith('.csv')).toBe(true); + }); + }); +} diff --git a/e2e/tests/admin/members/shared/export-suite.ts b/e2e/tests/admin/members/shared/export-suite.ts deleted file mode 100644 index 42e2eb5e3ef..00000000000 --- a/e2e/tests/admin/members/shared/export-suite.ts +++ /dev/null @@ -1,153 +0,0 @@ -import {Page} from '@playwright/test'; - -import {MemberFactory, createMemberFactory} from '@/data-factory'; -import {expect, test} from '@/helpers/playwright'; -import {usePerTestIsolation} from '@/helpers/playwright/isolation'; - -interface ExportedFile { - suggestedFilename: string; - content: string; -} - -interface MembersExportPage { - goto(): Promise; - openActionsMenu(): Promise; - exportMembers(): Promise; - applyLabelFilter(labelName: string): Promise; - getVisibleMemberCount(): Promise; -} - -interface RunMembersExportTestsOptions { - suiteName: string; - labs: Record; - createPage: (page: Page) => MembersExportPage; -} - -const DOWNLOADED_CONTENT_FIELDS = [ - 'id,', - 'email,', - 'name,', - 'note,', - 'subscribed_to_emails,', - 'complimentary_plan,', - 'stripe_customer_id,', - 'created_at,', - 'deleted_at,', - 'labels,', - 'tiers' -]; - -const MEMBERS_FIXTURE = [ - { - name: 'Test Member 1', - email: 'test@member1.com', - note: 'This is a test member', - labels: ['old'] - }, - { - name: 'Test Member 2', - email: 'test@member2.com', - note: 'This is a test member', - labels: ['old'] - }, - { - name: 'Test Member 3', - email: 'test@member3.com', - note: 'This is a test member', - labels: ['old'] - }, - { - name: 'Sashi', - email: 'test@member4.com', - note: 'This is a test member', - labels: ['dog'] - }, - { - name: 'Mia', - email: 'test@member5.com', - note: 'This is a test member', - labels: ['dog'] - }, - { - name: 'Minki', - email: 'test@member6.com', - note: 'This is a test member', - labels: ['dog'] - } -]; - -function extractDownloadedContentSpecifics(content: string) { - const contentIds = content.match(/[a-z0-9]{24}/gm); - const contentTimestamps = content.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/gm); - - return { - contentIds, - contentTimestamps - }; -} - -function assertExportedMembers(content: string, membersFixture: typeof MEMBERS_FIXTURE) { - expect(content).toMatch(new RegExp(DOWNLOADED_CONTENT_FIELDS.join(''))); - - membersFixture.forEach((member) => { - expect(content).toContain(member.name); - expect(content).toContain(member.email); - expect(content).toContain(member.note); - expect(content).toContain(member.labels[0]); - }); -} - -usePerTestIsolation(); - -export function runMembersExportTests({suiteName, labs, createPage}: RunMembersExportTestsOptions) { - test.describe(suiteName, () => { - test.use({labs}); - - let memberFactory: MemberFactory; - - test.beforeEach(async ({page}) => { - memberFactory = createMemberFactory(page.request); - }); - - test('exports all members to CSV', async ({page}) => { - await memberFactory.createMany(MEMBERS_FIXTURE); - - const membersPage = createPage(page); - await membersPage.goto(); - await membersPage.openActionsMenu(); - - const {suggestedFilename, content} = await membersPage.exportMembers(); - const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); - - assertExportedMembers(content, MEMBERS_FIXTURE); - - expect(contentIds).toHaveLength(MEMBERS_FIXTURE.length); - expect(contentTimestamps).toHaveLength(MEMBERS_FIXTURE.length); - expect(suggestedFilename.startsWith('members')).toBe(true); - expect(suggestedFilename.endsWith('.csv')).toBe(true); - }); - - test('exports filtered members by label to CSV', async ({page}) => { - await memberFactory.createMany(MEMBERS_FIXTURE); - - const labelToFilterBy = 'dog'; - const filteredMembers = MEMBERS_FIXTURE.filter(member => member.labels[0] === labelToFilterBy); - - const membersPage = createPage(page); - await membersPage.goto(); - await membersPage.applyLabelFilter(labelToFilterBy); - await expect.poll(async () => await membersPage.getVisibleMemberCount()).toBe(filteredMembers.length); - await membersPage.openActionsMenu(); - - const {suggestedFilename, content} = await membersPage.exportMembers(); - const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); - - assertExportedMembers(content, filteredMembers); - - expect(contentIds).toHaveLength(filteredMembers.length); - expect(contentTimestamps).toHaveLength(filteredMembers.length); - expect(suggestedFilename.startsWith('members')).toBe(true); - expect(suggestedFilename.endsWith('.csv')).toBe(true); - }); - }); -} diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index f9bcf294afd..6c1f7795846 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -33,7 +33,6 @@ "@/public-pages": ["./helpers/pages/public/index"], "@/portal-pages": ["./helpers/pages/portal/index"], "@/helpers/*": ["./helpers/*"], - "@/tests/*": ["./tests/*"], "@/data-factory": ["./data-factory/index.ts"], "@/data-factory/*": ["./data-factory/*"] }, /* Specify a set of entries that re-map imports to additional lookup locations. */ From 9ff9e03171122050404b955a27879d4da1821dc2 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 22:12:38 +0200 Subject: [PATCH 26/29] Removed redundant members import modal gate ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The React members view is already mounted behind route ownership, so the extra feature-flag check was redundant. This also drops the duplicate route-open unit assertion and keeps only the close/query behavior that the branch still needs unit coverage for. --- .../members/components/members-actions.tsx | 6 +-- .../views/members/members-actions.test.tsx | 43 +------------------ 2 files changed, 3 insertions(+), 46 deletions(-) diff --git a/apps/posts/src/views/members/components/members-actions.tsx b/apps/posts/src/views/members/components/members-actions.tsx index d8fa5b299f9..fb61f184a2a 100644 --- a/apps/posts/src/views/members/components/members-actions.tsx +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -7,7 +7,6 @@ import {blobDownloadFromEndpoint} from '@tryghost/admin-x-framework/helpers'; import {buildMemberOperationParams} from '../member-query-params'; import {buildMembersUrl} from '../member-route'; import {toast} from 'sonner'; -import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {useBrowseNewsletters} from '@tryghost/admin-x-framework/api/newsletters'; import {useBulkDeleteMembers, useBulkEditMembers} from '@tryghost/admin-x-framework/api/members'; import {useLocation, useNavigate} from '@tryghost/admin-x-framework'; @@ -45,9 +44,6 @@ const MembersActions: React.FC = ({ const navigate = useNavigate(); const isImportRoute = location.pathname === '/members/import'; const currentSearch = location.search ?? ''; - const {data: configData} = useBrowseConfig(); - const membersForwardEnabled = configData?.config?.labs?.membersForward === true; - const isReactImportRoute = isImportRoute && membersForwardEnabled; const [showAddLabelModal, setShowAddLabelModal] = useState(false); const [showRemoveLabelModal, setShowRemoveLabelModal] = useState(false); const [showUnsubscribeModal, setShowUnsubscribeModal] = useState(false); @@ -276,7 +272,7 @@ const MembersActions: React.FC = ({ {/* Modals */} | null} = {current: null}; -const {mockUseLocation, mockUseNavigate, mockUseBrowseConfig} = vi.hoisted(() => ({ +const {mockUseLocation, mockUseNavigate} = vi.hoisted(() => ({ mockUseLocation: vi.fn(), - mockUseNavigate: vi.fn(), - mockUseBrowseConfig: vi.fn() + mockUseNavigate: vi.fn() })); vi.mock('@tryghost/admin-x-framework', () => ({ @@ -26,10 +25,6 @@ vi.mock('@src/views/members/components/bulk-action-modals', () => ({ DeleteModal: () => React.createElement('div') })); -vi.mock('@tryghost/admin-x-framework/api/config', () => ({ - useBrowseConfig: mockUseBrowseConfig -})); - vi.mock('@tryghost/admin-x-framework/api/newsletters', () => ({ useBrowseNewsletters: () => ({ data: {newsletters: []}, @@ -59,18 +54,6 @@ const setLocation = (pathname: string, search = '') => { mockUseLocation.mockReturnValue({pathname, search}); }; -const setMembersForward = (enabled: boolean) => { - mockUseBrowseConfig.mockReturnValue({ - data: { - config: { - labs: { - membersForward: enabled - } - } - } - }); -}; - const renderMembersActions = (props: Partial> = {}) => { return render( { importModalPropsRef.current = null; setLocation('/members'); mockUseNavigate.mockReturnValue(vi.fn()); - setMembersForward(false); - }); - - it('does not open the import modal on the import route when membersForward is disabled', () => { - setLocation('/members/import'); - - renderMembersActions({onImportComplete: vi.fn()}); - - expect(importModalPropsRef.current).not.toBeNull(); - expect(importModalPropsRef.current?.open).toBe(false); - }); - - it('opens the import modal when membersForward is enabled on the import route', () => { - setLocation('/members/import'); - setMembersForward(true); - - renderMembersActions({onImportComplete: vi.fn()}); - - expect(importModalPropsRef.current).not.toBeNull(); - expect(importModalPropsRef.current?.open).toBe(true); }); it('navigates back to members when the import route modal closes', () => { const navigate = vi.fn(); setLocation('/members/import', '?filter=label%3AVIP&search=alice'); mockUseNavigate.mockReturnValue(navigate); - setMembersForward(true); renderMembersActions(); @@ -130,7 +92,6 @@ describe('MembersActions', () => { const navigate = vi.fn(); setLocation('/members/import', '?filter=label%3AVIP&search=alice'); mockUseNavigate.mockReturnValue(navigate); - setMembersForward(true); renderMembersActions(); expect(importModalPropsRef.current).not.toBeNull(); From 041886b42ff5b701887ba6efa94738c65c663668 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 22:19:10 +0200 Subject: [PATCH 27/29] Stopped Ember members import modal rendering on React route ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The Ember members import route should keep its existing URL and permission behavior, but it must not render the legacy fullscreen modal when membersForward is enabled because React owns the UI on that route. --- ghost/admin/app/controllers/members/import.js | 1 + ghost/admin/app/routes/members/import.js | 18 ------------------ ghost/admin/app/templates/members/import.hbs | 10 ++++++---- .../tests/acceptance/members/import-test.js | 4 ++-- 4 files changed, 9 insertions(+), 24 deletions(-) diff --git a/ghost/admin/app/controllers/members/import.js b/ghost/admin/app/controllers/members/import.js index 07bb72af6a3..6ea239b50e4 100644 --- a/ghost/admin/app/controllers/members/import.js +++ b/ghost/admin/app/controllers/members/import.js @@ -4,6 +4,7 @@ import {resetQueryParams} from 'ghost-admin/helpers/reset-query-params'; import {inject as service} from '@ember/service'; export default class ImportController extends Controller { + @service feature; @service router; @controller members; diff --git a/ghost/admin/app/routes/members/import.js b/ghost/admin/app/routes/members/import.js index 95064398ebc..23eaf73fad4 100644 --- a/ghost/admin/app/routes/members/import.js +++ b/ghost/admin/app/routes/members/import.js @@ -1,22 +1,4 @@ import MembersManagementRoute from '../members-management'; -import {inject as service} from '@ember/service'; export default class MembersImportRoute extends MembersManagementRoute { - @service feature; - @service router; - - beforeModel(transition) { - const nextTransition = super.beforeModel(...arguments); - - if (nextTransition) { - return nextTransition; - } - - if (this.feature.membersForward) { - const queryString = new URLSearchParams(transition?.to?.queryParams || {}).toString(); - const path = queryString ? `/members/import?${queryString}` : '/members/import'; - - return this.router.replaceWith(path); - } - } } diff --git a/ghost/admin/app/templates/members/import.hbs b/ghost/admin/app/templates/members/import.hbs index 5522be2b226..b02ea750a14 100644 --- a/ghost/admin/app/templates/members/import.hbs +++ b/ghost/admin/app/templates/members/import.hbs @@ -1,4 +1,6 @@ - +{{#unless this.feature.membersForward}} + +{{/unless}} diff --git a/ghost/admin/tests/acceptance/members/import-test.js b/ghost/admin/tests/acceptance/members/import-test.js index 1252b8699af..914d2f909d8 100644 --- a/ghost/admin/tests/acceptance/members/import-test.js +++ b/ghost/admin/tests/acceptance/members/import-test.js @@ -117,7 +117,7 @@ testemail@example.com,Test Email,This is a test template for importing your memb await visit('/members/import'); - expect(currentRouteName()).to.equal('react-fallback'); + expect(currentRouteName()).to.equal('members.import'); expect(find('[data-test-modal="import-members"]'), 'members import modal').to.not.exist; }); @@ -126,7 +126,7 @@ testemail@example.com,Test Email,This is a test template for importing your memb await visit('/members/import?filter=label%3AVIP&search=alice'); - expect(currentRouteName()).to.equal('react-fallback'); + expect(currentRouteName()).to.equal('members.import'); expect(currentURL()).to.equal('/members/import?filter=label%3AVIP&search=alice'); }); }); From 84466ced38c6efd755e38b847eae52099da64809 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 8 Apr 2026 08:05:50 +0200 Subject: [PATCH 28/29] Simplified members nav state and Ember list opt-out ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The sidebar now uses the shared routing active-state logic with the custom-view suppression kept in nav-content, and the Ember members list route skips legacy loading/rendering when membersForward is enabled. --- .../app-sidebar/member-sidebar-views.ts | 11 +++++++---- .../src/layout/app-sidebar/nav-content.tsx | 19 ++++++------------- ghost/admin/app/routes/members.js | 8 ++++++++ ghost/admin/app/templates/members.hbs | 2 ++ ghost/admin/tests/acceptance/members-test.js | 15 +++++++++++++++ 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts b/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts index 343d65d0753..7d63696319b 100644 --- a/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts +++ b/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts @@ -18,14 +18,17 @@ function getMemberViewUrl(filter: string) { return `members?${new URLSearchParams({filter}).toString()}`; } -function isMemberViewActive(currentSearch: string, filter: string) { +function isMemberViewActive(pathname: string, currentSearch: string, filter: string) { + if (pathname !== '/members') { + return false; + } + return new URLSearchParams(currentSearch).get('filter') === filter; } export function useMemberSidebarViews() { const location = useLocation(); const sharedViews = useSharedViews('members'); - const isOnMembersListRoute = location.pathname === '/members'; return useMemo(() => { return sharedViews @@ -34,7 +37,7 @@ export function useMemberSidebarViews() { key: `${view.name}:${view.filter.filter}`, name: view.name, to: getMemberViewUrl(view.filter.filter), - isActive: isOnMembersListRoute && isMemberViewActive(location.search, view.filter.filter) + isActive: isMemberViewActive(location.pathname, location.search, view.filter.filter) })); - }, [isOnMembersListRoute, location.search, sharedViews]); + }, [location.pathname, location.search, sharedViews]); } diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 2ce152b7e1a..acbfb80c2b2 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -2,7 +2,6 @@ import React from "react" import {SidebarGroup, SidebarGroupContent, SidebarMenu, SidebarMenuBadge} from "@tryghost/shade/components" import {formatNumber, LucideIcon} from "@tryghost/shade/utils" -import { useLocation } from "@tryghost/admin-x-framework"; import { useCurrentUser } from "@tryghost/admin-x-framework/api/current-user"; import { canManageMembers, canManageTags } from "@tryghost/admin-x-framework/api/users"; import { NavMenuItem } from "./nav-menu-item"; @@ -15,8 +14,6 @@ import { useCustomSidebarViews } from "./use-custom-sidebar-views"; import { useEmberRouting } from "@/ember-bridge"; import { useFeatureFlag } from "@/hooks/use-feature-flag"; -const LEGACY_MEMBERS_SUBROUTES = ['member', 'member.new', 'members-activity']; - function PostsNavItemContent({isActive, to}: {isActive: boolean; to: string}) { return ( <> @@ -73,15 +70,12 @@ function NavContent({ ...props }: React.ComponentProps) { const [savedMembersExpanded, setMembersExpanded] = useNavigationExpanded('members'); const postCustomViews = useCustomSidebarViews('posts'); const memberViews = useMemberSidebarViews(); - const hasMemberViews = memberViews.length > 0; - const location = useLocation(); const memberCount = useMemberCount(); const routing = useEmberRouting(); const commentModerationEnabled = useFeatureFlag('commentModeration'); const membersForwardEnabled = useFeatureFlag('membersForward'); - const normalizedPathname = location.pathname.replace(/\/+$/, '') || '/'; - const isReactMembersListRouteActive = normalizedPathname === '/members'; - const isReactMembersImportRouteActive = normalizedPathname === '/members/import'; + const visibleMemberViews = membersForwardEnabled ? memberViews : []; + const hasMemberViews = visibleMemberViews.length > 0; const showTags = currentUser && canManageTags(currentUser); const showMembers = currentUser && canManageMembers(currentUser); @@ -90,14 +84,13 @@ function NavContent({ ...props }: React.ComponentProps) { const isPublishedPostsRouteActive = routing.isRouteActive('posts', {type: 'published'}); const hasActivePostChild = isDraftPostsRouteActive || isScheduledPostsRouteActive || isPublishedPostsRouteActive || postCustomViews.some(view => view.isActive); const postsExpanded = savedPostsExpanded; - const hasActiveMemberChild = membersForwardEnabled && hasMemberViews && memberViews.some(view => view.isActive); + const hasActiveMemberChild = visibleMemberViews.some(view => view.isActive); const membersExpanded = savedMembersExpanded; - const isLegacyMembersRouteActive = routing.isRouteActive(LEGACY_MEMBERS_SUBROUTES) || (!membersForwardEnabled && routing.isRouteActive('members')); - const isMembersBaseRouteActive = isLegacyMembersRouteActive || isReactMembersImportRouteActive || (isReactMembersListRouteActive && !hasActiveMemberChild); + const isMembersBaseRouteActive = routing.isRouteActive(['members', 'member', 'member.new', 'members-activity']); const postsRoute = routing.getRouteUrl('posts'); const isPostsRouteActive = routing.isRouteActive('posts'); const postsNavActive = isPostsRouteActive || (!postsExpanded && hasActivePostChild); - const membersNavActive = isMembersBaseRouteActive || (!membersExpanded && hasActiveMemberChild); + const membersNavActive = (isMembersBaseRouteActive && !hasActiveMemberChild) || (!membersExpanded && hasActiveMemberChild); const membersRoute = routing.getRouteUrl('members'); return ( @@ -175,7 +168,7 @@ function NavContent({ ...props }: React.ComponentProps) { {showMembers && ( <> - {membersForwardEnabled && hasMemberViews ? ( + {hasMemberViews ? (
@@ -231,3 +232,4 @@ @modifier="action wide" /> {{/if}} +{{/unless}} diff --git a/ghost/admin/tests/acceptance/members-test.js b/ghost/admin/tests/acceptance/members-test.js index 7df57218ceb..005e1358179 100644 --- a/ghost/admin/tests/acceptance/members-test.js +++ b/ghost/admin/tests/acceptance/members-test.js @@ -2,6 +2,7 @@ import moment from 'moment-timezone'; import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support'; import {beforeEach, describe, it} from 'mocha'; import {blur, click, currentURL, fillIn, find, findAll} from '@ember/test-helpers'; +import {enableLabsFlag} from '../helpers/labs-flag'; import {expect} from 'chai'; import {setupApplicationTest} from 'ember-mocha'; import {setupMirage} from 'ember-cli-mirage/test-support'; @@ -38,6 +39,20 @@ describe('Acceptance: Members Test', function () { await authenticateSession(); }); + it('does not load or render the Ember members list when membersForward is enabled', async function () { + enableLabsFlag(this.server, 'membersForward'); + this.server.createList('member', 2); + + await visit('/members'); + + expect(currentURL()).to.equal('/members'); + expect(find('[data-test-screen-title]')).to.not.exist; + expect(find('[data-test-table="members"]')).to.not.exist; + + const membersRequests = this.server.pretender.handledRequests.filter(request => request.url.match(/\/members\/(\?|$)/)); + expect(membersRequests.length, 'members API requests').to.equal(0); + }); + it('it renders, can be navigated, can edit member', async function () { let member1 = this.server.create('member', {createdAt: moment.utc().subtract(1, 'day').format('YYYY-MM-DD HH:mm:ss')}); this.server.create('member', {createdAt: moment.utc().subtract(2, 'day').format('YYYY-MM-DD HH:mm:ss')}); From 54338d5a66bb3e730877b6ee6cfdfa2ceb0e484c Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Wed, 8 Apr 2026 08:12:31 +0200 Subject: [PATCH 29/29] Reduced members list page test churn ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release The extra members page scoping in the React members page object was only there to work around hidden Ember rendering. With the Ember render gating fixed, the helper can go back to using the stable list and action locators directly. --- .../pages/admin/members/members-list-page.ts | 24 ++++++++----------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/e2e/helpers/pages/admin/members/members-list-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts index 02f0ee9344b..0cc491cc6ab 100644 --- a/e2e/helpers/pages/admin/members/members-list-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -16,8 +16,6 @@ export interface MembersListSurface { } export class MembersListPage extends AdminPage implements MembersListSurface { - readonly membersPage: Locator; - readonly membersList: Locator; readonly memberRows: Locator; readonly searchInput: Locator; readonly actionsButton: Locator; @@ -32,17 +30,15 @@ export class MembersListPage extends AdminPage implements MembersListSurface { super(page); this.pageUrl = '/ghost/#/members'; - this.membersPage = page.getByTestId('members-page'); - this.membersList = this.membersPage.getByTestId('members-list'); - this.memberRows = this.membersList.getByTestId('members-list-item'); - this.searchInput = this.membersPage.getByTestId('members-search-input'); - this.actionsButton = this.membersPage.getByTestId('members-actions'); - this.newMemberButton = this.membersPage.getByRole('link', {name: 'New member'}); - this.filterButton = this.membersPage.getByRole('button', {name: /^(Filter|Add filter)$/}); - this.clearFiltersButton = this.membersPage.getByRole('button', {name: 'Clear'}); - this.emptyState = this.membersPage.getByText('No members yet'); - this.noResults = this.membersPage.getByText('No matching members found.'); - this.showAllButton = this.membersPage.getByRole('button', {name: 'Show all members'}); + this.memberRows = page.getByTestId('members-list-item'); + this.searchInput = page.getByTestId('members-search-input'); + this.actionsButton = page.getByTestId('members-actions'); + this.newMemberButton = page.getByRole('link', {name: 'New member'}); + this.filterButton = page.getByRole('button', {name: /^(Filter|Add filter)$/}); + this.clearFiltersButton = page.getByRole('button', {name: 'Clear'}); + this.emptyState = page.getByText('No members yet'); + this.noResults = page.getByText('No matching members found.'); + this.showAllButton = page.getByRole('button', {name: 'Show all members'}); } getMemberByName(name: string): Locator { @@ -72,7 +68,7 @@ export class MembersListPage extends AdminPage implements MembersListSurface { } async saveCurrentView(name: string): Promise { - await this.membersPage.getByRole('button', {name: 'Save view'}).click(); + await this.page.getByRole('button', {name: 'Save view'}).click(); const dialog = this.page.getByRole('dialog'); await dialog.waitFor({state: 'visible'}); await dialog.getByRole('textbox', {name: 'View name'}).fill(name);