Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 17 additions & 5 deletions apps/posts/src/views/members/components/members-actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,39 @@ interface MembersActionsProps {
memberCount: number;
nql?: string;
search: string;
siteTitle?: string | null;
canBulkDelete: boolean;
onImportComplete?: (importResponse?: ImportResponse) => void;
}

async function exportMembers(filter?: string, search?: string): Promise<void> {
export function getMembersExportFileName(siteTitle?: string | null): string {
const titleSlug = siteTitle
?.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const titlePrefix = titleSlug ? `${titleSlug}.` : '';
Comment on lines +25 to +29

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Non-ASCII site titles are dropped from the filename prefix.

At Line 25-Line 29, the slug regex only keeps [a-z0-9]. Titles like 日本語ブログ become an empty slug and incorrectly fall back to ghost.members... even though siteTitle is present. Please switch this path to the same slugification behavior used by other exports (or a Unicode-safe slug strategy) so non-empty titles consistently contribute a prefix.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/posts/src/views/members/components/members-actions.tsx` around lines 25
- 29, The current titleSlug computation drops non-ASCII characters because it
restricts to [a-z0-9]; update members-actions.tsx to use the project’s
Unicode-aware slugification used by other exports (or a Unicode-safe slug
strategy) instead of that regex: replace the titleSlug logic that depends on
siteTitle?.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') with
a call to the shared slug function (or a Unicode-safe slugifier) so titleSlug
(and consequently titlePrefix) preserves non-Latin titles like 日本語ブログ and
produces a non-empty prefix when siteTitle exists (refer to the existing slug
utility or export code for the exact function name to reuse).

const datetime = new Date().toJSON().substring(0, 10);

return `${titlePrefix}ghost.members.${datetime}.csv`;
}

export async function exportMembers(filter?: string, search?: string, siteTitle?: string | null): Promise<void> {
const params = new URLSearchParams({limit: 'all'});
if (filter) {
params.set('filter', filter);
}
if (search) {
params.set('search', search);
}
const datetime = new Date().toJSON().substring(0, 10);
await blobDownloadFromEndpoint(`/members/upload/?${params}`, `members.${datetime}.csv`);
await blobDownloadFromEndpoint(`/members/upload/?${params}`, getMembersExportFileName(siteTitle));
}

const MembersActions: React.FC<MembersActionsProps> = ({
hasFilterOrSearch,
memberCount,
nql,
search,
siteTitle,
canBulkDelete,
onImportComplete
}) => {
Expand All @@ -62,14 +74,14 @@ const MembersActions: React.FC<MembersActionsProps> = ({
const memberOperationParams = buildMemberOperationParams({nql, search});
const handleExport = useCallback(async () => {
try {
await exportMembers(nql, search);
await exportMembers(nql, search, siteTitle);
} catch (e) {
toast.error('Export failed', {
description: 'There was a problem downloading your member data. Please check your connection and try again.'
});
throw e;
}
}, [nql, search]);
}, [nql, search, siteTitle]);

const handleAddLabel = useCallback(async (labelIds: string[]) => {
try {
Expand Down
6 changes: 4 additions & 2 deletions apps/posts/src/views/members/members.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {useLocation, useSearchParams} from 'react-router';
const SEARCH_DEBOUNCE_MS = 250;
const MEMBERS_HELP_CARDS_LIMIT = 6;

const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean}> = ({timezone, membershipsEnabled}) => {
const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean; siteTitle?: string | null}> = ({timezone, membershipsEnabled, siteTitle}) => {
const headerRef = useRef<HTMLDivElement | null>(null);
const setHeaderContentRef = useCallback((node: HTMLDivElement | null) => {
headerRef.current = node?.closest('[data-list-page="header"]') as HTMLDivElement | null;
Expand Down Expand Up @@ -155,6 +155,7 @@ const MembersPage: React.FC<{timezone: string; membershipsEnabled: boolean}> = (
memberCount={totalMembers}
nql={nql}
search={search}
siteTitle={siteTitle}
onImportComplete={() => {
void refetch();
}}
Expand Down Expand Up @@ -282,9 +283,10 @@ const Members: React.FC = () => {

const timezone = getSiteTimezone(settingsData.settings);
const membersSignupAccess = getSettingValue<string>(settingsData.settings, 'members_signup_access');
const siteTitle = getSettingValue<string>(settingsData.settings, 'title');
const membershipsEnabled = membersSignupAccess !== 'none';

return <MembersPage membershipsEnabled={membershipsEnabled} timezone={timezone} />;
return <MembersPage membershipsEnabled={membershipsEnabled} siteTitle={siteTitle} timezone={timezone} />;
};

export default Members;
42 changes: 39 additions & 3 deletions apps/posts/test/unit/views/members/members-actions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import MembersActions from '@src/views/members/components/members-actions';
import MembersActions, {exportMembers, getMembersExportFileName} from '@src/views/members/components/members-actions';
import React from 'react';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {render, screen} from '@testing-library/react';

const importModalPropsRef: {current: Record<string, unknown> | null} = {current: null};
const {mockUseLocation, mockUseNavigate} = vi.hoisted(() => ({
const {mockBlobDownloadFromEndpoint, mockUseLocation, mockUseNavigate} = vi.hoisted(() => ({
mockBlobDownloadFromEndpoint: vi.fn(),
mockUseLocation: vi.fn(),
mockUseNavigate: vi.fn()
}));
Expand All @@ -14,6 +15,10 @@ vi.mock('@tryghost/admin-x-framework', () => ({
useNavigate: mockUseNavigate
}));

vi.mock('@tryghost/admin-x-framework/helpers', () => ({
blobDownloadFromEndpoint: mockBlobDownloadFromEndpoint
}));

vi.mock('@src/views/members/components/bulk-action-modals', () => ({
ImportMembersModal: (props: Record<string, unknown>) => {
importModalPropsRef.current = props;
Expand Down Expand Up @@ -64,12 +69,43 @@ const renderMembersActions = (props: Partial<React.ComponentProps<typeof Members
};

describe('MembersActions', () => {
afterEach(() => {
vi.useRealTimers();
});

beforeEach(() => {
importModalPropsRef.current = null;
mockBlobDownloadFromEndpoint.mockReset();
setLocation('/members');
mockUseNavigate.mockReturnValue(vi.fn());
});

it('builds a members export filename with the slugified site title', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-02T12:00:00.000Z'));

expect(getMembersExportFileName('My Publication')).toBe('my-publication.ghost.members.2026-06-02.csv');
});

it('falls back to ghost when the members export site title is missing', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-02T12:00:00.000Z'));

expect(getMembersExportFileName(null)).toBe('ghost.members.2026-06-02.csv');
});

it('exports members with the site title in the CSV filename', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-02T12:00:00.000Z'));

await exportMembers(undefined, '', 'My Publication');

expect(mockBlobDownloadFromEndpoint).toHaveBeenCalledWith(
'/members/upload/?limit=all',
'my-publication.ghost.members.2026-06-02.csv'
);
});

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

Expand Down
Loading