Skip to content

Port members list from ember to react#26334

Open
weylandswart wants to merge 1 commit intomainfrom
port-members-list-from-ember-to-react
Open

Port members list from ember to react#26334
weylandswart wants to merge 1 commit intomainfrom
port-members-list-from-ember-to-react

Conversation

@weylandswart
Copy link
Contributor

No ref

Draft PR to port our Members list from Ember to React. Accounts for filtering updates made on the Comments page and new components used for the newer React pages.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 10, 2026

Walkthrough

This change adds a React-based members management interface gated by the "membersForward" feature flag. It extends the members API with new public types (labels, tiers, newsletters, subscriptions) and augments the Member type with additional fields. New client hooks provide infinite browsing and bulk member operations (edit, delete). A React members page, list, filters, actions, and several bulk-action modals are introduced, plus URL-backed filter state management. Routing and Ember templates are updated to conditionally route/render the React view while preserving the Ember fallback. Minor UI adjustments to filter popovers and a new lab flag are included.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Port members list from ember to react' accurately describes the main objective of the pull request, which is to migrate the Members list component from Ember to React.
Description check ✅ Passed The description is related to the changeset, mentioning the port from Ember to React and accounting for filtering updates and new components, though it lacks specific implementation details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch port-members-list-from-ember-to-react

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 21880026498 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

@github-actions
Copy link
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 21884316502 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

@weylandswart weylandswart force-pushed the port-members-list-from-ember-to-react branch from 7e0c093 to 8ce6c23 Compare February 11, 2026 11:18
@codecov
Copy link

codecov bot commented Feb 11, 2026

Codecov Report

❌ Patch coverage is 25.00000% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.78%. Comparing base (553e171) to head (aea9c2d).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
ghost/admin/app/routes/members.js 0.00% 3 Missing and 3 partials ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##             main   #26334   +/-   ##
=======================================
  Coverage   72.77%   72.78%           
=======================================
  Files        1562     1562           
  Lines      120898   120905    +7     
  Branches    14558    14564    +6     
=======================================
+ Hits        87984    87995   +11     
+ Misses      31881    31874    -7     
- Partials     1033     1036    +3     
Flag Coverage Δ
admin-tests 52.34% <0.00%> (-0.03%) ⬇️
e2e-tests 72.78% <25.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions
Copy link
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 21903576179 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

@github-actions
Copy link
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 21904425147 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

@github-actions
Copy link
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 21907494267 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

@github-actions
Copy link
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 21944183858 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

1 similar comment
@github-actions
Copy link
Contributor

E2E Tests Failed

To view the Playwright test report locally, run:

REPORT_DIR=$(mktemp -d) && gh run download 21944183858 -n playwright-report -D "$REPORT_DIR" && npx playwright show-report "$REPORT_DIR"

@weylandswart weylandswart marked this pull request as ready for review February 12, 2026 13:02
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@apps/admin-x-framework/src/api/members.ts`:
- Around line 214-224: The searchParams callback in useBulkEditMembers and
useBulkDeleteMembers can return an empty params object when called with {filter:
'', all: false}, causing server-side errors; update the searchParams
implementation in both functions (the searchParams: ({filter, all}) => { ... }
block) to validate inputs and throw an error if neither a non-empty filter (use
filter.trim() to treat whitespace as empty) nor all === true is provided, so
callers fail fast with a clear message instead of sending an invalid request.

In `@apps/posts/src/views/members/components/bulk-action-modals/delete-modal.tsx`:
- Around line 41-44: The handleExportBackup function doesn't handle rejections
from onExportBackup(), leaving the UI stuck in an exporting state; wrap the
await onExportBackup() call in a try/catch inside handleExportBackup, in the try
setBackupExported(true) as now, and in the catch reset any exporting flags
(e.g., setBackupExported(false) or the component's isExporting state) and
surface an error to the user (toast, setExportError state, or similar) so
failures clear the spinner and show feedback; reference handleExportBackup,
onExportBackup, and setBackupExported when making the changes.

In `@apps/posts/src/views/members/components/members-actions.tsx`:
- Around line 133-135: handleExportBackup currently awaits exportMembers(nql)
without handling failures, so an exception leaves DeleteModal stuck spinning;
wrap the call inside handleExportBackup in a try/catch and either return a
success boolean (true on success, false on failure) or rethrow a wrapped error
so the caller can distinguish failure. Then update DeleteModal to call
handleExportBackup and catch errors (instead of unconditionally calling
setBackupExported(true)) — on catch set the backup failure UI state (e.g.,
setBackupExported(false) and set an error flag/message) so the export button
shows an error rather than spinning forever. Ensure you reference
handleExportBackup, exportMembers, DeleteModal and setBackupExported when making
the change.
- Around line 78-131: The handlers (handleAddLabel, handleRemoveLabel,
handleUnsubscribe, handleDelete) currently call bulkEdit/bulkDelete with filter:
nql || '' and all: !nql which makes all=true when only a search term is active;
fix by incorporating the search term into the bulk payload: use filter: nql ||
searchQuery || '' and set all: !(nql || searchQuery) so operations apply only to
the NQL or current search results, and add searchQuery to each useCallback
dependency list (or alternatively hide the bulk UI unless nql is non-empty).

In `@apps/posts/src/views/members/hooks/use-members-filter-state.ts`:
- Around line 207-215: The generated NQL for the 'audience_feedback' case is
adding an unnecessary `:>0` prefix which makes `is`/`is_any_of` produce
contradictory filters (e.g.
`audience_feedback.score:>0+audience_feedback.score:0`); update the
'audience_feedback' branch (the switch case handling operator and pushing into
`parts`) to stop prepending `audience_feedback.score:>0+` and instead push a
direct equality segment like `audience_feedback.score:${value}` for `operator
=== 'is'` and `operator === 'is_any_of'` (preserving the existing negation
behavior for the else branch that pushes `audience_feedback.score:-${value}`).

In `@apps/posts/src/views/members/members.tsx`:
- Around line 83-106: MembersFilters is being unmounted/remounted when
hasFilters toggles because it's rendered in two different conditional branches;
keep a single MembersFilters instance to preserve its internal state by moving
the <MembersFilters filters={filters} onFiltersChange={setFilters} /> out of the
conditionals and control its visual placement with CSS (use filtersClassName and
hasFilters to apply a different class/positioning), or alternatively always
render it inside one container and toggle visibility via a className derived
from hasFilters; ensure you remove the duplicate MembersFilters render so only
the unique component instance (MembersFilters) remains and continue passing
filters and setFilters props as before.
🧹 Nitpick comments (8)
apps/posts/src/views/members/hooks/use-members-filter-state.ts (1)

81-92: Empty string '' is silently skipped by the guard on Line 85.

!filter.values[0] is true for '', so text filters where the user typed and then cleared the input would be silently dropped. This appears intentional and correct (no point querying name:~''), but worth confirming that the UI prevents submitting filters with empty text values, otherwise the user could see a filter chip with no corresponding NQL clause.

apps/admin/src/routes/members-route.tsx (1)

20-23: Consider a more polished Suspense fallback.

A bare <div>Loading...</div> may flash as unstyled text. If the codebase has a shared loading spinner or skeleton component, using it here would provide a smoother experience consistent with other lazy-loaded routes.

apps/posts/src/views/members/components/bulk-action-modals/remove-label-modal.tsx (1)

27-89: Clean modal implementation.

State reset on close, disabled button guards, and loading feedback are all handled well.

One minor note: if labels is empty, the select dropdown will be empty with no guidance to the user. Consider showing a message or disabling the entire dialog when there are no labels available.

apps/posts/src/views/members/components/members-filters.tsx (1)

35-48: Duplicated paid+active tier filtering.

The expression tiers.filter(t => t.type === 'paid' && t.active) appears three times (lines 35, 40, 48). Consider extracting to a local variable for clarity.

Suggested refactor
     const labels = labelsData?.labels || [];
     const tiers = tiersData?.tiers || [];
     const newsletters = newslettersData?.newsletters || [];
-    const hasMultipleTiers = tiers.filter(t => t.type === 'paid' && t.active).length > 1;
+    const paidActiveTiers = tiers.filter(t => t.type === 'paid' && t.active);
+    const hasMultipleTiers = paidActiveTiers.length > 1;
 
     // Get filter configuration
     const filterFields = useMembersFilterConfig({
         labels,
-        tiers: tiers.filter(t => t.type === 'paid' && t.active),
+        tiers: paidActiveTiers,
         newsletters: newsletters.filter(n => n.status === 'active'),
         offers: [], // TODO: Fetch offers if needed
         hasMultipleTiers,
         paidMembersEnabled,
         emailAnalyticsEnabled,
         // For now, use static options - can be enhanced with search later
         labelsOptions: labels.map(l => ({value: l.id, label: l.name})),
-        tiersOptions: tiers.filter(t => t.type === 'paid' && t.active).map(t => ({value: t.id, label: t.name}))
+        tiersOptions: paidActiveTiers.map(t => ({value: t.id, label: t.name}))
     });
apps/posts/src/views/members/components/members-list.tsx (3)

31-35: Hardcoded 'en-US' locale in date formatting.

Ghost Admin is English-only so this works, but if internationalization is ever a goal, consider using undefined (user's locale) or a shared locale constant.


37-59: Consider using Intl.RelativeTimeFormat instead of hand-rolled relative time.

The native Intl.RelativeTimeFormat API handles relative time formatting with proper pluralization and localization. The current implementation works but could be replaced with a ~10-line wrapper around the standard API, which would also handle edge cases (e.g., month/year boundaries) more accurately than the diffDays / 30 and diffDays / 365 approximations.


137-144: Hash-based navigation to Ember member detail page is intentional for the migration, but worth documenting.

window.location.hash directly manipulates the URL, bypassing React Router. This is fine during the Ember-to-React transition but should be revisited when the member detail page is also ported.

apps/posts/src/views/members/members.tsx (1)

36-45: searchParams duplicates the defaults already defined in useBrowseMembersInfinite.

The hook's defaultSearchParams (in apps/admin-x-framework/src/api/members.ts, lines 155-158) already specifies include: 'labels,tiers', limit: '50', and order: 'created_at desc'. Repeating them here means changes to the defaults would need to be mirrored in two places. Consider only passing the filter param and relying on the hook's defaults for the rest.

Suggested simplification
-    // Build search params for the API query
-    const searchParams: Record<string, string> = {
-        include: 'labels,tiers',
-        limit: '50',
-        order: 'created_at desc'
-    };
-
-    // Add NQL filter if present
-    if (nql) {
-        searchParams.filter = nql;
-    }
+    // Build search params for the API query (defaults for include, limit, order are in the hook)
+    const searchParams: Record<string, string> = nql ? {filter: nql} : {};

Comment on lines 214 to 224
searchParams: ({filter, all}) => {
const params: Record<string, string> = {};
if (all) {
params.all = 'true';
} else if (filter) {
params.filter = filter;
}
return params;
},
invalidateQueries: {dataType}
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find the members API routes/endpoints in Ghost
find ghost/core -name "*.js" -o -name "*.ts" | grep -i member | head -20

Repository: TryGhost/Ghost

Length of output: 1483


🏁 Script executed:

# Search for bulk edit/delete endpoints in Ghost API
rg -l "bulk" ghost/core/core/server/api/ --type=js --type=ts | head -10

Repository: TryGhost/Ghost

Length of output: 806


🏁 Script executed:

# Look for the members API routes
fd -e js -e ts "members" ghost/core/core/server/api/

Repository: TryGhost/Ghost

Length of output: 661


🏁 Script executed:

# Search more broadly for bulk operations in the API
rg "bulkEdit|bulkDelete|bulkDestroy" ghost/core/core/server -A 5 -B 2 --type=js --type=ts | head -50

Repository: TryGhost/Ghost

Length of output: 4983


🏁 Script executed:

# Read the members.js endpoint file to see how bulkEdit and bulkDestroy are implemented
cat -n ghost/core/core/server/api/endpoints/members.js | head -100

Repository: TryGhost/Ghost

Length of output: 3520


🏁 Script executed:

# Check size and read the entire members.js endpoint file
wc -l ghost/core/core/server/api/endpoints/members.js

Repository: TryGhost/Ghost

Length of output: 109


🏁 Script executed:

# Search for bulkEdit and bulkDestroy in members.js
rg -n "bulkEdit|bulkDestroy" ghost/core/core/server/api/endpoints/members.js -A 30

Repository: TryGhost/Ghost

Length of output: 2621


🏁 Script executed:

# Also check the members input validator to see if there's parameter validation
cat -n ghost/core/core/server/api/endpoints/utils/validators/input/members.js | head -150

Repository: TryGhost/Ghost

Length of output: 234


🏁 Script executed:

# Find the members service implementation
fd -e js "members" ghost/core/core/server/services/ | grep -v test | grep -v node_modules

Repository: TryGhost/Ghost

Length of output: 531


🏁 Script executed:

# Look for the bulkDestroy and bulkEdit implementations in the members service
rg -n "bulkDestroy|bulkEdit" ghost/core/core/server/services/ -B 3 -A 20 --type=js | head -100

Repository: TryGhost/Ghost

Length of output: 10125


🏁 Script executed:

# Find bulkDestroy and bulkEdit in members-api.js
rg -n "bulkDestroy|bulkEdit" ghost/core/core/server/services/members/members-api/members-api.js -B 3 -A 20

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Also check the base model for bulk operations to understand the behavior
cat -n ghost/core/core/server/models/base/plugins/bulk-operations.js

Repository: TryGhost/Ghost

Length of output: 10039


🏁 Script executed:

# Find the members API service
find ghost/core/core/server/services/members -name "*.js" | xargs grep -l "bulkDestroy\|bulkEdit" 2>/dev/null

Repository: TryGhost/Ghost

Length of output: 143


🏁 Script executed:

# Search in members-api.js for the members service
cat -n ghost/core/core/server/services/members/members-api/members-api.js | head -200

Repository: TryGhost/Ghost

Length of output: 6862


🏁 Script executed:

# Find bulkDestroy and bulkEdit in member-repository.js
rg -n "bulkDestroy|bulkEdit" ghost/core/core/server/services/members/members-api/repositories/member-repository.js -B 5 -A 25

Repository: TryGhost/Ghost

Length of output: 4309


🏁 Script executed:

# Get the complete context around lines 214-224 and 232-242 in members.ts
cat -n apps/admin-x-framework/src/api/members.ts | sed -n '200,250p'

Repository: TryGhost/Ghost

Length of output: 1434


🏁 Script executed:

# Also check if there are usages of these hooks that might pass empty filter
rg -n "useBulkEditMembers|useBulkDeleteMembers" apps/ -B 3 -A 5

Repository: TryGhost/Ghost

Length of output: 3378


🏁 Script executed:

# Check how bulkEdit and bulkDelete are called in members-actions.tsx
cat -n apps/posts/src/views/members/components/members-actions.tsx | sed -n '50,150p'

Repository: TryGhost/Ghost

Length of output: 3871


🏁 Script executed:

# Look for where these mutations are called with parameters
rg -n "bulkEdit\(|bulkDelete\(" apps/posts/src/views/members/components/members-actions.tsx -B 2 -A 5

Repository: TryGhost/Ghost

Length of output: 1061


🏁 Script executed:

# Check if there are other usages of these mutations besides members-actions.tsx
rg -n "useBulkEditMembers\|useBulkDeleteMembers" apps/ --type=ts --type=tsx

Repository: TryGhost/Ghost

Length of output: 85


🏁 Script executed:

# Also check the createMutation function to understand how searchParams is used
fd -e ts -e tsx "createMutation" apps/admin-x-framework/ | head -5

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Check usages of the bulk mutations without type specification
rg -n "useBulkEditMembers|useBulkDeleteMembers" apps/ -B 2 -A 2

Repository: TryGhost/Ghost

Length of output: 1997


🏁 Script executed:

# Find createMutation in admin-x-framework
fd "createMutation" apps/admin-x-framework/ --type f

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Search for createMutation definition in the codebase
rg -n "export.*createMutation|function createMutation" apps/admin-x-framework/ -B 2 -A 10

Repository: TryGhost/Ghost

Length of output: 50370


🏁 Script executed:

# Check the complete context in members-actions.tsx to understand the logic
# The pattern shows: filter: nql || '', all: !nql
# This means: if nql exists, use it as filter and all=false
#            if nql is empty/falsy, filter becomes '' and all becomes true
# So the actual problematic case is when nql is empty string ''
rg -n "nql" apps/posts/src/views/members/components/members-actions.tsx | head -20

Repository: TryGhost/Ghost

Length of output: 566


🏁 Script executed:

# Check if there are other places where these hooks might be called with problematic params
rg -n "useBulkEditMembers\|useBulkDeleteMembers" apps/ --type js --type ts -A 10 | grep -E "filter|all" | head -40

Repository: TryGhost/Ghost

Length of output: 40


🏁 Script executed:

# Look at the createMutation hook to see how searchParams is handled
cat -n apps/admin-x-framework/src/utils/api/hooks.ts | sed -n '184,220p'

Repository: TryGhost/Ghost

Length of output: 2041


Add early validation to prevent bulk operations without filter or all flag.

In both useBulkEditMembers and useBulkDeleteMembers, the searchParams function checks if (filter) which is falsy for ''. If a caller passes {filter: '', all: false}, the request will have no filter or all query parameter, which the API rejects. This relies on server-side error handling rather than failing early on the client.

Throw an error if neither a non-empty filter nor the all flag is provided:

Proposed safeguard
     searchParams: ({filter, all}) => {
         const params: Record<string, string> = {};
         if (all) {
             params.all = 'true';
         } else if (filter) {
             params.filter = filter;
+        } else {
+            throw new Error('Bulk operation requires either a filter or all flag');
         }
         return params;
     },

Also applies to: 232-242

🤖 Prompt for AI Agents
In `@apps/admin-x-framework/src/api/members.ts` around lines 214 - 224, The
searchParams callback in useBulkEditMembers and useBulkDeleteMembers can return
an empty params object when called with {filter: '', all: false}, causing
server-side errors; update the searchParams implementation in both functions
(the searchParams: ({filter, all}) => { ... } block) to validate inputs and
throw an error if neither a non-empty filter (use filter.trim() to treat
whitespace as empty) nor all === true is provided, so callers fail fast with a
clear message instead of sending an invalid request.

Comment on lines 133 to 135
const handleExportBackup = useCallback(async () => {
await exportMembers(nql);
}, [nql]);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

handleExportBackup doesn't catch errors, leaving the DeleteModal in a broken state.

If exportMembers throws, the promise rejection is unhandled here. In DeleteModal, setBackupExported(true) won't execute, and the export button won't show a failure state — it'll just silently stop spinning.

Suggested fix
     const handleExportBackup = useCallback(async () => {
-        await exportMembers(nql);
+        try {
+            await exportMembers(nql);
+        } catch (error) {
+            // eslint-disable-next-line no-console
+            console.error('Export backup failed:', error);
+            throw error; // Re-throw so DeleteModal knows it failed
+        }
     }, [nql]);

Then in DeleteModal, catch the error to show appropriate UI feedback rather than setting backupExported = true.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleExportBackup = useCallback(async () => {
await exportMembers(nql);
}, [nql]);
const handleExportBackup = useCallback(async () => {
try {
await exportMembers(nql);
} catch (error) {
// eslint-disable-next-line no-console
console.error('Export backup failed:', error);
throw error; // Re-throw so DeleteModal knows it failed
}
}, [nql]);
🤖 Prompt for AI Agents
In `@apps/posts/src/views/members/components/members-actions.tsx` around lines 133
- 135, handleExportBackup currently awaits exportMembers(nql) without handling
failures, so an exception leaves DeleteModal stuck spinning; wrap the call
inside handleExportBackup in a try/catch and either return a success boolean
(true on success, false on failure) or rethrow a wrapped error so the caller can
distinguish failure. Then update DeleteModal to call handleExportBackup and
catch errors (instead of unconditionally calling setBackupExported(true)) — on
catch set the backup failure UI state (e.g., setBackupExported(false) and set an
error flag/message) so the export button shows an error rather than spinning
forever. Ensure you reference handleExportBackup, exportMembers, DeleteModal and
setBackupExported when making the change.

Comment on lines +83 to +106
{!hasFilters && (
<MembersFilters
filters={filters}
onFiltersChange={setFilters}
/>
)}
<MembersActions
canBulkDelete={canBulkDelete}
isFiltered={isFiltered}
memberCount={totalMembers}
nql={nql}
/>
</Header.ActionGroup>
</Header.Actions>

{/* When filters are active, show them in a row below */}
{hasFilters && (
<div className={filtersClassName}>
<MembersFilters
filters={filters}
onFiltersChange={setFilters}
/>
</div>
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

MembersFilters remounts when the first filter is added, potentially losing internal component state.

When hasFilters transitions from false to true, MembersFilters unmounts from the inline position (lines 83-88) and mounts in a new position (lines 99-106). React treats these as different component instances, so any internal state in Filters (e.g., open popover, partially entered filter) would be lost during the transition.

Consider using a single MembersFilters instance that is repositioned via CSS, or use a key prop to explicitly control instance identity.

Sketch: single instance with CSS-driven positioning
                 <Header.Actions>
                     <Header.ActionGroup>
-                        {/* When no filters, show filter button inline with other actions */}
-                        {!hasFilters && (
-                            <MembersFilters
-                                filters={filters}
-                                onFiltersChange={setFilters}
-                            />
-                        )}
                         <MembersActions
                             canBulkDelete={canBulkDelete}
                             isFiltered={isFiltered}
                             memberCount={totalMembers}
                             nql={nql}
                         />
                     </Header.ActionGroup>
                 </Header.Actions>

-                {/* When filters are active, show them in a row below */}
-                {hasFilters && (
-                    <div className={filtersClassName}>
-                        <MembersFilters
-                            filters={filters}
-                            onFiltersChange={setFilters}
-                        />
-                    </div>
-                )}
+                <div className={filtersClassName}>
+                    <MembersFilters
+                        filters={filters}
+                        onFiltersChange={setFilters}
+                    />
+                </div>
🤖 Prompt for AI Agents
In `@apps/posts/src/views/members/members.tsx` around lines 83 - 106,
MembersFilters is being unmounted/remounted when hasFilters toggles because it's
rendered in two different conditional branches; keep a single MembersFilters
instance to preserve its internal state by moving the <MembersFilters
filters={filters} onFiltersChange={setFilters} /> out of the conditionals and
control its visual placement with CSS (use filtersClassName and hasFilters to
apply a different class/positioning), or alternatively always render it inside
one container and toggle visibility via a className derived from hasFilters;
ensure you remove the duplicate MembersFilters render so only the unique
component instance (MembersFilters) remains and continue passing filters and
setFilters props as before.

Copy link
Contributor

@rob-ghost rob-ghost left a comment

Choose a reason for hiding this comment

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

Some comments that could probably be addressed now with the help of an agent, otherwise we can merge and iterate.

Comment on lines +30 to +33
const response = await fetch(`/ghost/api/admin/members/upload/?${params}`, {
method: 'GET',
credentials: 'include'
});
Copy link
Contributor

Choose a reason for hiding this comment

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

Issue: We shouldn't be using a raw fetch and favour useFetchApi for consistency. If there's a reason to use raw fetch lets call it out with a comment.

Comment on lines 70 to 72
} catch (error) {
// eslint-disable-next-line no-console
console.error('Export failed:', error);
Copy link
Contributor

Choose a reason for hiding this comment

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

Issue: This swallows the error without any feedback to the user. The ember version shows a toast on failure.

If we intend to handle failure differently in React, or if we're waiting on Toast porting, we should add a TODO here explaining why we're handling the error.

Comment on lines 36 to 40
const searchParams: Record<string, string> = {
include: 'labels,tiers',
limit: '50',
order: 'created_at desc'
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: This object is created on every render, not a big deal but I don't know if passing it to useBrowseMembersInfinite causes it to re-fetch un-necessarily.

We could use useMemo:

const searchParams = useMemo(() => {
    const searchParams: Record<string, string> = {
        include: 'labels,tiers',
        limit: '50',
        order: 'created_at desc'
    };

    if (nql) {
        searchParams.filter = nql;
    }

    return searchParams;
}, [nql])

labels,
tiers: tiers.filter(t => t.type === 'paid' && t.active),
newsletters: newsletters.filter(n => n.status === 'active'),
offers: [], // TODO: Fetch offers if needed
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: Is the lack of offers being tracked anywhere? Linear issue?

Comment on lines 31 to 35
function formatDate(dateString: string): string {
const date = new Date(dateString);
const options: Intl.DateTimeFormatOptions = {day: 'numeric', month: 'short', year: 'numeric'};
return date.toLocaleDateString('en-US', options);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: Shade provides formatDisplayDate() and formatTimestamp() we should use for consistency.

Comment on lines +41 to +76
/**
* Escape a string for NQL (escape single quotes)
*/
function escapeNqlString(value: string): string {
return '\'' + value.replace(/'/g, '\\\'') + '\'';
}

/**
* Map UI operator names to NQL operators
*/
function getFilterRelationOperator(relation: string): string {
const relationMap: Record<string, string> = {
'is-less': '<',
'is-or-less': '<=',
is: '',
'is-not': '-',
'is-greater': '>',
'is-or-greater': '>=',
contains: '~',
'does-not-contain': '-~',
'starts-with': '~^',
'ends-with': '~$',
// Shade filter operators (mapped to our internal names)
before: '<',
after: '>',
is_not: '-',
is_any_of: '',
is_not_any_of: '-',
greater_than: '>',
less_than: '<',
equals: '',
not_equals: '-'
};

return relationMap[relation] ?? '';
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion(not-now): These are duplicated from Ember, along with some other utilities (escapeNqlString, getFilterRelationOperator, buildMemberNqlFilter, the filter option constants)

We probably want to centralise these into admin-x-framework or something at some point.

@rob-ghost rob-ghost force-pushed the port-members-list-from-ember-to-react branch from c051041 to 75e7d1b Compare February 12, 2026 16:40
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/posts/src/views/members/members.tsx`:
- Around line 36-51: The current useMemo for searchParams sets searchParams to
just {filter: nql} which replaces options.defaultSearchParams in
createInfiniteQuery's queryFn (so include, limit, order are lost); change
searchParams generation to merge the defaults with the filter when nql is
present (e.g. produce an object that spreads options.defaultSearchParams then
overrides/sets filter when nql exists) so useBrowseMembersInfinite/queryFn
receives a combined searchParams containing include, limit, order plus the new
filter; update the useMemo that creates searchParams and ensure types align with
createInfiniteQuery/queryFn expectations.
🧹 Nitpick comments (5)
apps/posts/src/views/members/hooks/use-members-filter-state.ts (1)

350-358: Potential stale closure in functional setFilters updates.

When action is a function, it receives the memoized filters from the last render rather than the latest URL state. If setFilters is called twice in the same event loop before a re-render, the second call's functional updater will operate on stale filters.

This is unlikely to be a problem in practice for a filter UI, but worth noting if batch updates are ever needed.

apps/posts/src/views/members/hooks/use-members-filter-config.tsx (1)

54-65: Operator naming mismatch between config and shade filter component.

The operators here use hyphenated names ('is-not', 'is-less', 'starts-with', etc.) while the shade Filter component may emit underscored names ('is_not', 'is_any_of', etc.). The NQL builder in use-members-filter-state.ts maps both conventions, so this works, but it could become a maintenance burden.

apps/admin-x-framework/src/api/members.ts (1)

148-181: The isEnd field on MembersInfiniteResponseType appears unused.

The returnData computes isEnd but consumers (e.g., members.tsx) use hasNextPage from react-query's infinite query instead. Consider removing it to avoid dead code, or document its intended use case.

apps/posts/src/views/members/components/members-actions.tsx (1)

22-47: Hardcoded API path for member export.

The path /ghost/api/admin/members/upload/ is hardcoded. While this is common in Ghost's codebase, consider extracting it to a constant or deriving it from a shared API config to reduce maintenance burden if the path ever changes.

apps/admin/src/routes/members-route.tsx (1)

20-24: Minor: bare "Loading..." fallback may be inconsistent with other routes.

Other lazy-loaded routes in this app use lazyComponent which likely provides a more polished loading state. Consider using a shared loading/spinner component for visual consistency.

Comment on lines +36 to +51
const searchParams = useMemo((): Record<string, string> | undefined => {
return nql ? {filter: nql} : undefined;
}, [nql]);

const {
data,
isError,
isFetching,
isFetchingNextPage,
isRefetching,
fetchNextPage,
hasNextPage
} = useBrowseMembersInfinite({
searchParams,
keepPreviousData: true
});
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n "searchParams.*defaultSearchParams|createInfiniteQuery" apps/admin-x-framework/src/utils/api/hooks.ts -B 3 -A 8

Repository: TryGhost/Ghost

Length of output: 5295


🏁 Script executed:

fd -t f "hooks.ts" apps/admin-x-framework/src/utils/api/

Repository: TryGhost/Ghost

Length of output: 103


🏁 Script executed:

rg -n "useBrowseMembersInfinite|createInfiniteQuery.*members" apps/admin-x-framework/src --type ts -B 2 -A 10

Repository: TryGhost/Ghost

Length of output: 1040


🏁 Script executed:

rg -n "browseMembersInfinite|defaultSearchParams.*label|include.*label" apps/admin-x-framework/src --type ts -A 3

Repository: TryGhost/Ghost

Length of output: 335


Filtering loses include, limit, and order parameters — must merge with defaults instead of replacing them.

When nql is set, searchParams becomes {filter: nql}. In createInfiniteQuery (line 124), the queryFn uses pageParam || searchParams || options.defaultSearchParams — so when searchParams is provided, it completely overrides the defaults. This means filtered API requests lose include: 'labels,tiers', limit: '50', and order: 'created_at desc', breaking both data enrichment (missing labels/tiers) and sort order.

Merge with defaults:

Proposed fix
     const searchParams = useMemo((): Record<string, string> | undefined => {
-        return nql ? {filter: nql} : undefined;
+        if (!nql) {
+            return undefined;
+        }
+        return {
+            include: 'labels,tiers',
+            limit: '50',
+            order: 'created_at desc',
+            filter: nql
+        };
     }, [nql]);
🤖 Prompt for AI Agents
In `@apps/posts/src/views/members/members.tsx` around lines 36 - 51, The current
useMemo for searchParams sets searchParams to just {filter: nql} which replaces
options.defaultSearchParams in createInfiniteQuery's queryFn (so include, limit,
order are lost); change searchParams generation to merge the defaults with the
filter when nql is present (e.g. produce an object that spreads
options.defaultSearchParams then overrides/sets filter when nql exists) so
useBrowseMembersInfinite/queryFn receives a combined searchParams containing
include, limit, order plus the new filter; update the useMemo that creates
searchParams and ensure types align with createInfiniteQuery/queryFn
expectations.

This adds a React implementation of the members list view behind a labs flag.
Includes infinite query API hooks, bulk operations support, and filter UI.
The Ember implementation is preserved and active by default until the flag
is enabled.
@weylandswart weylandswart force-pushed the port-members-list-from-ember-to-react branch from 75e7d1b to aea9c2d Compare February 12, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants