Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
21 changes: 11 additions & 10 deletions apps/admin/src/layout/app-sidebar/nav-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@ import { NavCustomViews } from "./nav-custom-views";
import { NavMemberViews } from "./nav-member-views";
import { useMemberSidebarViews } from "./member-sidebar-views";
import { useCustomSidebarViews } from "./use-custom-sidebar-views";
import { useIsActiveLink } from "./use-is-active-link";
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 (
<>
Expand Down Expand Up @@ -70,12 +73,11 @@ function NavContent({ ...props }: React.ComponentProps<typeof SidebarGroup>) {
const [savedMembersExpanded, setMembersExpanded] = useNavigationExpanded('members');
const postCustomViews = useCustomSidebarViews('posts');
const memberViews = useMemberSidebarViews();
const hasMemberViews = memberViews.length > 0;
const memberCount = useMemberCount();
const routing = useEmberRouting();
const commentModerationEnabled = useFeatureFlag('commentModeration');
const membersForwardEnabled = useFeatureFlag('membersForward');
const visibleMemberViews = membersForwardEnabled ? memberViews : [];
const hasMemberViews = visibleMemberViews.length > 0;
const isMembersRouteActive = useIsActiveLink({path: 'members', activeOnSubpath: true});

const showTags = currentUser && canManageTags(currentUser);
const showMembers = currentUser && canManageMembers(currentUser);
Expand All @@ -84,15 +86,14 @@ 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 hasActiveMemberChild = visibleMemberViews.some(view => view.isActive);
const hasActiveMemberView = hasMemberViews && memberViews.some(view => view.isActive);
const membersExpanded = savedMembersExpanded;
const isMembersBaseRouteActive = routing.isRouteActive(['members', 'member', 'member.new', 'members-activity']);
const membersNavActive = isMembersRouteActive
? (!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');

return (
<SidebarGroup {...props}>
<SidebarGroupContent>
Expand Down Expand Up @@ -179,7 +180,7 @@ function NavContent({ ...props }: React.ComponentProps<typeof SidebarGroup>) {
collapsible={true}
count={memberCount}
isActive={membersNavActive}
to={membersRoute}
to="members"
/>
</NavMenuItem.CollapsibleItem>

Expand All @@ -193,7 +194,7 @@ function NavContent({ ...props }: React.ComponentProps<typeof SidebarGroup>) {
collapsible={false}
count={memberCount}
isActive={membersNavActive}
to={membersRoute}
to="members"
/>
</NavMenuItem>
)}
Expand Down
13 changes: 0 additions & 13 deletions apps/admin/src/members-route-gate.tsx

This file was deleted.

9 changes: 3 additions & 6 deletions apps/admin/src/members-route.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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);
Expand All @@ -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(<MembersRoute />);

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

it('redirects users without member permissions to home', () => {
Expand Down
5 changes: 2 additions & 3 deletions apps/admin/src/members-route.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -18,5 +17,5 @@ export function MembersRoute() {
return <Navigate replace to="/" />;
}

return <MembersRouteGate />;
return <Outlet />;
}
11 changes: 0 additions & 11 deletions apps/admin/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -99,7 +89,6 @@ export const routes: RouteObject[] = [
handle: emberFallbackHandle,
},
membersRoute,
membersForwardRedirectRoute,
{
element: (
<PostsAppContextProvider value={{ fromAnalytics: true }}>
Expand Down
4 changes: 3 additions & 1 deletion apps/posts/src/views/members/components/members-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ const MembersActions: React.FC<MembersActionsProps> = ({
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);
Expand Down Expand Up @@ -264,7 +266,7 @@ const MembersActions: React.FC<MembersActionsProps> = ({

{/* New Member Button - styled like Tags */}
<Button asChild>
<a aria-label="New member" className="inline-flex items-center gap-2 font-bold" href="#/members/new">
<a aria-label="New member" className="inline-flex items-center gap-2 font-bold" href={newMemberHref}>
<LucideIcon.Plus className="size-4" />
<span className="hidden sm:inline">New member</span>
</a>
Expand Down
17 changes: 10 additions & 7 deletions apps/posts/src/views/members/components/members-list-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import {Member} from '@tryghost/admin-x-framework/api/members';
import {MemberAvatar} from '@components/member-avatar';
import {TableCell, TableRow} from '@tryghost/shade/components';
import {buildMemberDetailPath} from '../member-detail-hash';
import {cn} from '@tryghost/shade/utils';
import {getActiveColumnValue} from '../member-query-params';
import type {ActiveColumn} from '../member-query-params';
Expand Down Expand Up @@ -60,13 +61,13 @@
return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;
}

function openMemberInNewTab(memberId: string) {
window.open(`#/members/${memberId}`, '_blank', 'noopener');
function openMemberInNewTab(memberId: string, backPath?: string) {
window.open(`#${buildMemberDetailPath(memberId, backPath)}`, '_blank', 'noopener');
}

// --- Sub-components ---

function MembersListItemName({item, onClick}: { item: Member; onClick?: (memberId: string) => void }) {
function MembersListItemName({item, backPath, onClick}: { item: Member; backPath?: string; onClick?: (memberId: string) => void }) {

Check warning on line 70 in apps/posts/src/views/members/components/members-list-item.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1sFTrD39oYaSTa6SEO&open=AZ1sFTrD39oYaSTa6SEO&pullRequest=27221
return (
<div className="flex min-w-0 items-center gap-3">
<MemberAvatar
Expand All @@ -79,7 +80,7 @@
<div className="min-w-0">
<a
className="block min-w-0 cursor-pointer"
href={`#/members/${item.id}`}
href={`#${buildMemberDetailPath(item.id, backPath)}`}
onClick={onClick ? (e) => {
if (isModifiedClick(e)) {
e.stopPropagation();
Expand Down Expand Up @@ -207,6 +208,7 @@
interface MembersListItemProps {
item: Member;
activeColumns: ActiveColumn[];
backPath?: string;
columnStyles: MemberTableColumnStyles;
showPinnedEdge: boolean;
showEmailOpenRate: boolean;
Expand All @@ -217,6 +219,7 @@
function MembersListItem({
item,
activeColumns,
backPath,
columnStyles,
showPinnedEdge,
showEmailOpenRate,
Expand All @@ -230,7 +233,7 @@
} as CSSProperties;
const handleRowClick = (event: React.MouseEvent<HTMLTableRowElement>) => {
if (isModifiedClick(event)) {
openMemberInNewTab(item.id);
openMemberInNewTab(item.id, backPath);
return;
}

Expand All @@ -242,7 +245,7 @@
}

event.preventDefault();
openMemberInNewTab(item.id);
openMemberInNewTab(item.id, backPath);
};

return (
Expand All @@ -256,7 +259,7 @@
<TableCell className={cn(
'min-w-0 bg-background px-4 py-3 group-hover:bg-[var(--members-sticky-hover-bg)] max-sm:!w-full max-sm:!min-w-0 lg:sticky lg:left-0 lg:z-20'
)} style={memberCellStyle}>
<MembersListItemName item={item} onClick={onClick} />
<MembersListItemName backPath={backPath} item={item} onClick={onClick} />
{showPinnedEdge && (
<>
<div
Expand Down
6 changes: 5 additions & 1 deletion apps/posts/src/views/members/components/members-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import MembersListItem from './members-list-item';
import {Member} from '@tryghost/admin-x-framework/api/members';
import {MembersTableColGroup, MembersTableHeader, PinnedMemberHeader} from './member-table-chrome';
import {Table, TableBody, TableCell, TableRow} from '@tryghost/shade/components';
import {buildMemberDetailPath} from '../member-detail-hash';
import {forwardRef, useEffect, useMemo, useRef, useState} from 'react';
import {getMemberTableLayout, getMemberTableLayoutStyles} from './member-table-layout';
import {useInfiniteVirtualScroll} from '@components/virtual-table/use-infinite-virtual-scroll';
Expand Down Expand Up @@ -39,6 +40,7 @@ const PlaceholderRow = forwardRef<HTMLTableRowElement>(
interface MembersListProps {
items: Member[];
totalItems: number;
backPath?: string;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage: () => void;
Expand All @@ -53,6 +55,7 @@ interface MembersListProps {
function MembersList({
items,
totalItems,
backPath,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
Expand Down Expand Up @@ -189,7 +192,7 @@ function MembersList({
onRowClick(memberId);
} else {
// Default: Navigate to Ember member detail page
window.location.hash = `/members/${memberId}`;
window.location.hash = buildMemberDetailPath(memberId, backPath);
}
};

Expand Down Expand Up @@ -263,6 +266,7 @@ function MembersList({
key={key}
{...props}
activeColumns={activeColumns}
backPath={backPath}
columnStyles={columnStyles}
item={item}
showEmailOpenRate={showEmailOpenRate}
Expand Down
11 changes: 11 additions & 0 deletions apps/posts/src/views/members/member-detail-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function buildMemberDetailPath(memberId: string, backPath?: string) {
const params = new URLSearchParams();

if (backPath) {
params.set('back', backPath);
}

const queryString = params.toString();

return `/members/${memberId}${queryString ? `?${queryString}` : ''}`;

Check warning on line 10 in apps/posts/src/views/members/member-detail-hash.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this code to not use nested template literals.

See more on https://sonarcloud.io/project/issues?id=TryGhost_Ghost&issues=AZ1xoYUtAo3DRkjy_qSW&open=AZ1xoYUtAo3DRkjy_qSW&pullRequest=27221
}
4 changes: 3 additions & 1 deletion apps/posts/src/views/members/members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,14 @@ import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config';
import {useBrowseMembersInfinite} from '@tryghost/admin-x-framework/api/members';
import {useBrowseSettings} from '@tryghost/admin-x-framework/api/settings';
import {useDebounce} from 'use-debounce';
import {useSearchParams} from 'react-router';
import {useLocation, useSearchParams} from 'react-router';

const SEARCH_DEBOUNCE_MS = 250;

const MembersPage: React.FC<{timezone: string}> = ({timezone}) => {
const headerRef = useRef<HTMLDivElement>(null);
const {filters, nql, search, setFilters, setSearch, hasFilterOrSearch, clearAll} = useMembersFilterState(timezone);
const location = useLocation();
const {data: configData} = useBrowseConfig();
const savedViews = useMemberViews();
const activeView = useActiveMemberView(savedViews, nql);
Expand Down Expand Up @@ -209,6 +210,7 @@ const MembersPage: React.FC<{timezone: string}> = ({timezone}) => {
) : (
<MembersList
activeColumns={activeColumns}
backPath={`${location.pathname}${location.search}`}
fetchNextPage={fetchNextPage}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
Expand Down
32 changes: 31 additions & 1 deletion apps/posts/test/unit/views/members/members-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import MembersActions from '@src/views/members/components/members-actions';
import React from 'react';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {render} from '@testing-library/react';
import {render, screen} from '@testing-library/react';

const importModalPropsRef: {current: Record<string, unknown> | null} = {current: null};
const {mockUseLocation, mockUseNavigate} = vi.hoisted(() => ({
Expand Down Expand Up @@ -70,6 +70,15 @@ describe('MembersActions', () => {
mockUseNavigate.mockReturnValue(vi.fn());
});

it('opens the import modal on the import route', () => {
setLocation('/members/import');

renderMembersActions({onImportComplete: vi.fn()});

expect(importModalPropsRef.current).not.toBeNull();
expect(importModalPropsRef.current?.open).toBe(true);
});

it('navigates back to members when the import route modal closes', () => {
const navigate = vi.fn();
setLocation('/members/import', '?filter=label%3AVIP&search=alice');
Expand Down Expand Up @@ -102,4 +111,25 @@ describe('MembersActions', () => {
});
expect(navigate).toHaveBeenCalledWith('/members?filter=label%3A%5Bimport-2026-03-17%5D', {replace: true});
});

it('preserves the current members list URL in the new member link', () => {
mockUseLocation.mockReturnValue({
pathname: '/members',
search: '?filter=label%3AVIP&search=alice'
});

render(
<MembersActions
hasFilterOrSearch={false}
memberCount={10}
search=""
canBulkDelete
/>
);

expect(screen.getByRole('link', {name: 'New member'})).toHaveAttribute(
'href',
'#/members/new?back=%2Fmembers%3Ffilter%3Dlabel%253AVIP%26search%3Dalice'
);
});
});
Loading
Loading