From fd386c5e4b23026bed33e770959d6b0ae8a14949 Mon Sep 17 00:00:00 2001 From: Jonatan Svennberg Date: Tue, 7 Apr 2026 21:54:07 +0200 Subject: [PATCH 01/13] Removed members-forward release gate ref https://linear.app/ghost/issue/BER-3361/remove-feature-flag-release This releases the React-owned /members routes while keeping Ember detail flows intact and deferring dead-code cleanup to the next stacked PR. --- .../advanced/labs/private-features.tsx | 4 -- .../src/layout/app-sidebar/nav-content.tsx | 19 +++-- apps/admin/src/members-route-gate.tsx | 13 ---- apps/admin/src/members-route.test.tsx | 9 +-- apps/admin/src/members-route.tsx | 5 +- apps/admin/src/routes.test.tsx | 72 +++++++++++++++++++ apps/admin/src/routes.tsx | 11 --- .../members/components/members-actions.tsx | 4 +- .../members/components/members-list-item.tsx | 28 ++++++-- .../views/members/components/members-list.tsx | 3 + apps/posts/src/views/members/members.tsx | 4 +- .../views/members/members-actions.test.tsx | 23 +++++- .../admin/members/member-details-page.ts | 2 + .../members-legacy/disable-commenting.test.ts | 4 -- .../members-legacy/filter-actions.test.ts | 2 - .../members-legacy/impersonation.test.ts | 2 - e2e/tests/admin/members-legacy/import.test.ts | 2 - .../member-activity-events.test.ts | 2 - .../admin/members-legacy/members.test.ts | 2 - .../members-legacy/stripe-webhooks.test.ts | 4 +- e2e/tests/admin/members/bulk-actions.test.ts | 2 - e2e/tests/admin/members/list.test.ts | 29 ++++++-- .../admin/members/multiselect-filters.test.ts | 4 +- e2e/tests/admin/members/saved-views.test.ts | 2 - .../admin/members/search-and-filter.test.ts | 2 - .../admin/members/tier-filter-search.test.ts | 2 - .../admin/members/virtual-window.test.ts | 2 - .../dashboard/onboarding-checklist.hbs | 6 +- .../editor/publish-options/publish-type.hbs | 4 +- .../app/components/posts-list/list-item.hbs | 12 ++-- ghost/admin/app/controllers/member.js | 22 +++++- ghost/admin/app/router.js | 3 - ghost/admin/app/routes/member.js | 6 +- ghost/admin/app/routes/members.js | 1 - ghost/admin/app/services/feature.js | 1 - ghost/admin/app/templates/member.hbs | 8 +-- ghost/admin/tests/acceptance/members-test.js | 2 +- .../tests/acceptance/members/filter-test.js | 2 +- .../tests/acceptance/members/import-test.js | 2 +- ghost/core/core/shared/labs.js | 1 - .../admin/__snapshots__/config.test.js.snap | 1 - ghost/core/test/unit/shared/labs.test.js | 4 ++ 42 files changed, 217 insertions(+), 116 deletions(-) delete mode 100644 apps/admin/src/members-route-gate.tsx create mode 100644 apps/admin/src/routes.test.tsx diff --git a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx index d17e18b7896..fd7056add9c 100644 --- a/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin-x-settings/src/components/settings/advanced/labs/private-features.tsx @@ -47,10 +47,6 @@ const features: Feature[] = [{ title: 'Featurebase Feedback', description: 'Display a Feedback menu item in the admin sidebar. Requires the new admin experience.', flag: 'featurebaseFeedback' -}, { - title: 'Members Forward', - description: 'Use the new React-based members list instead of the Ember implementation', - flag: 'membersForward' }, { title: 'Drip Sequences', description: 'Enable welcome email drip sequences', diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index acbfb80c2b2..6850fa64ee9 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -2,6 +2,7 @@ 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"; @@ -14,6 +15,8 @@ import { useCustomSidebarViews } from "./use-custom-sidebar-views"; import { useEmberRouting } from "@/ember-bridge"; import { useFeatureFlag } from "@/hooks/use-feature-flag"; +const LEGACY_MEMBERS_ACTIVE_ROUTES = ['member', 'member.new', 'members-activity']; + function PostsNavItemContent({isActive, to}: {isActive: boolean; to: string}) { return ( <> @@ -70,12 +73,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 normalizedPathname = location.pathname.replace(/\/+$/, '') || '/'; const showTags = currentUser && canManageTags(currentUser); const showMembers = currentUser && canManageMembers(currentUser); @@ -84,14 +87,16 @@ 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 = visibleMemberViews.some(view => view.isActive); + const isOnMembersRoute = normalizedPathname === '/members' || normalizedPathname === '/members/import'; + const hasActiveMemberView = hasMemberViews && memberViews.some(view => view.isActive); const membersExpanded = savedMembersExpanded; - const isMembersBaseRouteActive = routing.isRouteActive(['members', 'member', 'member.new', 'members-activity']); + const membersNavActive = isOnMembersRoute + ? (!hasActiveMemberView || !membersExpanded) + : routing.isRouteActive(LEGACY_MEMBERS_ACTIVE_ROUTES); const postsRoute = routing.getRouteUrl('posts'); const isPostsRouteActive = routing.isRouteActive('posts'); const postsNavActive = isPostsRouteActive || (!postsExpanded && hasActivePostChild); - const membersNavActive = (isMembersBaseRouteActive && !hasActiveMemberChild) || (!membersExpanded && hasActiveMemberChild); - const membersRoute = routing.getRouteUrl('members'); + const membersRoute = 'members'; return ( diff --git a/apps/admin/src/members-route-gate.tsx b/apps/admin/src/members-route-gate.tsx deleted file mode 100644 index ee1ecd56a1b..00000000000 --- a/apps/admin/src/members-route-gate.tsx +++ /dev/null @@ -1,13 +0,0 @@ -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/members-route.test.tsx b/apps/admin/src/members-route.test.tsx index d58855aa622..a8e90fb894a 100644 --- a/apps/admin/src/members-route.test.tsx +++ b/apps/admin/src/members-route.test.tsx @@ -9,6 +9,7 @@ const {mockCanManageMembers, mockUseCurrentUser} = vi.hoisted(() => ({ })); vi.mock('@tryghost/admin-x-framework', () => ({ + Outlet: () => React.createElement('div', {'data-testid': 'outlet'}), Navigate: ({replace, to}: {replace?: boolean; to: string}) => React.createElement('div', { 'data-replace': String(Boolean(replace)), 'data-testid': 'navigate', @@ -24,10 +25,6 @@ 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); @@ -41,10 +38,10 @@ describe('MembersRoute', () => { }); }); - it('renders the members route gate for authorized users', () => { + it('renders the nested members routes for authorized users', () => { render(); - expect(screen.getByTestId('members-route-gate')).toBeInTheDocument(); + expect(screen.getByTestId('outlet')).toBeInTheDocument(); }); it('redirects users without member permissions to home', () => { diff --git a/apps/admin/src/members-route.tsx b/apps/admin/src/members-route.tsx index bc01a046359..ebff1ed35ae 100644 --- a/apps/admin/src/members-route.tsx +++ b/apps/admin/src/members-route.tsx @@ -1,7 +1,6 @@ -import {Navigate} from "@tryghost/admin-x-framework"; +import {Navigate, Outlet} 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(); @@ -18,5 +17,5 @@ export function MembersRoute() { return ; } - return ; + return ; } diff --git a/apps/admin/src/routes.test.tsx b/apps/admin/src/routes.test.tsx new file mode 100644 index 00000000000..bc045b17424 --- /dev/null +++ b/apps/admin/src/routes.test.tsx @@ -0,0 +1,72 @@ +import {describe, expect, it, vi} from 'vitest'; + +vi.mock('@tryghost/admin-x-framework', () => ({ + Outlet: () => null, + lazyComponent: (loader: unknown) => loader, + redirect: (to: string) => ({to}) +})); + +vi.mock('@tryghost/activitypub/src/index', () => ({ + FeatureFlagsProvider: () => null, + routes: [] +})); + +vi.mock('@tryghost/posts/src/providers/posts-app-context', () => ({ + default: ({children}: {children: React.ReactNode}) => children +})); + +vi.mock('@tryghost/posts/src/routes', () => ({ + routes: [{ + children: [] + }] +})); + +vi.mock('@tryghost/stats/src/providers/global-data-provider', () => ({ + default: ({children}: {children: React.ReactNode}) => children +})); + +vi.mock('@tryghost/stats/src/routes', () => ({ + routes: [] +})); + +vi.mock('./my-profile-redirect', () => ({ + default: () => null +})); + +vi.mock('./ember-bridge', () => ({ + EmberFallback: () => null, + ForceUpgradeGuard: () => null +})); + +vi.mock('./members-route', () => ({ + MembersRoute: () => null +})); + +vi.mock('./not-found', () => ({ + NotFound: () => null +})); + +import {routes} from './routes'; + +type RouteNode = { + path?: string; + children?: RouteNode[]; +}; + +function collectPaths(routeObjects: RouteNode[]): string[] { + return routeObjects.flatMap((route): string[] => { + const paths = route.path ? [route.path] : []; + + if (!route.children) { + return paths; + } + + return [...paths, ...collectPaths(route.children)]; + }); +} + +describe('admin routes', () => { + it('does not register the removed members-forward route', () => { + expect(collectPaths(routes)).not.toContain('/members-forward'); + }); +}); diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index 86992ee5347..09e5fad6f2b 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -72,16 +72,6 @@ const membersRoute: RouteObject = { ] }; -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. @@ -99,7 +89,6 @@ export const routes: RouteObject[] = [ handle: emberFallbackHandle, }, membersRoute, - membersForwardRedirectRoute, { element: ( diff --git a/apps/posts/src/views/members/components/members-actions.tsx b/apps/posts/src/views/members/components/members-actions.tsx index fb61f184a2a..9ba05416c38 100644 --- a/apps/posts/src/views/members/components/members-actions.tsx +++ b/apps/posts/src/views/members/components/members-actions.tsx @@ -44,6 +44,8 @@ const MembersActions: React.FC = ({ const navigate = useNavigate(); const isImportRoute = location.pathname === '/members/import'; const currentSearch = location.search ?? ''; + const membersBackPath = location.pathname === '/members' ? `${location.pathname}${currentSearch}` : '/members'; + const newMemberHref = `#/members/new?back=${encodeURIComponent(membersBackPath)}`; const [showAddLabelModal, setShowAddLabelModal] = useState(false); const [showRemoveLabelModal, setShowRemoveLabelModal] = useState(false); const [showUnsubscribeModal, setShowUnsubscribeModal] = useState(false); @@ -264,7 +266,7 @@ const MembersActions: React.FC = ({ {/* New Member Button - styled like Tags */}