Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
d4a18c1
Added postsListReact flag gating the posts and pages lists
peterzimon Aug 4, 2026
362831f
Added acceptance coverage for the posts and pages list flag
peterzimon Aug 4, 2026
c0ccf12
Added the posts list query and filter param layer
peterzimon Aug 4, 2026
2b445f6
Added the posts and pages list data layer
peterzimon Aug 4, 2026
7df6f29
Fixed an incorrect claim about the posts list request params
peterzimon Aug 4, 2026
3a0be4a
Fixed sequencing, author scoping and paging in the posts list data layer
peterzimon Aug 4, 2026
a21ee4d
Reverted premature rendering of partially loaded post buckets
peterzimon Aug 4, 2026
667f132
Added the post row copy as pure functions
peterzimon Aug 4, 2026
4b94eca
Added post rows and the two empty states
peterzimon Aug 4, 2026
f41cd1f
Changed post status colours to Shade's semantic state tokens
peterzimon Aug 4, 2026
eebb1f3
Fixed post row parity issues found in review
peterzimon Aug 4, 2026
7034b8c
Added the posts and pages filter bar and sort control
peterzimon Aug 4, 2026
6844fff
Fixed duplicate filter chips on the posts list
peterzimon Aug 4, 2026
3a587e9
Fixed unknown filter values, author paging and typing on the posts list
peterzimon Aug 4, 2026
a954a26
Fixed sidebar highlighting for the React posts list
peterzimon Aug 4, 2026
b208c05
Added sticky filters to the React posts sidebar
peterzimon Aug 4, 2026
65edcad
Added save-as-view to the React posts list
peterzimon Aug 4, 2026
5a9e41e
Added the analytics metric columns to post rows
peterzimon Aug 4, 2026
9f63552
Fixed data loss and regressions in posts saved views
peterzimon Aug 4, 2026
5abbfae
Added visitor and member counts to the posts list metrics
peterzimon Aug 4, 2026
8b91fba
Added metric hover panels and row actions to the React posts list
peterzimon Aug 4, 2026
70aa219
Added modifier-click selection to the React posts list
peterzimon Aug 4, 2026
83e7026
Fixed selected rows reading as unselected while hovered
peterzimon Aug 5, 2026
3058ff8
Added the right-click menu to the React posts list
peterzimon Aug 5, 2026
6daf940
Added bulk delete, unpublish and feature to the React posts list
peterzimon Aug 5, 2026
dee4939
Added bulk tagging and access changes to the React posts list
peterzimon Aug 5, 2026
2509e0f
Fixed the posts list freezing after a bulk edit
peterzimon Aug 5, 2026
58e134a
Removed a bad Ember store invalidation from the posts list
peterzimon Aug 5, 2026
0841a1f
Added the publish-celebration handoff to the React posts list
peterzimon Aug 5, 2026
1072977
Fixed the publish celebration never firing under StrictMode
peterzimon Aug 5, 2026
cc16c05
Fixed the publish celebration never firing for pages
peterzimon Aug 5, 2026
81985ed
Merged main into the React posts list branch
peterzimon Aug 5, 2026
e6ba244
Added a dual-implementation e2e suite for the posts list
peterzimon Aug 5, 2026
9974df9
Expanded the dual-implementation e2e suite for the posts list
peterzimon Aug 5, 2026
fc2ea36
Added selection and bulk-delete coverage to the posts list e2e suite
peterzimon Aug 5, 2026
76edafd
Fixed the sidebar staying visible in the editor
peterzimon Aug 5, 2026
23ec939
Fixed a tag made in the editor missing from the posts list filter
peterzimon Aug 5, 2026
f7ae54b
Fixed the posts list gate tests broken by the main merge
peterzimon Aug 5, 2026
811a61e
Merge remote-tracking branch 'origin/main' into react-posts-pages-list
peterzimon Aug 6, 2026
4983b83
Merged main into the React posts list branch
peterzimon Aug 6, 2026
de5ae74
Moved selection and menu coverage into the dual e2e suite
peterzimon Aug 6, 2026
044d957
Improved posts list selection speed by rendering the menu inside the row
peterzimon Aug 6, 2026
2fa3e59
Added selection and context menu coverage to the dual e2e suite
peterzimon Aug 6, 2026
216ac33
Added delete coverage to the dual posts list e2e suite
peterzimon Aug 6, 2026
f04d7a7
Removed build output accidentally committed in a merge
peterzimon Aug 6, 2026
a57cf8c
Improved the visual details of the React posts list rows
peterzimon Aug 6, 2026
68ff06f
Fixed reopening the same post rendering an empty screen
peterzimon Aug 6, 2026
845abe2
Changed the React posts list header to match the members list
peterzimon Aug 6, 2026
0f63865
Improved the filter and sort controls on the React posts list
peterzimon Aug 6, 2026
a9c2c59
Fixed the editor back button pointing at Analytics from the posts list
peterzimon Aug 6, 2026
45f8380
Changed the React posts list selected row to a light blue
peterzimon Aug 6, 2026
8136a85
Improved the React posts list right-click menu
peterzimon Aug 6, 2026
24350f8
Fixed the Ember posts list losing its gift-link modal behind the Reac…
9larsons Aug 6, 2026
e9d708e
Fixed stale caches and wrong-bucket rows after posts list bulk actions
9larsons Aug 6, 2026
c3d3070
Fixed the e2e posts filter helper racing Ember re-renders
9larsons Aug 6, 2026
20f206d
Merge remote-tracking branch 'origin/main' into react-posts-pages-list
peterzimon Aug 6, 2026
bc0b2fe
Merge remote-tracking branch 'origin/react-posts-pages-list' into rea…
peterzimon Aug 6, 2026
5ec97d9
Fixed the posts list analytics counts blanking while the next page loads
9larsons Aug 6, 2026
8ba4c93
Added accessible descriptions to the bulk tag and access modals
9larsons Aug 6, 2026
ac387dc
Cleaned dead code and a stale docblock from the posts list
9larsons Aug 6, 2026
7e68475
Moved the posts list testids into the shared selectors package
9larsons Aug 6, 2026
0ce4871
Cleaned the duplicated boot fakes from the posts list acceptance tests
9larsons Aug 6, 2026
896bc2e
Fixed the clipboard stub leaking across context-menu tests
9larsons Aug 6, 2026
05562fc
Changed the Ember posts route flag check to strict boolean
9larsons Aug 6, 2026
538d1fd
Fixed parking the Ember router wiping react-router's history state
9larsons Aug 6, 2026
a390361
Fixed bulk-action totals drifting on edits and inverted deletes
9larsons Aug 6, 2026
be993ae
Fixed a stray menu separator for a single public published post
9larsons Aug 6, 2026
1e007b8
Changed the e2e posts filter helpers to branch by implementation ever…
9larsons Aug 6, 2026
a25dcf0
Removed a mis-attributed perf note from the selection hook
9larsons Aug 6, 2026
bbfe422
Removed two e2e posts tests already covered by acceptance
9larsons Aug 6, 2026
b72ede2
Added the unfiltered-unpublish divergence to the e2e divergences suite
9larsons Aug 6, 2026
1253a20
Cleaned narrative comments and a dead memo from the posts list
9larsons Aug 6, 2026
a60091d
Added unit coverage for the bulk-action cache totals
9larsons Aug 6, 2026
c8736cf
Improved the analytics popover on the React posts list
peterzimon Aug 10, 2026
3f6c192
Changed the per-row analytics to Ember's icon style
peterzimon Aug 10, 2026
1d899fe
Changed the add-tag modal to a chip picker
peterzimon Aug 10, 2026
a1ca916
Fixed new tags missing from the list until a refresh
peterzimon Aug 10, 2026
95bf1ba
Improved the tag picker's keyboard and selection behaviour
peterzimon Aug 10, 2026
1c873dc
Fixed same-named filter options highlighting together
peterzimon Aug 10, 2026
1f0473d
Merge remote-tracking branch 'origin/main' into react-posts-pages-list
peterzimon Aug 10, 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
43 changes: 41 additions & 2 deletions apps/admin-x-framework/src/api/pages.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import {InfiniteData} from '@tanstack/react-query';
import {Meta, createInfiniteQuery, createQuery, createQueryWithId} from '../utils/api/hooks';
import {Meta, createInfiniteQuery, createMutation, createQuery, createQueryWithId} from '../utils/api/hooks';
import type {Email, PostBulkAction, PostListFields} from './posts';

// A page is a post with `displayName: 'page'` server-side, so the list screens
// read the same fields off both.
export type Page = {
id: string;
title: string;
Expand All @@ -10,7 +13,17 @@ export type Page = {
published_at?: string;
visibility?: string;
uuid?: string;
};
feature_image?: string;
email?: Email;
count?: {
clicks?: number;
};
// Pages are never emailed, but the list reads these off both resources
// through one type, so they have to be addressable here too.
email_only?: boolean;
email_segment?: string;
newsletter?: object;
} & PostListFields;

export interface PagesResponseType {
meta?: Meta
Expand Down Expand Up @@ -54,3 +67,29 @@ export const useBrowsePagesInfinite = createInfiniteQuery<PagesResponseType & {i
};
}
});

/** Duplicate a page. As with posts, the copy is always a draft. */
export const useCopyPage = createMutation<PagesResponseType, string>({
method: 'POST',
path: id => `/pages/${id}/copy/`
});

/** Bulk-edit pages matching an NQL filter. See `useBulkEditPosts`. */
export const useBulkEditPages = createMutation<unknown, {filter: string; action: PostBulkAction}>({
method: 'PUT',
path: () => '/pages/bulk/',
searchParams: ({filter}) => ({filter}),
body: ({action}) => ({
bulk: {
action: action.type,
meta: 'meta' in action ? action.meta : {}
}
})
});

/** Bulk-delete pages matching an NQL filter. */
export const useBulkDeletePages = createMutation<unknown, {filter: string}>({
method: 'DELETE',
path: () => '/pages/',
searchParams: ({filter}) => ({filter})
});
82 changes: 81 additions & 1 deletion apps/admin-x-framework/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,43 @@ export type Email = {
opened_count: number;
email_count: number;
status?: string;
track_opens?: boolean;
track_clicks?: boolean;
};

// Every field optional: these are supertypes of the narrower author/tag shapes
// already declared around the analytics screens, so widening `Post` doesn't
// invalidate them. The list only reads names and slugs.
export type PostAuthor = {
id?: string;
name?: string;
email?: string;
slug?: string;
};

export type PostTag = {
id?: string;
name?: string;
slug?: string;
visibility?: string;
};

/**
* Fields the list screens need on top of the analytics-shaped core. All
* optional: the analytics endpoints don't return them, and the list gets them
* from the server's default relations rather than an explicit `include`.
*/
export type PostListFields = {
featured?: boolean;
updated_at?: string;
created_at?: string;
excerpt?: string;
custom_excerpt?: string;
authors?: PostAuthor[];
primary_author?: PostAuthor | null;
tags?: PostTag[];
primary_tag?: PostTag | null;
tiers?: object[];
};

export type Post = {
Expand All @@ -30,7 +67,7 @@ export type Post = {
email_recipient_filter?: string;
send_email_when_published?: boolean;
email_stats?: object;
};
} & PostListFields;

export interface PostsResponseType {
meta?: Meta
Expand Down Expand Up @@ -80,6 +117,49 @@ export const useDeletePost = createMutation<unknown, string>({
path: id => `/posts/${id}/`
});

export type PostBulkAction =
| {type: 'feature'}
| {type: 'unfeature'}
| {type: 'unpublish'}
| {type: 'unschedule'}
| {type: 'addTag'; meta: {tags: {id?: string; name: string; slug?: string}[]}}
| {type: 'access'; meta: {visibility: string; tiers?: {id: string}[]}};

/**
* Bulk-edit posts matching an NQL filter.
*
* The filter is the point: after Cmd+A the selection is inverted and covers
* posts that were never loaded, so the action has to be expressed as a query
* rather than as a list of ids.
*/
export const useBulkEditPosts = createMutation<unknown, {filter: string; action: PostBulkAction}>({
method: 'PUT',
path: () => '/posts/bulk/',
searchParams: ({filter}) => ({filter}),
body: ({action}) => ({
bulk: {
action: action.type,
meta: 'meta' in action ? action.meta : {}
}
})
});

/** Bulk-delete posts matching an NQL filter. */
export const useBulkDeletePosts = createMutation<unknown, {filter: string}>({
method: 'DELETE',
path: () => '/posts/',
searchParams: ({filter}) => ({filter})
});

/**
* Duplicate a post. The copy is always a draft, whatever the source was, so
* callers place it at the top of the list rather than beside its original.
*/
export const useCopyPost = createMutation<PostsResponseType, string>({
method: 'POST',
path: id => `/posts/${id}/copy/`
});

// Search index endpoints for efficient search
export const useSearchIndexPosts = createQuery<PostsResponseType>({
dataType,
Expand Down
75 changes: 75 additions & 0 deletions apps/admin-x-framework/src/api/stats.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {createQuery, createQueryWithId} from '../utils/api/hooks';
import {apiUrl, useFetchApi} from '../utils/api/fetch-api';
import {keepPreviousData, useQuery} from '@tanstack/react-query';

// Types

Expand Down Expand Up @@ -366,3 +368,76 @@ export const useSubscriberCountByNewsletterId = (newsletterId?: string, options:

return useSubscriberCount({searchParams});
};

// Post visitor and member counts
//
// These two are POST-with-body *reads* — the id lists are too long for a query
// string — so they can't go through `createQuery`, which only builds GETs.
// Written directly against `useQuery` instead.

export type PostVisitorCounts = Record<string, number>;

export interface PostMemberCounts {
[postId: string]: {free: number; paid: number};
}

/**
* Visitor counts for a batch of posts, keyed by post uuid.
*
* The uuid list is part of the query key, so changing the filter starts a new
* query rather than writing a stale response over the new one — which is what
* the Ember service's manual generation counter exists to prevent.
*/
export const usePostVisitorCounts = (postUuids: string[], {enabled = true} = {}) => {
const fetchApi = useFetchApi();

return useQuery<PostVisitorCounts>({
queryKey: ['PostVisitorCounts', [...postUuids].sort().join(',')],
enabled: enabled && postUuids.length > 0,
// The id list is the key, so loading the next page is a brand-new
// query — without this every visible count blanks to zero meanwhile.
placeholderData: keepPreviousData,
queryFn: async () => {
const response = await fetchApi<{stats?: Array<{data?: {visitor_counts?: PostVisitorCounts}}>}>(
apiUrl('/stats/posts-visitor-counts/'),
{method: 'POST', body: JSON.stringify({postUuids})}
);

return response.stats?.[0]?.data?.visitor_counts ?? {};
}
});
};

/** Free and paid member counts for a batch of posts, keyed by post id. */
export const usePostMemberCounts = (postIds: string[], {enabled = true} = {}) => {
const fetchApi = useFetchApi();

return useQuery<PostMemberCounts>({
queryKey: ['PostMemberCounts', [...postIds].sort().join(',')],
enabled: enabled && postIds.length > 0,
placeholderData: keepPreviousData,
queryFn: async () => {
// The endpoint returns `{stats: [{<postId>: {free_members,
// paid_members}}]}` — the map sits directly in the first element,
// not under a `data` key like the visitor endpoint's.
// No explicit content-type: `fetchApi` sets it for string bodies,
// and passing `Content-Type` here as well produces two
// differently-cased keys in the same header object, which the
// request drops — the endpoint then sees no body and returns an
// empty map rather than an error.
const response = await fetchApi<{
stats?: Array<Record<string, {free_members?: number; paid_members?: number}>>
}>(
apiUrl('/stats/posts-member-counts/'),
{method: 'POST', body: JSON.stringify({postIds})}
);

const raw = response.stats?.[0] ?? {};

return Object.fromEntries(Object.entries(raw).map(([postId, counts]) => [
postId,
{free: counts.free_members ?? 0, paid: counts.paid_members ?? 0}
]));
}
});
};
17 changes: 13 additions & 4 deletions apps/admin-x-framework/src/api/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,19 @@ export const useBrowseUsers = createInfiniteQuery<UsersResponseType & {isEnd: bo
dataType,
path: '/users/',
defaultSearchParams: {limit: '100', include: 'roles'},
defaultNextPageParams: (lastPage, otherParams) => ({
...otherParams,
page: (lastPage.meta?.pagination.next || 1).toString()
}),
defaultNextPageParams: (lastPage, otherParams) => {
// Returning a param unconditionally makes TanStack report hasNextPage
// forever, so consumers render a "Load more" that refetches page 1.
// Every other resource here guards the same way.
if (!lastPage.meta?.pagination.next) {
return undefined;
}

return {
...otherParams,
page: lastPage.meta.pagination.next.toString()
};
},
returnData: (originalData) => {
const {pages} = originalData as InfiniteData<UsersResponseType>;
const users = pages.flatMap(page => page.users);
Expand Down
2 changes: 1 addition & 1 deletion apps/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
"@types/react-svg-map": "2.1.4",
"@types/semver": "catalog:",
"@types/validator": "catalog:",
"@typescript/native": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/browser-playwright": "catalog:",
"eslint": "catalog:",
Expand All @@ -89,7 +90,6 @@
"msw": "catalog:",
"sirv": "3.0.2",
"tailwindcss": "catalog:",
"@typescript/native": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"vite": "catalog:",
Expand Down
15 changes: 4 additions & 11 deletions apps/admin/src/analytics/hooks/use-latest-post-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,9 @@ import {type Post, useBrowsePosts} from '@tryghost/admin-x-framework/api/posts';
import {useMemo} from 'react';
import {usePostStats} from '@tryghost/admin-x-framework/api/stats';

// Extended Post interface that includes authors and excerpt
interface ExtendedPost extends Post {
authors?: {
name: string;
}[];
excerpt?: string;
count?: {
clicks?: number;
};
}
// `Post` now carries authors, excerpt and click counts itself — this alias is
// kept so the rest of the file reads unchanged.
type ExtendedPost = Post;

export interface LatestPostWithStats {
id: string;
Expand All @@ -33,7 +26,7 @@ export interface LatestPostWithStats {
clicks?: number;
} | null;
authors?: {
name: string;
name?: string;
}[];
// Analytics data
recipient_count: number | null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import FeatureImagePlaceholder from '@/analytics/views/stats/components/feature-image-placeholder';
import FeatureImagePlaceholder from '@/shared/feature-image-placeholder';
import React from 'react';
import {Card, CardContent, CardDescription, CardHeader, CardTitle, EmptyIndicator, SkeletonTable} from '@tryghost/shade/components';
import {LucideIcon, abbreviateNumber, cn, formatDisplayDate, formatNumber} from '@tryghost/shade/utils';
Expand Down
70 changes: 70 additions & 0 deletions apps/admin/src/ember-bridge/ember-bridge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,76 @@ describe('useEmberDataSync', () => {
unmount();
});

/**
* Saving a post in the editor can *create* tags: a tag typed into the post
* is written as part of the post's own save, as an embedded relation. Ember
* therefore reports a `post` change and never a `tag` one — so the posts
* list's tag filter kept serving a cached list, and a tag you had just
* made was missing from it until a full browser reload.
*/
queryTest('invalidates tags when Ember saves a post, which can create them', async ({ queryClient, wrapper }) => {
const mock = createMockStateBridge();
window.EmberBridge = { state: mock.stateBridge };

// Without a gcTime these are collected before the assertion runs, and
// `every` on an empty array passes vacuously.
queryClient.setQueryDefaults(['TagsResponseType', '/tags'], {gcTime: Infinity});
queryClient.setQueryDefaults(['MembersResponseType', '/members'], {gcTime: Infinity});
queryClient.setQueryData(['TagsResponseType', '/tags'], { tags: [] });
queryClient.setQueryData(['MembersResponseType', '/members'], { members: [] });

renderHook(() => useEmberDataSync(), { wrapper });

await waitFor(() => {
expect(mock.onSpy).toHaveBeenCalledWith('emberDataChange', expect.any(Function));
});

act(() => {
mock.emit('emberDataChange', {
operation: 'update',
modelName: 'post',
id: '1',
data: null,
});
});

await waitFor(() => {
const queries = queryClient.getQueryCache().getAll();
const tagQueries = queries.filter(q => q.queryKey[0] === 'TagsResponseType');
const memberQueries = queries.filter(q => q.queryKey[0] === 'MembersResponseType');

expect(tagQueries.length).toBeGreaterThan(0);
expect(tagQueries.every(q => q.state.isInvalidated)).toBe(true);
// ...and nothing unrelated is dragged along with it.
expect(memberQueries.every(q => !q.state.isInvalidated)).toBe(true);
});
});

queryTest('invalidates tags when Ember saves a page too', async ({ queryClient, wrapper }) => {
const mock = createMockStateBridge();
window.EmberBridge = { state: mock.stateBridge };

queryClient.setQueryDefaults(['TagsResponseType', '/tags'], {gcTime: Infinity});
queryClient.setQueryData(['TagsResponseType', '/tags'], { tags: [] });

renderHook(() => useEmberDataSync(), { wrapper });

await waitFor(() => {
expect(mock.onSpy).toHaveBeenCalledWith('emberDataChange', expect.any(Function));
});

act(() => {
mock.emit('emberDataChange', { operation: 'update', modelName: 'page', id: '1', data: null });
});

await waitFor(() => {
const tagQueries = queryClient.getQueryCache().getAll().filter(q => q.queryKey[0] === 'TagsResponseType');

expect(tagQueries.length).toBeGreaterThan(0);
expect(tagQueries.every(q => q.state.isInvalidated)).toBe(true);
});
Comment on lines +209 to +231

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert PagesResponseType invalidation for a page save.

This test only checks TagsResponseType. It passes if page maps to any configured data type, including an incorrect one. Seed a PagesResponseType query and assert that the page event invalidates it.

🤖 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/admin/src/ember-bridge/ember-bridge.test.tsx` around lines 209 - 231,
Update the test around useEmberDataSync to seed a PagesResponseType query for
the page endpoint and assert that the emitted page update invalidates it. Keep
the existing TagsResponseType assertion if needed, but explicitly verify
PagesResponseType invalidation so the page model mapping is tested.

});

queryTest('invalidates the sidebar member count query for Ember member changes', async ({ queryClient, wrapper }) => {
const mock = createMockStateBridge();
window.EmberBridge = { state: mock.stateBridge };
Expand Down
Loading