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..7d63696319b 100644 --- a/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts +++ b/apps/admin/src/layout/app-sidebar/member-sidebar-views.ts @@ -15,17 +15,20 @@ 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) { +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 isOnMembersForward = location.pathname === '/members-forward'; 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: isOnMembersForward && isMemberViewActive(location.search, view.filter.filter) + isActive: isMemberViewActive(location.pathname, location.search, view.filter.filter) })); - }, [isOnMembersForward, location.search, sharedViews]); + }, [location.pathname, 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 deleted file mode 100644 index 6e6e7df76e2..00000000000 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -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', () => { - expect(getMembersNavActiveRoutes()).toEqual([ - 'members-forward', - 'members', - 'member', - 'member.new' - ]); - }); -}); - -describe('isMembersNavActive', () => { - it('uses the legacy route active state when members forward is disabled', () => { - expect(isMembersNavActive({ - membersForwardEnabled: false, - isOnMembersForward: 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, - isOnMembersForward: 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, - isOnMembersForward: true, - hasActiveMemberView: true, - isMembersExpanded: true, - isLegacyMembersRouteActive: false - })).toBe(false); - }); - - it('falls back to the base Members link when no saved member view is active', () => { - expect(isMembersNavActive({ - membersForwardEnabled: true, - isOnMembersForward: 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 ba946c9c852..00000000000 --- a/apps/admin/src/layout/app-sidebar/nav-content.helpers.ts +++ /dev/null @@ -1,32 +0,0 @@ -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']; -} - -export function isMembersNavActive({ - membersForwardEnabled, - isOnMembersForward, - hasActiveMemberView, - isMembersExpanded, - isLegacyMembersRouteActive -}: { - membersForwardEnabled: boolean; - isOnMembersForward: boolean; - hasActiveMemberView: boolean; - isMembersExpanded: boolean; - isLegacyMembersRouteActive: boolean; -}): boolean { - if (!membersForwardEnabled) { - return isLegacyMembersRouteActive; - } - - if (isOnMembersForward) { - 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 5ac5d5fc56f..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"; @@ -11,7 +10,6 @@ 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 { useCustomSidebarViews } from "./use-custom-sidebar-views"; import { useEmberRouting } from "@/ember-bridge"; import { useFeatureFlag } from "@/hooks/use-feature-flag"; @@ -72,12 +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 visibleMemberViews = membersForwardEnabled ? memberViews : []; + const hasMemberViews = visibleMemberViews.length > 0; const showTags = currentUser && canManageTags(currentUser); const showMembers = currentUser && canManageMembers(currentUser); @@ -86,20 +84,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 isOnMembersForward = location.pathname === '/members-forward'; - const hasActiveMemberView = isOnMembersForward && memberViews.some(view => view.isActive); + const hasActiveMemberChild = visibleMemberViews.some(view => view.isActive); const membersExpanded = savedMembersExpanded; - const membersNavActive = isMembersNavActive({ - membersForwardEnabled, - isOnMembersForward, - hasActiveMemberView, - isMembersExpanded: membersExpanded, - isLegacyMembersRouteActive: routing.isRouteActive(getMembersNavActiveRoutes()) - }); + 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 membersRoute = membersForwardEnabled ? 'members-forward' : routing.getRouteUrl('members'); + const membersNavActive = (isMembersBaseRouteActive && !hasActiveMemberChild) || (!membersExpanded && hasActiveMemberChild); + const membersRoute = routing.getRouteUrl('members'); return ( @@ -176,7 +168,7 @@ function NavContent({ ...props }: React.ComponentProps) { {showMembers && ( <> - {membersForwardEnabled && hasMemberViews ? ( + {hasMemberViews ? ( ; + } + + return ; +} 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 6c0f7a3a9c2..86992ee5347 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 { MembersRoute } from "./members-route"; 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,33 @@ 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: ({request}) => { + const url = new URL(request.url); + return redirect(`/members${url.search}`); + } +}; + export const routes: RouteObject[] = [ { // ForceUpgradeGuard wraps all routes to redirect to /pro when in force upgrade mode. @@ -69,6 +98,8 @@ export const routes: RouteObject[] = [ Component: EmberFallback, handle: emberFallbackHandle, }, + membersRoute, + membersForwardRedirectRoute, { element: ( 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/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..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,5 +1,6 @@ import {CompleteStep, ErrorStep, InitStep, MappingStep, ProcessingStep} from './import-members/components'; 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'; @@ -12,13 +13,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 +32,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 +56,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 +265,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..fb61f184a2a 100644 --- a/apps/posts/src/views/members/components/members-actions.tsx +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -1,12 +1,15 @@ 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 {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'; 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 +17,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 +40,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 +185,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}]` + }), {replace: true}); + return; + } + + navigate(`/members${currentSearch}`, {replace: true}); + }, [currentSearch, navigate]); + return ( <> {/* Actions Dropdown */} @@ -190,7 +217,7 @@ const MembersActions: React.FC = ({ {/* Import */} - setShowImportModal(true)}> + Import members @@ -245,9 +272,10 @@ const MembersActions: React.FC = ({ {/* Modals */} = ({ 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 db49ac57868..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,15 +52,38 @@ class MockFileReader { } } +const originalCreateObjectURL = URL.createObjectURL; +const originalRevokeObjectURL = URL.revokeObjectURL; + describe('ImportMembersModal', () => { 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(() => { 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/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..7fcfdeba140 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) => { @@ -34,25 +43,63 @@ 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 renderMembersActions = (props: Partial> = {}) => { + return render( + + ); +}; + describe('MembersActions', () => { beforeEach(() => { importModalPropsRef.current = null; + setLocation('/members'); + mockUseNavigate.mockReturnValue(vi.fn()); }); - it('passes onImportComplete to ImportMembersModal onComplete prop', () => { - const onImportComplete = vi.fn(); + 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); + + renderMembersActions(); + + 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?filter=label%3AVIP&search=alice', {replace: true}); + }); - render( - - ); + it('navigates to the imported label filter when the import route modal closes after a labeled import', () => { + const navigate = vi.fn(); + setLocation('/members/import', '?filter=label%3AVIP&search=alice'); + mockUseNavigate.mockReturnValue(navigate); + renderMembersActions(); expect(importModalPropsRef.current).not.toBeNull(); - expect(importModalPropsRef.current?.onComplete).toBe(onImportComplete); + 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', {replace: true}); }); }); 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-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/helpers/pages/admin/members/members-forward-page.ts b/e2e/helpers/pages/admin/members/members-list-page.ts similarity index 77% rename from e2e/helpers/pages/admin/members/members-forward-page.ts rename to e2e/helpers/pages/admin/members/members-list-page.ts index c6347fab225..0cc491cc6ab 100644 --- a/e2e/helpers/pages/admin/members/members-forward-page.ts +++ b/e2e/helpers/pages/admin/members/members-list-page.ts @@ -2,13 +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 MembersForwardPage extends AdminPage { - readonly membersList: Locator; +export interface MembersListSurface { + goto(): Promise; + openActionsMenu(): Promise; + applyLabelFilter(labelName: string): Promise; + getVisibleMemberCount(): Promise; + exportMembers(): Promise; +} + +export class MembersListPage extends AdminPage implements MembersListSurface { readonly memberRows: Locator; readonly searchInput: Locator; readonly actionsButton: Locator; @@ -21,11 +28,10 @@ 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'); - this.searchInput = page.getByLabel('Search members', {exact: true}); + 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)$/}); @@ -36,13 +42,40 @@ export class MembersForwardPage 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 { + 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(); } + 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.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); + 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..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; @@ -138,6 +134,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-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-forward/saved-views.test.ts b/e2e/tests/admin/members-forward/saved-views.test.ts deleted file mode 100644 index cff1f251274..00000000000 --- a/e2e/tests/admin/members-forward/saved-views.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import {MemberFactory, createMemberFactory} from '@/data-factory'; -import {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) { - if (filterName === 'Label') { - const url = new URL(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-forward?${params.toString()}`); - return; - } - - await page.getByRole('button', {name: /^(Filter|Add filter)$/}).click(); - await page.getByRole('option', {name: filterName, exact: true}).click(); - - if (filterName === 'Name') { - await page.getByRole('textbox', {name: 'Enter name...'}).fill(value); - return; - } - - if (filterName === 'Email') { - await page.getByRole('textbox', {name: 'Enter email...'}).fill(value); - return; - } - - await 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'); - 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'}); -} - -test.describe('Ghost Admin - Members Forward Saved Views', () => { - test.use({labs: {membersForward: true}}); - - let memberFactory: MemberFactory; - - test.beforeEach(async ({page}) => { - memberFactory = createMemberFactory(page.request); - }); - - test('exact filter match controls active saved view and falls back to Members', async ({page}) => { - test.slow(); - - await memberFactory.create({ - name: 'Active Nav Member', - email: 'active-nav-member@example.com', - labels: ['Active Nav Label'] - }); - - const sidebar = new SidebarPage(page); - await page.goto('/ghost/#/members-forward'); - - await addFilter(page, 'Name', 'active-nav'); - await saveCurrentView(page, 'View A'); - - await expect(sidebar.getNavLink('View A')).toHaveAttribute('aria-current', 'page'); - - await addFilter(page, 'Email', 'example.com'); - await saveCurrentView(page, '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 expect(sidebar.getNavLink('View A')).not.toHaveAttribute('aria-current', 'page'); - await expect(sidebar.getNavLink('View B')).not.toHaveAttribute('aria-current', 'page'); - await expect(sidebar.getNavLink('Members')).toHaveAttribute('aria-current', 'page'); - }); -}); diff --git a/e2e/tests/admin/members/disable-commenting.test.ts b/e2e/tests/admin/members-legacy/disable-commenting.test.ts similarity index 99% rename from e2e/tests/admin/members/disable-commenting.test.ts rename to e2e/tests/admin/members-legacy/disable-commenting.test.ts index b9ef85d92e8..6bff8279004 100644 --- a/e2e/tests/admin/members/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}) => { @@ -170,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/filter-actions.test.ts b/e2e/tests/admin/members-legacy/filter-actions.test.ts similarity index 94% rename from e2e/tests/admin/members/filter-actions.test.ts rename to e2e/tests/admin/members-legacy/filter-actions.test.ts index f7ff1e15b3d..d4bd4875734 100644 --- a/e2e/tests/admin/members/filter-actions.test.ts +++ b/e2e/tests/admin/members-legacy/filter-actions.test.ts @@ -1,9 +1,14 @@ 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}}); + let memberFactory: MemberFactory; const membersFixture = [ diff --git a/e2e/tests/admin/members/impersonation.test.ts b/e2e/tests/admin/members-legacy/impersonation.test.ts similarity index 96% rename from e2e/tests/admin/members/impersonation.test.ts rename to e2e/tests/admin/members-legacy/impersonation.test.ts index 5adbdd1303e..936f25ac5a4 100644 --- a/e2e/tests/admin/members/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/import.test.ts b/e2e/tests/admin/members-legacy/import.test.ts similarity index 84% rename from e2e/tests/admin/members/import.test.ts rename to e2e/tests/admin/members-legacy/import.test.ts index 3d56569e21d..40f66098178 100644 --- a/e2e/tests/admin/members/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-forward'}); + const importPage = new MembersPage(page, {route: 'members/import'}); + const membersPage = new MembersPage(page); const importModal = new MembersImportModal(page); const timestamp = Date.now(); @@ -26,17 +29,15 @@ test.describe('Ghost Admin - Members Import', () => { const csvPath = join(tmpdir(), `members-import-${timestamp}.csv`); writeFileSync(csvPath, csvContent); - await membersPage.goto(); - await membersPage.membersActionsButton.click(); - await page.getByRole('menuitem', {name: 'Import members'}).click(); + await importPage.goto(); await importModal.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(importModal.getMappingValue('email')).toHaveValue('email'); + await expect(importModal.getMappingValue('name')).toHaveValue('name'); + await expect(importModal.getMappingValue('note')).toHaveValue('note'); await importModal.importButton.click(); @@ -44,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\?filter=label%3A%5Bimport-/); await expect(membersPage.getMemberByName('Alice Test')).toBeVisible({timeout: 30000}); await expect(membersPage.getMemberByName('Bob Test')).toBeVisible(); 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 98% rename from e2e/tests/admin/members/member-activity-events.test.ts rename to e2e/tests/admin/members-legacy/member-activity-events.test.ts index f19463e0777..36efc309a68 100644 --- a/e2e/tests/admin/members/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/members.test.ts b/e2e/tests/admin/members-legacy/members.test.ts similarity index 99% rename from e2e/tests/admin/members/members.test.ts rename to e2e/tests/admin/members-legacy/members.test.ts index 8b27cd7ce85..f5f1ee509bf 100644 --- a/e2e/tests/admin/members/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/stripe-webhooks.test.ts b/e2e/tests/admin/members-legacy/stripe-webhooks.test.ts similarity index 96% rename from e2e/tests/admin/members/stripe-webhooks.test.ts rename to e2e/tests/admin/members-legacy/stripe-webhooks.test.ts index 27a97f41085..af03539f13d 100644 --- a/e2e/tests/admin/members/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-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..558c5afe4fc 100644 --- a/e2e/tests/admin/members/export.test.ts +++ b/e2e/tests/admin/members/export.test.ts @@ -1,137 +1,153 @@ -import {expect, test} from '@/helpers/playwright'; -import {usePerTestIsolation} from '@/helpers/playwright/isolation'; +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(); -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 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'] } - - 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); +]; + +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) + } +]; - 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); +for (const variation of variations) { + test.describe(`Ghost Admin - Members Export (${variation.name})`, () => { + test.use({labs: {membersForward: variation.membersForward}}); - expect(content).toMatch(new RegExp(downloadedContentFields.join(''))); + let memberFactory: MemberFactory; - membersFixture.forEach((member) => { - expect(content).toContain(member.name); - expect(content).toContain(member.email); - expect(content).toContain(member.note); - expect(content).toContain(member.labels[0]); + test.beforeEach(async ({page}) => { + memberFactory = createMemberFactory(page.request); }); - expect(contentIds).toHaveLength(6); - expect(contentTimestamps).toHaveLength(6); + test('exports all members to CSV', async ({page}) => { + await memberFactory.createMany(MEMBERS_FIXTURE); - expect(suggestedFilename.startsWith('members')).toBe(true); - expect(suggestedFilename.endsWith('.csv')).toBe(true); - }); + const membersPage = variation.createPage(page); + await membersPage.goto(); + await membersPage.openActionsMenu(); - test('exports filtered members by label to CSV', async ({page}) => { - await memberFactory.createMany(membersFixture); - const labelToFilterBy = 'dog'; + const {suggestedFilename, content} = await membersPage.exportMembers(); + const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); - const membersPage = new MembersPage(page); - await membersPage.goto(); - await membersPage.filterSection.applyLabel(labelToFilterBy); - await expect(membersPage.memberListItems).toHaveCount(3); + assertExportedMembers(content, MEMBERS_FIXTURE); - await membersPage.membersActionsButton.click(); - await expect(membersPage.exportMembersButton).toContainText('Export selected members'); + expect(contentIds).toHaveLength(MEMBERS_FIXTURE.length); + expect(contentTimestamps).toHaveLength(MEMBERS_FIXTURE.length); + expect(suggestedFilename.startsWith('members')).toBe(true); + expect(suggestedFilename.endsWith('.csv')).toBe(true); + }); - const {suggestedFilename, content} = await membersPage.exportMembers(); - const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); + test('exports filtered members by label to CSV', async ({page}) => { + await memberFactory.createMany(MEMBERS_FIXTURE); - const fixture = membersFixture - .filter(member => member.labels[0] === 'dog'); + const labelToFilterBy = 'dog'; + const filteredMembers = MEMBERS_FIXTURE.filter(member => member.labels[0] === labelToFilterBy); - expect(content).toMatch(new RegExp(downloadedContentFields.join(''))); + 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(); - fixture.forEach((member) => { - expect(content).toContain(member.name); - expect(content).toContain(member.email); - expect(content).toContain(member.note); - expect(content).toContain(labelToFilterBy); - }); + const {suggestedFilename, content} = await membersPage.exportMembers(); + const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content); - expect(contentIds).toHaveLength(3); - expect(contentTimestamps).toHaveLength(3); + assertExportedMembers(content, filteredMembers); - expect(suggestedFilename.startsWith('members')).toBe(true); - expect(suggestedFilename.endsWith('.csv')).toBe(true); + 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-forward/list.test.ts b/e2e/tests/admin/members/list.test.ts similarity index 65% rename from e2e/tests/admin/members-forward/list.test.ts rename to e2e/tests/admin/members/list.test.ts index ff51af5da65..7a2e500cc0d 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; @@ -21,37 +21,49 @@ 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); 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 MembersForwardPage(page); + const membersPage = new MembersListPage(page); await membersPage.goto(); await expect(membersPage.emptyState).toBeVisible(); 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%3A%5BVIP%5D$/); + 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', email: 'detail@example.com' }); - const membersPage = new MembersForwardPage(page); + 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-forward/multiselect-filters.test.ts b/e2e/tests/admin/members/multiselect-filters.test.ts similarity index 94% rename from e2e/tests/admin/members-forward/multiselect-filters.test.ts rename to e2e/tests/admin/members/multiselect-filters.test.ts index 9ff36519e52..23d675dcb5e 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'; @@ -14,9 +14,9 @@ async function seedMembersAndNavigate( memberFactory: MemberFactory, page: Page, members: Partial[] -): Promise { +): 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/saved-views.test.ts b/e2e/tests/admin/members/saved-views.test.ts new file mode 100644 index 00000000000..7dfd65276be --- /dev/null +++ b/e2e/tests/admin/members/saved-views.test.ts @@ -0,0 +1,57 @@ +import {MemberFactory, createMemberFactory} from '@/data-factory'; +import {MembersListPage, SidebarPage} from '@/admin-pages'; +import {expect, test} from '@/helpers/playwright/fixture'; + +async function addFilter(membersPage: MembersListPage, filterName: 'Name' | 'Email' | 'Label', value: string) { + if (filterName === 'Label') { + await membersPage.applyLabelFilter(value); + return; + } + + await membersPage.addFilter(filterName, value); +} + +async function saveCurrentView(membersPage: MembersListPage, name: string) { + await membersPage.saveCurrentView(name); +} + +test.describe('Ghost Admin - Members Saved Views', () => { + test.use({labs: {membersForward: true}}); + + let memberFactory: MemberFactory; + + test.beforeEach(async ({page}) => { + memberFactory = createMemberFactory(page.request); + }); + + test('exact filter match controls active saved view and falls back to Members', async ({page}) => { + test.slow(); + + await memberFactory.create({ + name: 'Active Nav Member', + email: 'active-nav-member@example.com', + labels: ['Active Nav Label'] + }); + + const sidebar = new SidebarPage(page); + const membersPage = new MembersListPage(page); + await page.goto('/ghost/#/members'); + + await addFilter(membersPage, 'Name', 'active-nav'); + await saveCurrentView(membersPage, 'View A'); + + await expect(sidebar.getNavLink('View A')).toHaveAttribute('aria-current', 'page'); + + 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(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'); + await expect(sidebar.getNavLink('Members')).toHaveAttribute('aria-current', 'page'); + }); +}); 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 85% 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..0b91b96334c 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,11 +40,11 @@ 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); - 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(); @@ -58,14 +58,14 @@ 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); 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 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(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(); }); @@ -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 90% rename from e2e/tests/admin/members-forward/tier-filter-search.test.ts rename to e2e/tests/admin/members/tier-filter-search.test.ts index cd85561f0ed..d8a9cc9a9cd 100644 --- a/e2e/tests/admin/members-forward/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 444638c3c2b..c39167c041c 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,15 +77,16 @@ 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 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(), @@ -94,13 +95,13 @@ 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(membersPage.getMemberListItemByIndex(targetRow.index)).toContainText(targetRow.text ?? ''); + await expect(page).toHaveURL(/\/ghost\/#\/members$/); + 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(); 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.js b/ghost/admin/app/routes/members.js index 4487c92e7b3..6ca5d38c579 100644 --- a/ghost/admin/app/routes/members.js +++ b/ghost/admin/app/routes/members.js @@ -16,6 +16,10 @@ export default class MembersRoute extends MembersManagementRoute { }; model(params) { + if (this.feature.membersForward) { + return null; + } + this.controllerFor('members').resetFilters(params); return this.controllerFor('members').fetchMembersTask.perform(params); } @@ -24,6 +28,10 @@ export default class MembersRoute extends MembersManagementRoute { setupController(controller) { super.setupController(...arguments); + if (this.feature.membersForward) { + return; + } + try { controller.fetchLabelsTask.perform(); } catch (e) { diff --git a/ghost/admin/app/routes/members/import.js b/ghost/admin/app/routes/members/import.js index f0455c18fac..23eaf73fad4 100644 --- a/ghost/admin/app/routes/members/import.js +++ b/ghost/admin/app/routes/members/import.js @@ -1,3 +1,4 @@ import MembersManagementRoute from '../members-management'; -export default class MembersImportRoute extends MembersManagementRoute {} +export default class MembersImportRoute extends MembersManagementRoute { +} 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/app/templates/members.hbs b/ghost/admin/app/templates/members.hbs index 81a1b6f1244..57249e9e78d 100644 --- a/ghost/admin/app/templates/members.hbs +++ b/ghost/admin/app/templates/members.hbs @@ -1,3 +1,4 @@ +{{#unless this.feature.membersForward}}
@@ -231,3 +232,4 @@ @modifier="action wide" /> {{/if}} +{{/unless}} 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-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')}); diff --git a/ghost/admin/tests/acceptance/members/import-test.js b/ghost/admin/tests/acceptance/members/import-test.js index 1e11fcd6e2e..914d2f909d8 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,24 @@ 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('members.import'); + 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('members.import'); + expect(currentURL()).to.equal('/members/import?filter=label%3AVIP&search=alice'); + }); }); describe ('super editors functions', function () { beforeEach(async function () { @@ -225,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'); + }); }); -}); \ No newline at end of file +});