Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
006d371
Changed members routes to use feature-gated rendering
jonatansberg Apr 1, 2026
bc05ca9
Fixed member e2e routes after suite reshuffle
jonatansberg Apr 1, 2026
171015d
Reduced duplicate members route tests
jonatansberg Apr 1, 2026
4ad2087
Simplified members nav active route handling
jonatansberg Apr 1, 2026
fb4107c
Restored members React e2e coverage shape
jonatansberg Apr 1, 2026
ea2bff4
Fixed members e2e search selector
jonatansberg Apr 1, 2026
60dca51
Fixed members review issues and e2e scoping
jonatansberg Apr 1, 2026
c62d580
Fixed members route regressions after rebase
jonatansberg Apr 2, 2026
473f258
Fixed members e2e helper scoping after rebase
jonatansberg Apr 2, 2026
892eac6
Fixed members-forward redirect assertion
jonatansberg Apr 2, 2026
a02a97b
Fixed members legacy e2e failures
jonatansberg Apr 2, 2026
866378d
Prevented dual members import modals
jonatansberg Apr 2, 2026
44321fa
Fixed shade imports after rebase
jonatansberg Apr 7, 2026
5723454
Added members route access control
jonatansberg Apr 7, 2026
b075b69
Fixed legacy import mapping assertions
jonatansberg Apr 7, 2026
996f9a6
Fixed legacy import redirect assertion
jonatansberg Apr 7, 2026
1084372
Fixed legacy import hash-route assertion
jonatansberg Apr 7, 2026
c708e85
Fixed members import route handoff
jonatansberg Apr 7, 2026
9f9400b
Fixed members saved views e2e selectors
jonatansberg Apr 7, 2026
90aea91
Simplified members sidebar active state
jonatansberg Apr 7, 2026
607038c
Fixed members import modal ownership gating
jonatansberg Apr 7, 2026
501f508
Removed stale inAdminForward import gating
jonatansberg Apr 7, 2026
00d337a
Removed trivial members route gate test
jonatansberg Apr 7, 2026
db66fc1
Reduced duplicate members test churn
jonatansberg Apr 7, 2026
1557afb
Cleaned members e2e duplication
jonatansberg Apr 7, 2026
9ff9e03
Removed redundant members import modal gate
jonatansberg Apr 7, 2026
041886b
Stopped Ember members import modal rendering on React route
jonatansberg Apr 7, 2026
84466ce
Simplified members nav state and Ember list opt-out
jonatansberg Apr 8, 2026
54338d5
Reduced members list page test churn
jonatansberg Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions apps/admin/src/layout/app-sidebar/member-sidebar-views.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ function isMemberSidebarView(view: SharedView): view is MemberSidebarView {
}

function getMemberViewUrl(filter: string) {
return `members-forward?${new URLSearchParams({filter}).toString()}`;
return `members?${new URLSearchParams({filter}).toString()}`;
}

function isMemberViewActive(currentSearch: string, filter: string) {
Expand All @@ -25,7 +25,7 @@ function isMemberViewActive(currentSearch: string, filter: string) {
export function useMemberSidebarViews() {
const location = useLocation();
const sharedViews = useSharedViews('members');
const isOnMembersForward = location.pathname === '/members-forward';
const isOnMembersListRoute = location.pathname === '/members';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize members path when computing saved-view active state

The members route gate now explicitly treats /members/ as the members list, but this check only matches '/members', so a trailing-slash URL leaves all member saved views inactive even when the filter matches. This creates inconsistent sidebar state for bookmarked/manual /members/ URLs; normalizing trailing slashes here (as done in the gate) avoids that mismatch.

Useful? React with 👍 / 👎.


return useMemo<NavSavedView[]>(() => {
return sharedViews
Expand All @@ -34,7 +34,7 @@ export function useMemberSidebarViews() {
key: `${view.name}:${view.filter.filter}`,
name: view.name,
to: getMemberViewUrl(view.filter.filter),
isActive: isOnMembersForward && isMemberViewActive(location.search, view.filter.filter)
isActive: isOnMembersListRoute && isMemberViewActive(location.search, view.filter.filter)
}));
}, [isOnMembersForward, location.search, sharedViews]);
}, [isOnMembersListRoute, location.search, sharedViews]);
}
25 changes: 7 additions & 18 deletions apps/admin/src/layout/app-sidebar/nav-content.helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,11 @@
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'
]);
});
});
import {isMembersNavActive} from './nav-content.helpers';

describe('isMembersNavActive', () => {
it('uses the legacy route active state when members forward is disabled', () => {
it('uses the legacy route active state when the feature flag is disabled', () => {
expect(isMembersNavActive({
membersForwardEnabled: false,
isOnMembersForward: false,
isOnMembersRoute: false,
hasActiveMemberView: false,
isMembersExpanded: false,
isLegacyMembersRouteActive: true
Expand All @@ -26,7 +15,7 @@ describe('isMembersNavActive', () => {
it('marks the base Members link active when a saved member view is active but collapsed', () => {
expect(isMembersNavActive({
membersForwardEnabled: true,
isOnMembersForward: true,
isOnMembersRoute: true,
hasActiveMemberView: true,
isMembersExpanded: false,
isLegacyMembersRouteActive: false
Expand All @@ -36,17 +25,17 @@ describe('isMembersNavActive', () => {
it('marks the base Members link inactive when a saved member view is active and expanded', () => {
expect(isMembersNavActive({
membersForwardEnabled: true,
isOnMembersForward: true,
isOnMembersRoute: true,
hasActiveMemberView: true,
isMembersExpanded: true,
isLegacyMembersRouteActive: false
})).toBe(false);
});

it('falls back to the base Members link when no saved member view is active', () => {
it('marks the base Members link active on React-owned members routes when no saved member view is active', () => {
expect(isMembersNavActive({
membersForwardEnabled: true,
isOnMembersForward: true,
isOnMembersRoute: true,
hasActiveMemberView: false,
isMembersExpanded: false,
isLegacyMembersRouteActive: false
Expand Down
11 changes: 3 additions & 8 deletions apps/admin/src/layout/app-sidebar/nav-content.helpers.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,12 @@
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,
isOnMembersRoute,
hasActiveMemberView,
isMembersExpanded,
isLegacyMembersRouteActive
}: {
membersForwardEnabled: boolean;
isOnMembersForward: boolean;
isOnMembersRoute: boolean;
hasActiveMemberView: boolean;
isMembersExpanded: boolean;
isLegacyMembersRouteActive: boolean;
Expand All @@ -20,7 +15,7 @@ export function isMembersNavActive({
return isLegacyMembersRouteActive;
}

if (isOnMembersForward) {
if (isOnMembersRoute) {
if (!hasActiveMemberView) {
return true;
}
Expand Down
14 changes: 8 additions & 6 deletions apps/admin/src/layout/app-sidebar/nav-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ import { useNavigationExpanded } from "./hooks/use-navigation-preferences";
import { NavCustomViews } from "./nav-custom-views";
import { NavMemberViews } from "./nav-member-views";
import { useMemberSidebarViews } from "./member-sidebar-views";
import { getMembersNavActiveRoutes, isMembersNavActive } from "./nav-content.helpers";
import { isMembersNavActive } from "./nav-content.helpers";
import { useCustomSidebarViews } from "./use-custom-sidebar-views";
import { useEmberRouting } from "@/ember-bridge";
import { useFeatureFlag } from "@/hooks/use-feature-flag";

const LEGACY_MEMBERS_ACTIVE_ROUTES = ['members', 'member', 'member.new'];

function PostsNavItemContent({isActive, to}: {isActive: boolean; to: string}) {
return (
<>
Expand Down Expand Up @@ -86,20 +88,20 @@ function NavContent({ ...props }: React.ComponentProps<typeof SidebarGroup>) {
const isPublishedPostsRouteActive = routing.isRouteActive('posts', {type: 'published'});
const hasActivePostChild = isDraftPostsRouteActive || isScheduledPostsRouteActive || isPublishedPostsRouteActive || postCustomViews.some(view => view.isActive);
const postsExpanded = savedPostsExpanded;
const isOnMembersForward = location.pathname === '/members-forward';
const hasActiveMemberView = isOnMembersForward && memberViews.some(view => view.isActive);
const isOnMembersRoute = location.pathname === '/members' || location.pathname === '/members/import';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Normalize members pathname before computing nav active state

The new check only treats '/members' and '/members/import' as React-owned members routes, so a valid trailing-slash URL like /members/ is treated as off-route and isMembersNavActive falls back to Ember route state, leaving the Members nav item inactive. This is a regression for bookmarked/manual URLs (and members-route-gate.test.tsx now explicitly treats /members/ as a members route), so the pathname should be normalized (for example by trimming a trailing slash) before this comparison.

Useful? React with 👍 / 👎.

const hasActiveMemberView = memberViews.some(view => view.isActive);
const membersExpanded = savedMembersExpanded;
const membersNavActive = isMembersNavActive({
membersForwardEnabled,
isOnMembersForward,
isOnMembersRoute,
hasActiveMemberView,
isMembersExpanded: membersExpanded,
isLegacyMembersRouteActive: routing.isRouteActive(getMembersNavActiveRoutes())
isLegacyMembersRouteActive: routing.isRouteActive(LEGACY_MEMBERS_ACTIVE_ROUTES)
});
const postsRoute = routing.getRouteUrl('posts');
const isPostsRouteActive = routing.isRouteActive('posts');
const postsNavActive = isPostsRouteActive || (!postsExpanded && hasActivePostChild);
const membersRoute = membersForwardEnabled ? 'members-forward' : routing.getRouteUrl('members');
const membersRoute = routing.getRouteUrl('members');

return (
<SidebarGroup {...props}>
Expand Down
54 changes: 54 additions & 0 deletions apps/admin/src/members-route-gate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {render, screen} from '@testing-library/react';
import React from 'react';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {MembersRouteGate} from './members-route-gate';

const {mockUseLocation, mockUseFeatureFlag} = vi.hoisted(() => ({
mockUseLocation: vi.fn(),
mockUseFeatureFlag: vi.fn()
}));

vi.mock('@tryghost/admin-x-framework', () => ({
Outlet: () => React.createElement('div', {'data-testid': 'outlet'}),
useLocation: mockUseLocation
}));

vi.mock('./ember-bridge', () => ({
EmberFallback: () => React.createElement('div', {'data-testid': 'ember-fallback'})
}));

vi.mock('./hooks/use-feature-flag', () => ({
useFeatureFlag: mockUseFeatureFlag
}));

describe('MembersRouteGate', () => {
beforeEach(() => {
mockUseFeatureFlag.mockReturnValue(false);
mockUseLocation.mockReturnValue({pathname: '/members'});
});

it('delegates /members/ to Ember when the flag is disabled', () => {
mockUseLocation.mockReturnValue({pathname: '/members/'});

render(<MembersRouteGate />);

expect(screen.getByTestId('ember-fallback')).toBeInTheDocument();
});

it('delegates /members/import to Ember when the flag is disabled', () => {
mockUseLocation.mockReturnValue({pathname: '/members/import'});

render(<MembersRouteGate />);

expect(screen.getByTestId('ember-fallback')).toBeInTheDocument();
});

it('renders React routes when the flag is enabled', () => {
mockUseFeatureFlag.mockReturnValue(true);
mockUseLocation.mockReturnValue({pathname: '/members/import'});

render(<MembersRouteGate />);

expect(screen.getByTestId('outlet')).toBeInTheDocument();
});
});
13 changes: 13 additions & 0 deletions apps/admin/src/members-route-gate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {Outlet} from "@tryghost/admin-x-framework";
import {EmberFallback} from "./ember-bridge";
import {useFeatureFlag} from "./hooks/use-feature-flag";

export function MembersRouteGate() {
const membersForwardEnabled = useFeatureFlag("membersForward");

if (!membersForwardEnabled) {
return <EmberFallback />;
}

return <Outlet />;
}
82 changes: 82 additions & 0 deletions apps/admin/src/members-route.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MembersRoute />);

expect(screen.getByTestId('members-route-gate')).toBeInTheDocument();
});

it('redirects users without member permissions to home', () => {
mockCanManageMembers.mockReturnValue(false);

render(<MembersRoute />);

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(<MembersRoute />);

expect(container).toBeEmptyDOMElement();
});

it('redirects to home when the current user is unavailable after loading', () => {
mockUseCurrentUser.mockReturnValue({
data: undefined,
isError: false,
isLoading: false
});

render(<MembersRoute />);

expect(screen.getByTestId('navigate')).toHaveAttribute('data-to', '/');
});
});
22 changes: 22 additions & 0 deletions apps/admin/src/members-route.tsx
Original file line number Diff line number Diff line change
@@ -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 <Navigate replace to="/" />;
}

return null;
}

if (!canManageMembers(currentUser)) {
return <Navigate replace to="/" />;
}

return <MembersRouteGate />;
}
33 changes: 32 additions & 1 deletion apps/admin/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
// Ember
import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge";
import type { RouteHandle } from "./ember-bridge";
import { MembersRoute } from "./members-route";

import { NotFound } from "./not-found";

Expand All @@ -40,7 +41,8 @@
"/tags/new",
"/explore/*",
"/migrate/*",
"/members/*",
"/members/new",
"/members/:member_id",
"/members-activity",
"/designsandbox",
"/mentions",
Expand All @@ -53,6 +55,33 @@
Component: EmberFallback,
handle: emberFallbackHandle,
}));

const membersRoute: RouteObject = {
path: "/members",
element: <MembersRoute />,
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.

Check warning on line 77 in apps/admin/src/routes.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1If0oNrwhSJOCgFKsF&open=AZ1If0oNrwhSJOCgFKsF&pullRequest=27040
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.
Expand All @@ -69,6 +98,8 @@
Component: EmberFallback,
handle: emberFallbackHandle,
},
membersRoute,
membersForwardRedirectRoute,
{
element: (
<PostsAppContextProvider value={{ fromAnalytics: true }}>
Expand Down
Loading
Loading