diff --git a/apps/admin-x-framework/src/api/pages.ts b/apps/admin-x-framework/src/api/pages.ts index 501115bdec5..7275d28c4d7 100644 --- a/apps/admin-x-framework/src/api/pages.ts +++ b/apps/admin-x-framework/src/api/pages.ts @@ -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; @@ -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 @@ -54,3 +67,29 @@ export const useBrowsePagesInfinite = createInfiniteQuery({ + method: 'POST', + path: id => `/pages/${id}/copy/` +}); + +/** Bulk-edit pages matching an NQL filter. See `useBulkEditPosts`. */ +export const useBulkEditPages = createMutation({ + 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({ + method: 'DELETE', + path: () => '/pages/', + searchParams: ({filter}) => ({filter}) +}); diff --git a/apps/admin-x-framework/src/api/posts.ts b/apps/admin-x-framework/src/api/posts.ts index 2f2f13fcbe7..5db28116e01 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -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 = { @@ -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 @@ -80,6 +117,49 @@ export const useDeletePost = createMutation({ 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({ + 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({ + 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({ + method: 'POST', + path: id => `/posts/${id}/copy/` +}); + // Search index endpoints for efficient search export const useSearchIndexPosts = createQuery({ dataType, diff --git a/apps/admin-x-framework/src/api/stats.ts b/apps/admin-x-framework/src/api/stats.ts index 45470dc3f51..f1b74d97010 100644 --- a/apps/admin-x-framework/src/api/stats.ts +++ b/apps/admin-x-framework/src/api/stats.ts @@ -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 @@ -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; + +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({ + 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({ + queryKey: ['PostMemberCounts', [...postIds].sort().join(',')], + enabled: enabled && postIds.length > 0, + placeholderData: keepPreviousData, + queryFn: async () => { + // The endpoint returns `{stats: [{: {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> + }>( + 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} + ])); + } + }); +}; diff --git a/apps/admin-x-framework/src/api/users.ts b/apps/admin-x-framework/src/api/users.ts index 3131326e57b..fcd209a09b7 100644 --- a/apps/admin-x-framework/src/api/users.ts +++ b/apps/admin-x-framework/src/api/users.ts @@ -83,10 +83,19 @@ export const useBrowseUsers = createInfiniteQuery ({ - ...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; const users = pages.flatMap(page => page.users); diff --git a/apps/admin/package.json b/apps/admin/package.json index 681dca9bc8e..5419f8cb377 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -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:", @@ -89,7 +90,6 @@ "msw": "catalog:", "sirv": "3.0.2", "tailwindcss": "catalog:", - "@typescript/native": "catalog:", "typescript": "catalog:", "typescript-eslint": "catalog:", "vite": "catalog:", diff --git a/apps/admin/src/analytics/hooks/use-latest-post-stats.ts b/apps/admin/src/analytics/hooks/use-latest-post-stats.ts index 7022626d438..d7b06b78179 100644 --- a/apps/admin/src/analytics/hooks/use-latest-post-stats.ts +++ b/apps/admin/src/analytics/hooks/use-latest-post-stats.ts @@ -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; @@ -33,7 +26,7 @@ export interface LatestPostWithStats { clicks?: number; } | null; authors?: { - name: string; + name?: string; }[]; // Analytics data recipient_count: number | null; diff --git a/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx b/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx index 3eb0e9d4ae0..2146c5efd4c 100644 --- a/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx +++ b/apps/admin/src/analytics/views/stats/overview/components/top-posts.tsx @@ -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'; diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index d3ff06d0919..4be57831feb 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -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); + }); + }); + queryTest('invalidates the sidebar member count query for Ember member changes', async ({ queryClient, wrapper }) => { const mock = createMockStateBridge(); window.EmberBridge = { state: mock.stateBridge }; diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index 0710853a820..b4c94326bb6 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -86,6 +86,10 @@ const EMBER_TO_REACT_TYPE_MAPPING: Record = { 'tier': 'TiersResponseType', 'user': 'UsersResponseType', 'post': 'PostsResponseType', + // Without this, saving a page in the (Ember) editor never invalidates the + // React pages list, so a newly created page only appears after a manual + // refresh. Harmless while Ember owned /pages; visible as soon as React does. + 'page': 'PagesResponseType', 'member': 'MembersResponseType', 'comment': 'CommentsResponseType', 'tag': 'TagsResponseType', @@ -167,11 +171,23 @@ export function useEmberDataSync() { return; } - // Invalidate all queries matching this data type + /** + * Saving a post or page can *create* tags: a tag typed into the + * editor is written as part of that post's own save, as an embedded + * relation. Ember therefore reports a `post` change and never a + * `tag` one — so without this the posts list's tag filter keeps + * serving a cached list, and a tag the user just made is missing + * from it until a full browser reload. + */ + const alsoInvalidate = modelName === 'post' || modelName === 'page' + ? ['TagsResponseType'] + : []; + const dataTypes = new Set([reactDataType, ...alsoInvalidate]); + void queryClient.invalidateQueries({ predicate: (query) => { // Query keys are structured as [dataType, url] - return query.queryKey[0] === reactDataType; + return dataTypes.has(query.queryKey[0] as string); } }); }; diff --git a/apps/admin/src/flag-gated-route.test.tsx b/apps/admin/src/flag-gated-route.test.tsx index 4a35ac864c5..a115245f7c5 100644 --- a/apps/admin/src/flag-gated-route.test.tsx +++ b/apps/admin/src/flag-gated-route.test.tsx @@ -165,4 +165,68 @@ describe('FlagGatedRoute', () => { expect(screen.getByTestId('react-screen')).toBeInTheDocument(); }); }); + + describe('with a custom fallback', () => { + // The posts and pages lists need more than a bare EmberFallback while + // the flag is off: the Ember list's context menu opens the React + // gift-link modal over the state bridge, so that host has to stay + // mounted alongside Ember. + const customFallback =
; + + it('renders the custom fallback instead of EmberFallback while the flag is off', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({someFlag: false})); + + render(); + + expect(screen.getByTestId('custom-fallback')).toBeInTheDocument(); + expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); + }); + + it('renders the custom fallback when the config query fails', () => { + mockUseBrowseConfig.mockReturnValue(configResult({isError: true, data: undefined})); + + render(); + + expect(screen.getByTestId('custom-fallback')).toBeInTheDocument(); + expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); + }); + + it('renders the custom fallback when Ember reports the flag off', () => { + // The Ember-authority branch is the production path — Ember present + // with labs loaded. It has to honour `fallback` too, or the + // gift-link host disappears exactly when Ember serves the list. + // Config says on, Ember says off: Ember wins AND fallback renders. + mockUseBrowseConfig.mockReturnValue(withLabs({someFlag: true})); + window.EmberBridge = { + state: { + isFeatureEnabled: () => false + } + } as unknown as typeof window.EmberBridge; + + render(); + + expect(screen.getByTestId('custom-fallback')).toBeInTheDocument(); + expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); + expect(screen.queryByTestId('react-screen')).not.toBeInTheDocument(); + }); + + it('still renders nothing while config is loading', () => { + mockUseBrowseConfig.mockReturnValue(configResult({isLoading: true})); + + const {container} = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('still renders React while the flag is on', async () => { + mockUseBrowseConfig.mockReturnValue(withLabs({someFlag: true})); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('react-screen')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('custom-fallback')).not.toBeInTheDocument(); + }); + }); }); diff --git a/apps/admin/src/flag-gated-route.tsx b/apps/admin/src/flag-gated-route.tsx index 9622ab072ab..1971659f257 100644 --- a/apps/admin/src/flag-gated-route.tsx +++ b/apps/admin/src/flag-gated-route.tsx @@ -1,5 +1,5 @@ import { EmberFallback, useEmberFeatureFlag } from "./ember-bridge"; -import { Suspense, type ComponentType, type LazyExoticComponent } from "react"; +import { Suspense, type ComponentType, type LazyExoticComponent, type ReactNode } from "react"; import { useBrowseConfig } from "@tryghost/admin-x-framework/api/config"; /** @@ -23,10 +23,16 @@ import { useBrowseConfig } from "@tryghost/admin-x-framework/api/config"; * Errors are deliberately not reported here: `useBrowseConfig` already routes * them through the framework's default error handler, and the shell calls the * same query, so anything logged here would be a duplicate. + * + * `fallback` overrides what the Ember side renders, for routes that need more + * than a bare EmberFallback while the flag is off — the posts and pages lists + * also mount the React gift-link modal host, which the Ember context menu + * opens over the state bridge. */ -export function FlagGatedRoute({ flag, component: Component }: { +export function FlagGatedRoute({ flag, component: Component, fallback = }: { flag: string; component: LazyExoticComponent; + fallback?: ReactNode; }) { const { data: config, isError, isLoading } = useBrowseConfig(); const emberFlag = useEmberFeatureFlag(flag); @@ -38,7 +44,7 @@ export function FlagGatedRoute({ flag, component: Component }: { ); if (typeof emberFlag === 'boolean') { - return emberFlag ? renderReact() : ; + return emberFlag ? renderReact() : fallback; } if (emberFlag === null) { @@ -50,11 +56,11 @@ export function FlagGatedRoute({ flag, component: Component }: { } if (isError || !config) { - return ; + return fallback; } if (config.config.labs?.[flag] !== true) { - return ; + return fallback; } return renderReact(); diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 71819d23abb..0a6c31985a7 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -8,10 +8,10 @@ import {getSettingValue, useBrowseSettings} from "@tryghost/admin-x-framework/ap import { canManageAutomations, canManageMembers, canManageTags } from "@tryghost/admin-x-framework/api/users"; import { NavMenuItem } from "./nav-menu-item"; import { useNavigationExpanded } from "./hooks/use-navigation-preferences"; -import { NavCustomViews } from "./nav-custom-views"; +import { NavSavedViews } from "./nav-saved-views"; import { NavMemberViews } from "./nav-member-views"; import { useMemberSidebarViews } from "./member-sidebar-views"; -import { useCustomSidebarViews } from "./use-custom-sidebar-views"; +import { usePostNavigation } from "./use-post-navigation"; import { useIsActiveLink } from "./use-is-active-link"; import { useEmberRouting } from "@/ember-bridge"; import { useFeatureFlag } from "@tryghost/admin-x-framework/hooks"; @@ -73,7 +73,8 @@ function NavContent({ ...props }: React.ComponentProps) { const {data: settingsData} = useBrowseSettings(); const [savedPostsExpanded, setPostsExpanded] = useNavigationExpanded('posts'); const [savedMembersExpanded, setMembersExpanded] = useNavigationExpanded('members'); - const postCustomViews = useCustomSidebarViews('posts'); + const postNavigation = usePostNavigation('posts'); + const pageNavigation = usePostNavigation('pages'); const memberViews = useMemberSidebarViews(); const hasMemberViews = memberViews.length > 0; const memberCount = useMemberCount(); @@ -86,19 +87,16 @@ function NavContent({ ...props }: React.ComponentProps) { const showAutomations = currentUser && canManageAutomations(currentUser); const commentsEnabled = getSettingValue(settingsData?.settings, 'comments_enabled'); const showComments = !!showMembers && commentsEnabled !== 'off'; - const isDraftPostsRouteActive = routing.isRouteActive('posts', {type: 'draft'}); - const isScheduledPostsRouteActive = routing.isRouteActive('posts', {type: 'scheduled'}); - const isPublishedPostsRouteActive = routing.isRouteActive('posts', {type: 'published'}); - const hasActivePostChild = isDraftPostsRouteActive || isScheduledPostsRouteActive || isPublishedPostsRouteActive || postCustomViews.some(view => view.isActive); + const postViews = [...postNavigation.defaultViews, ...postNavigation.customViews]; + const hasActivePostChild = postViews.some(view => view.isActive); const postsExpanded = savedPostsExpanded; const hasActiveMemberView = hasMemberViews && memberViews.some(view => view.isActive); const membersExpanded = savedMembersExpanded; 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 postsRoute = postNavigation.mainUrl; + const postsNavActive = postNavigation.isMainActive || (!postsExpanded && hasActivePostChild); return ( @@ -116,35 +114,14 @@ function NavContent({ ...props }: React.ComponentProps) { - - Drafts - - - - Scheduled - - - - Published - - - + Pages diff --git a/apps/admin/src/layout/app-sidebar/nav-custom-views.test.tsx b/apps/admin/src/layout/app-sidebar/nav-custom-views.test.tsx deleted file mode 100644 index 12ffffb2d23..00000000000 --- a/apps/admin/src/layout/app-sidebar/nav-custom-views.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -// @vitest-environment jsdom - -import {describe, expect, it, vi} from 'vitest'; -import {renderHook} from '@testing-library/react'; -import {type SharedView} from './shared-views'; -import {useCustomSidebarViews} from './use-custom-sidebar-views'; - -interface EmberRoutingMock { - getRouteUrl: (route: 'posts' | 'pages', filter: Record) => string; - isRouteActive: (route: 'posts' | 'pages', filter: Record) => boolean; -} - -const {mockUseSharedViews, mockUseEmberRouting} = vi.hoisted(() => ({ - mockUseSharedViews: vi.fn<(route?: string) => SharedView[]>(), - mockUseEmberRouting: vi.fn<() => EmberRoutingMock>() -})); - -vi.mock('./shared-views', () => ({ - useSharedViews: mockUseSharedViews -})); - -vi.mock('./nav-saved-views', () => ({ - NavSavedViews: () => null -})); - -vi.mock('@/ember-bridge', () => ({ - useEmberRouting: mockUseEmberRouting -})); - -describe('useCustomSidebarViews', () => { - it('maps shared views to sidebar views using Ember routing', () => { - mockUseSharedViews.mockReturnValue([ - { - name: 'Drafts by me', - route: 'posts', - color: 'green', - filter: {type: 'draft', author: 'me'} - } - ]); - mockUseEmberRouting.mockReturnValue({ - getRouteUrl: vi.fn(() => 'posts?type=draft&author=me'), - isRouteActive: vi.fn(() => true) - }); - - const {result} = renderHook(() => useCustomSidebarViews('posts')); - - expect(result.current).toEqual([ - { - key: 'posts?type=draft&author=me', - name: 'Drafts by me', - to: 'posts?type=draft&author=me', - isActive: true, - color: 'green' - } - ]); - }); -}); diff --git a/apps/admin/src/layout/app-sidebar/nav-custom-views.tsx b/apps/admin/src/layout/app-sidebar/nav-custom-views.tsx deleted file mode 100644 index 155af9da76c..00000000000 --- a/apps/admin/src/layout/app-sidebar/nav-custom-views.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { NavSavedViews } from './nav-saved-views'; -import { useCustomSidebarViews } from './use-custom-sidebar-views'; - -interface NavCustomViewsProps { - route?: 'posts' | 'pages'; -} - -export function NavCustomViews({ route = 'posts' }: NavCustomViewsProps) { - const customViews = useCustomSidebarViews(route); - - return ; -} diff --git a/apps/admin/src/layout/app-sidebar/post-sidebar-views.test.ts b/apps/admin/src/layout/app-sidebar/post-sidebar-views.test.ts new file mode 100644 index 00000000000..56323aada89 --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/post-sidebar-views.test.ts @@ -0,0 +1,104 @@ +import {describe, expect, it} from 'vitest'; +import { + POST_DEFAULT_VIEWS, + buildPostViewUrl, + getDefaultPostViews, + isPostViewActive +} from './post-sidebar-views'; + +// Saved views are addressed by the same five params the list screen uses, and +// Ember compares them for exact equality after dropping nulls. Getting this +// wrong means the sidebar highlights the wrong thing, or nothing. + +describe('buildPostViewUrl', () => { + it('builds a bare route URL for an empty filter', () => { + expect(buildPostViewUrl('posts', {})).toBe('posts'); + }); + + it('includes the params that are set', () => { + expect(buildPostViewUrl('posts', {type: 'draft'})).toBe('posts?type=draft'); + }); + + it('drops nulls rather than emitting empty params', () => { + expect(buildPostViewUrl('posts', {type: 'draft', tag: null, author: null})) + .toBe('posts?type=draft'); + }); + + // Stable order regardless of the record's key order, so the same view + // always produces the same URL. + it('orders params consistently', () => { + const fromOneOrder = buildPostViewUrl('posts', {tag: 'news', type: 'draft'}); + const fromAnother = buildPostViewUrl('posts', {type: 'draft', tag: 'news'}); + + expect(fromOneOrder).toBe(fromAnother); + expect(fromOneOrder).toBe('posts?type=draft&tag=news'); + }); + + it('builds pages URLs too', () => { + expect(buildPostViewUrl('pages', {type: 'draft'})).toBe('pages?type=draft'); + }); +}); + +describe('isPostViewActive', () => { + const at = (search: string) => ({pathname: '/posts', search}); + + it('matches when every param agrees', () => { + expect(isPostViewActive(at('?type=draft'), 'posts', {type: 'draft'})).toBe(true); + }); + + it('does not match a different route', () => { + expect(isPostViewActive({pathname: '/pages', search: '?type=draft'}, 'posts', {type: 'draft'})) + .toBe(false); + }); + + // Ember compares the whole param set, so a view is not active merely + // because the URL is a superset of it. + it('does not match when the URL carries an extra param', () => { + expect(isPostViewActive(at('?type=draft&tag=news'), 'posts', {type: 'draft'})).toBe(false); + }); + + it('does not match when the URL is missing one of the view params', () => { + expect(isPostViewActive(at('?type=draft'), 'posts', {type: 'draft', tag: 'news'})).toBe(false); + }); + + it('treats a null in the view as absent from the URL', () => { + expect(isPostViewActive(at('?type=draft'), 'posts', {type: 'draft', tag: null})).toBe(true); + }); + + it('matches an empty view against a bare URL', () => { + expect(isPostViewActive(at(''), 'posts', {})).toBe(true); + expect(isPostViewActive(at('?type=draft'), 'posts', {})).toBe(false); + }); + + // Sort is part of a view's identity in Ember - it is one of the five + // params `reset-query-params` covers. + it('includes order in the comparison', () => { + expect(isPostViewActive(at('?type=draft'), 'posts', {type: 'draft', order: 'published_at asc'})) + .toBe(false); + expect(isPostViewActive(at('?type=draft&order=published_at+asc'), 'posts', { + type: 'draft', order: 'published_at asc' + })).toBe(true); + }); + + it('ignores params that are not part of a view', () => { + expect(isPostViewActive(at('?type=draft&somethingElse=x'), 'posts', {type: 'draft'})).toBe(true); + }); +}); + +describe('getDefaultPostViews', () => { + it('offers Drafts, Scheduled and Published, in that order', () => { + expect(getDefaultPostViews(false).map(view => view.name)) + .toEqual(['Drafts', 'Scheduled', 'Published']); + }); + + it('matches the Ember filters exactly', () => { + expect(POST_DEFAULT_VIEWS.map(view => view.filter)) + .toEqual([{type: 'draft'}, {type: 'scheduled'}, {type: 'published'}]); + }); + + // Contributors only ever see their own drafts, so status views are + // meaningless to them - Ember hides them. + it('offers none to a contributor', () => { + expect(getDefaultPostViews(true)).toEqual([]); + }); +}); diff --git a/apps/admin/src/layout/app-sidebar/post-sidebar-views.ts b/apps/admin/src/layout/app-sidebar/post-sidebar-views.ts new file mode 100644 index 00000000000..487e1570786 --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/post-sidebar-views.ts @@ -0,0 +1,83 @@ +import {POST_VIEW_PARAMS} from '@/posts/list/post-view-params'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * Sidebar saved views for posts and pages. + * + * Resolved from React's own location rather than the Ember routing bridge: + * once the `postsListReact` flag hands `/posts` to React, the Ember route + * aborts and its `currentRouteName` is never `posts`, so every bridge-derived + * active state silently goes dead. Modelled on `member-sidebar-views.ts`, + * which solved the same problem for members. + */ + +export type PostViewFilter = Record; + +export interface PostDefaultView { + name: string; + filter: PostViewFilter; +} + +/** + * Hardcoded in Ember too (`services/custom-views.js`), and always `route: + * 'posts'` — there are no default views for pages. + */ +export const POST_DEFAULT_VIEWS: PostDefaultView[] = [ + {name: 'Drafts', filter: {type: 'draft'}}, + {name: 'Scheduled', filter: {type: 'scheduled'}}, + {name: 'Published', filter: {type: 'published'}} +]; + +export function getDefaultPostViews(isContributor: boolean): PostDefaultView[] { + // Contributors only ever see their own drafts, so status views say nothing. + return isContributor ? [] : POST_DEFAULT_VIEWS; +} + +/** Params are emitted in a fixed order so one view always yields one URL. */ +export function buildPostViewUrl(route: PostResource, filter: PostViewFilter): string { + const params = new URLSearchParams(); + + POST_VIEW_PARAMS.forEach((param) => { + const value = filter[param]; + + if (value !== null && value !== undefined && value !== '') { + params.set(param, value); + } + }); + + const query = params.toString(); + + return query ? `${route}?${query}` : route; +} + +export interface ViewLocation { + pathname: string; + search: string; +} + +/** + * A view is active only when *every* one of the five params agrees, matching + * Ember's `activeView` (which compares the whole cleaned filter). So a view of + * `{type: 'draft'}` is not active on `?type=draft&tag=news` — that is a + * different view, or none. + * + * Params outside the five are ignored; they aren't part of a view's identity. + */ +export function isPostViewActive( + location: ViewLocation, + route: PostResource, + filter: PostViewFilter +): boolean { + if (location.pathname !== `/${route}`) { + return false; + } + + const current = new URLSearchParams(location.search); + + return POST_VIEW_PARAMS.every((param) => { + const expected = filter[param] ?? null; + const actual = current.get(param); + + return expected === (actual === '' ? null : actual); + }); +} diff --git a/apps/admin/src/layout/app-sidebar/use-custom-sidebar-views.ts b/apps/admin/src/layout/app-sidebar/use-custom-sidebar-views.ts deleted file mode 100644 index 13edb3a80d6..00000000000 --- a/apps/admin/src/layout/app-sidebar/use-custom-sidebar-views.ts +++ /dev/null @@ -1,23 +0,0 @@ -import {useMemo} from 'react'; -import {type NavSavedView} from './nav-saved-views'; -import {useSharedViews} from './shared-views'; -import {useEmberRouting} from '@/ember-bridge'; - -export function useCustomSidebarViews(route: 'posts' | 'pages' = 'posts') { - const routing = useEmberRouting(); - const sharedViews = useSharedViews(route); - - return useMemo(() => { - return sharedViews.map((view) => { - const to = routing.getRouteUrl(route, view.filter); - - return { - key: to, - name: view.name, - to, - isActive: routing.isRouteActive(route, view.filter), - color: view.color - }; - }); - }, [route, routing, sharedViews]); -} diff --git a/apps/admin/src/layout/app-sidebar/use-post-navigation.ts b/apps/admin/src/layout/app-sidebar/use-post-navigation.ts new file mode 100644 index 00000000000..8f422f5bef6 --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/use-post-navigation.ts @@ -0,0 +1,131 @@ +import {buildPostViewUrl, getDefaultPostViews, isPostViewActive} from './post-sidebar-views'; +import {getStickyPostFilterUrl} from '@/posts/list/posts-sticky-filters'; +import {isContributorUser} from '@tryghost/admin-x-framework/api/users'; +import {type NavSavedView} from './nav-saved-views'; +import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user'; +import {useEmberRouting} from '@/ember-bridge'; +import {useFeatureFlag} from '@tryghost/admin-x-framework/hooks'; +import {useLocation} from '@tryghost/admin-x-framework'; +import {useMemo} from 'react'; +import {useSharedViews} from './shared-views'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * Everything the sidebar needs for a posts/pages nav item, from whichever + * implementation currently owns the route. + * + * Both sources are computed on every render and only the result is chosen — + * hooks can't be called conditionally, and the flag can flip at runtime. + */ + +export interface PostNavigation { + /** Where the top-level item links to. */ + mainUrl: string; + /** Highlighted only when no view is active, matching Ember. */ + isMainActive: boolean; + defaultViews: NavSavedView[]; + customViews: NavSavedView[]; +} + +function useReactPostNavigation(route: PostResource): PostNavigation { + const location = useLocation(); + const sharedViews = useSharedViews(route); + const {data: currentUser} = useCurrentUser(); + const isContributor = Boolean(currentUser && isContributorUser(currentUser)); + + return useMemo(() => { + const toNavView = (name: string, filter: Record, color?: string): NavSavedView => ({ + // Name included: a saved view whose filter equals a default + // view's would otherwise collide with it. + key: `${name}:${buildPostViewUrl(route, filter)}`, + name, + to: buildPostViewUrl(route, filter), + isActive: isPostViewActive(location, route, filter), + color + }); + + // Posts only, for both. Ember's sidebar shows no views under Pages — + // its save button is hardcoded to the posts route, so `forPages` is + // permanently empty. Surfacing them here would diverge, and worse, + // would let a view nobody can see suppress the Pages highlight. + const viewFilters = route === 'posts' + ? [ + ...getDefaultPostViews(isContributor).map(view => view.filter), + ...sharedViews.map(view => view.filter) + ] + : []; + + const defaultViews = route === 'posts' + ? getDefaultPostViews(isContributor).map(view => toNavView(view.name, view.filter)) + : []; + + const customViews = route === 'posts' + ? sharedViews.map(view => toNavView(view.name, view.filter, view.color)) + : []; + + const allViews = [...defaultViews, ...customViews]; + + return { + // Sticky filters: returns you to the filters you last had, unless + // you are already here or those filters are just a view. + mainUrl: getStickyPostFilterUrl( + route, + location.pathname, + viewFilters + ), + // Ember highlights the parent only when no view underneath is. + isMainActive: location.pathname === `/${route}` + && !allViews.some(view => view.isActive), + defaultViews, + customViews + }; + }, [location, route, sharedViews, isContributor]); +} + +function useEmberPostNavigation(route: PostResource): PostNavigation { + const routing = useEmberRouting(); + const sharedViews = useSharedViews(route); + + return useMemo(() => { + const defaultViews = route === 'posts' + ? POST_DEFAULT_VIEW_LINKS.map(view => ({ + key: view.to, + name: view.name, + to: view.to, + isActive: routing.isRouteActive(route, view.filter) + })) + : []; + + return { + mainUrl: routing.getRouteUrl(route), + isMainActive: routing.isRouteActive(route), + defaultViews, + customViews: sharedViews.map((view) => { + const to = routing.getRouteUrl(route, view.filter); + + return { + key: to, + name: view.name, + to, + isActive: routing.isRouteActive(route, view.filter), + color: view.color + }; + }) + }; + }, [route, routing, sharedViews]); +} + +/** The Ember branch keeps its hardcoded links, as `nav-content` had them. */ +const POST_DEFAULT_VIEW_LINKS = [ + {name: 'Drafts', to: 'posts?type=draft', filter: {type: 'draft'}}, + {name: 'Scheduled', to: 'posts?type=scheduled', filter: {type: 'scheduled'}}, + {name: 'Published', to: 'posts?type=published', filter: {type: 'published'}} +]; + +export function usePostNavigation(route: PostResource = 'posts'): PostNavigation { + const reactOwnsList = useFeatureFlag('postsListReact'); + const reactNavigation = useReactPostNavigation(route); + const emberNavigation = useEmberPostNavigation(route); + + return reactOwnsList ? reactNavigation : emberNavigation; +} diff --git a/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx b/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx new file mode 100644 index 00000000000..7cf11733c09 --- /dev/null +++ b/apps/admin/src/layout/editor-sidebar.acceptance.test.tsx @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { page } from "vitest/browser"; + +import { renderAdminApp } from "@test-utils/acceptance"; + +/** + * The editor is a focused writing surface — Ghost hides the nav sidebar for it, + * and always has. + * + * Ember arranges that by setting `ui.isFullScreen` when the editor route + * *activates*. With `postsListReact` on, the posts route aborts its transition, + * so the editor route never deactivates — and a second visit is a model change + * on an already-active route, where `activate()` does not run again. The + * sidebar came back from the second post onwards. + * + * React decides it from the route instead, which does not care how many times + * you have been there. + * + * These tests pin the route's own decision. They cannot prove the *original* + * bug is gone: there is no Ember in this harness, so `useSidebarVisibility` + * returns its default and the Ember half of the handshake is never exercised. + * What they guarantee is that React hides the sidebar on the editor route + * regardless of what Ember reports — which is the property the fix relies on. + * The sequence that produced the bug (list -> editor -> list -> editor) was + * verified by hand against a real Ghost. + */ +describe("Editor chrome", () => { + const sidebar = () => page.getByTestId("admin-sidebar"); + + it("hides the nav sidebar", async () => { + await renderAdminApp("/editor/post/abc123"); + + await expect(sidebar()).toHaveCount(0); + }); + + it("hides it for a page too", async () => { + await renderAdminApp("/editor/page/abc123"); + + await expect(sidebar()).toHaveCount(0); + }); + + // ...and still shows it everywhere else, or this would be a worse bug than + // the one it fixes. + it("leaves the sidebar alone on the posts list", async () => { + await renderAdminApp("/posts"); + + await expect.element(sidebar()).toBeVisible(); + }); +}); diff --git a/apps/admin/src/nql.d.ts b/apps/admin/src/nql.d.ts new file mode 100644 index 00000000000..511c649dc00 --- /dev/null +++ b/apps/admin/src/nql.d.ts @@ -0,0 +1,21 @@ +declare module '@tryghost/nql' { + interface NqlExpansion { + key: string; + replacement: string; + expansion?: string; + } + + interface NqlOptions { + expansions?: NqlExpansion[]; + } + + interface NqlQuery { + /** + * Throws on an unparseable filter — the parse is lazy, so building the + * query is not enough to know the filter is valid. + */ + queryJSON(value: unknown): boolean; + } + + export default function nql(filter: string, options?: NqlOptions): NqlQuery; +} diff --git a/apps/admin/src/posts-list-gate.test.tsx b/apps/admin/src/posts-list-gate.test.tsx new file mode 100644 index 00000000000..8f2186fea01 --- /dev/null +++ b/apps/admin/src/posts-list-gate.test.tsx @@ -0,0 +1,129 @@ +import React from 'react'; +import {PagesListGate, PostsListGate} from './posts-list-gate'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {render, screen, waitFor} from '@testing-library/react'; + +const {mockUseBrowseConfig} = vi.hoisted(() => ({ + mockUseBrowseConfig: vi.fn() +})); + +vi.mock('@tryghost/admin-x-framework/api/config', () => ({ + useBrowseConfig: mockUseBrowseConfig +})); + +// `useEmberFeatureFlag` is the ownership authority when Ember is present. +// Mirroring the real reader (window.EmberBridge, undefined without it) lets +// tests cover both the standalone config path and the integrated Ember path. +vi.mock('./ember-bridge', () => ({ + EmberFallback: () => React.createElement('div', {'data-testid': 'ember-fallback'}), + useEmberFeatureFlag: (flag: string) => { + const stateBridge = window.EmberBridge?.state; + if (!stateBridge?.isFeatureEnabled) { + return undefined; + } + return stateBridge.isFeatureEnabled(flag) ?? null; + } +})); + +// The Ember side of these routes is EmberListWithGiftLinks rather than a bare +// EmberFallback: the Ember list's context menu opens the React gift-link modal +// over the state bridge, so that host has to stay mounted alongside Ember. +vi.mock('./gift-link-modal-host', () => ({ + EmberListWithGiftLinks: () => React.createElement('div', {'data-testid': 'ember-list-with-gift-links'}) +})); + +// Stand in for the real lazy route modules so the test asserts the wiring +// (which gate loads which resource) without pulling in Shade. +vi.mock('./posts/list/posts-route', () => ({ + default: () => React.createElement('div', {'data-testid': 'react-screen', 'data-resource': 'posts'}) +})); + +vi.mock('./posts/list/pages-route', () => ({ + default: () => React.createElement('div', {'data-testid': 'react-screen', 'data-resource': 'pages'}) +})); + +const configResult = (overrides: Record) => ({ + data: undefined, + isError: false, + isLoading: false, + ...overrides +}); + +const withLabs = (labs: Record) => configResult({data: {config: {labs}}}); + +describe('posts and pages list gates', () => { + beforeEach(() => { + mockUseBrowseConfig.mockReset(); + delete window.EmberBridge; + }); + + describe.each([ + {name: 'PostsListGate', Gate: PostsListGate, resource: 'posts'}, + {name: 'PagesListGate', Gate: PagesListGate, resource: 'pages'} + ])('$name', ({Gate, resource}) => { + it('renders the Ember list (with the gift-link host) while the flag is off', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({postsListReact: false})); + + render(); + + expect(screen.getByTestId('ember-list-with-gift-links')).toBeInTheDocument(); + // A bare EmberFallback here would drop the gift-link modal host. + expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); + }); + + it('renders the Ember list (with the gift-link host) when Ember reports the flag off', () => { + // The integrated-admin path: FlagGatedRoute's Ember-authority + // branch used to bypass the `fallback` prop, silently unmounting + // the gift-link host in the flag's default state. + mockUseBrowseConfig.mockReturnValue(withLabs({postsListReact: false})); + window.EmberBridge = { + state: { + isFeatureEnabled: () => false + } + } as unknown as typeof window.EmberBridge; + + render(); + + expect(screen.getByTestId('ember-list-with-gift-links')).toBeInTheDocument(); + expect(screen.queryByTestId('ember-fallback')).not.toBeInTheDocument(); + }); + + it('renders the Ember list when the flag is absent', () => { + mockUseBrowseConfig.mockReturnValue(withLabs({})); + + render(); + + expect(screen.getByTestId('ember-list-with-gift-links')).toBeInTheDocument(); + }); + + it('renders the Ember list when the config query fails', () => { + mockUseBrowseConfig.mockReturnValue(configResult({isError: true})); + + render(); + + expect(screen.getByTestId('ember-list-with-gift-links')).toBeInTheDocument(); + }); + + it('renders nothing while config is loading', () => { + mockUseBrowseConfig.mockReturnValue(configResult({isLoading: true})); + + const {container} = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + // The two gates are otherwise identical, so a copy-paste slip that had + // PagesListGate load the posts screen would ship silently without this. + it(`renders the React ${resource} screen while the flag is on`, async () => { + mockUseBrowseConfig.mockReturnValue(withLabs({postsListReact: true})); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('react-screen')).toBeInTheDocument(); + }); + expect(screen.getByTestId('react-screen')).toHaveAttribute('data-resource', resource); + expect(screen.queryByTestId('ember-list-with-gift-links')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/apps/admin/src/posts-list-gate.tsx b/apps/admin/src/posts-list-gate.tsx new file mode 100644 index 00000000000..7662b673b70 --- /dev/null +++ b/apps/admin/src/posts-list-gate.tsx @@ -0,0 +1,36 @@ +import { EmberListWithGiftLinks } from "./gift-link-modal-host"; +import { FlagGatedRoute } from "./flag-gated-route"; +import { lazy } from "react"; + +/** + * Serves `/posts` and `/pages` from the React list screens when the + * `postsListReact` Labs flag is on, and from Ember otherwise. The gating + * semantics (loading, error, and flag branching) live in FlagGatedRoute. + * + * The Ember side needs EmberListWithGiftLinks rather than a bare EmberFallback: + * the Ember list's context menu opens the React gift-link modal over the state + * bridge, so that host has to stay mounted. The React screens open the modal + * directly and don't need it. + */ +const PostsListReact = lazy(() => import("./posts/list/posts-route")); +const PagesListReact = lazy(() => import("./posts/list/pages-route")); + +export function PostsListGate() { + return ( + } + flag="postsListReact" + /> + ); +} + +export function PagesListGate() { + return ( + } + flag="postsListReact" + /> + ); +} diff --git a/apps/admin/src/posts/analytics/hooks/use-post-success-modal.test.tsx b/apps/admin/src/posts/analytics/hooks/use-post-success-modal.test.tsx index e60d49af7cc..3d094bcdc81 100644 --- a/apps/admin/src/posts/analytics/hooks/use-post-success-modal.test.tsx +++ b/apps/admin/src/posts/analytics/hooks/use-post-success-modal.test.tsx @@ -1,5 +1,4 @@ import {HttpResponse, http} from 'msw'; -import {type Post} from '@tryghost/admin-x-framework/api/posts'; import {act, renderHook, waitFor} from '@testing-library/react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {createTestWrapper, mockData, mockServer} from '@test-utils/posts-analytics/msw-helpers'; @@ -96,7 +95,7 @@ describe('usePostSuccessModal', () => { authors: [{name: 'John Doe'}], email: {email_count: 100, opened_count: 30}, newsletter: {name: 'Weekly Newsletter'} - } as unknown as Partial); + }); // Set up MSW to return the post data mockServer.setup({ @@ -266,7 +265,7 @@ describe('usePostSuccessModal', () => { {name: 'Jane Smith'}, {name: 'Bob Johnson'} ] - } as unknown as Partial); + }); mockServer.setup({ posts: [testPost] diff --git a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts index ae7823ebb04..7b134fd0d98 100644 --- a/apps/admin/src/posts/analytics/providers/post-analytics-context.ts +++ b/apps/admin/src/posts/analytics/providers/post-analytics-context.ts @@ -5,8 +5,10 @@ import {createContext, useContext} from 'react'; export interface Post extends PostBase { published_at?: string; excerpt?: string; + // `name` optional, matching the framework's PostAuthor: invited staff have + // an email but no name yet. authors?: { - name: string; + name?: string; }[]; email?: { opened_count: number; diff --git a/apps/admin/src/posts/list/components/manage-post-view-popover.tsx b/apps/admin/src/posts/list/components/manage-post-view-popover.tsx new file mode 100644 index 00000000000..4065ebe4a35 --- /dev/null +++ b/apps/admin/src/posts/list/components/manage-post-view-popover.tsx @@ -0,0 +1,180 @@ +import {Button, Input, Popover, PopoverContent, PopoverTrigger} from '@tryghost/shade/components'; +import {Inline, Stack, Text} from '@tryghost/shade/primitives'; +import {cn} from '@tryghost/shade/utils'; +import {POST_VIEW_COLORS, type PostViewColor, pickPostViewColor} from '@/posts/list/post-views'; +import {getColorHex} from '@/layout/app-sidebar/shared-views'; +import {useDeletePostView, useSavePostView} from '@/posts/list/hooks/use-post-views'; +import {useNavigate} from '@tryghost/admin-x-framework'; +import {useState} from 'react'; +import type {PostListParams} from '@/posts/list/post-query-params'; +import type {PostResource} from '@/posts/list/post-resource'; +import type {SharedView} from '@/members/shared-views'; + +interface ManagePostViewPopoverProps { + resource: PostResource; + params: PostListParams; + /** The saved view matching the current params, if the user is on one. */ + activeView?: SharedView; +} + +function isPostViewColor(value: string | undefined): value is PostViewColor { + return POST_VIEW_COLORS.includes(value as PostViewColor); +} + +function ColorPicker({value, onChange}: {value: PostViewColor; onChange: (color: PostViewColor) => void}) { + return ( + + {POST_VIEW_COLORS.map(color => ( + + )} + + + + ); +} + +/** + * The save/edit-view affordance. Whether it shows at all is decided by + * `canSavePostView` — admins only, posts only, not on a default view, and at + * least one of the five view params set. + * + * Rendered in the filter bar beside Clear when there are filters, and in the + * page header when there are not. Both placements exist because that last rule + * includes `order`: a sort on its own makes the view saveable, and the bar only + * appears when there are chips to put in it. + */ +export function ManagePostViewPopover({resource, params, activeView}: ManagePostViewPopoverProps) { + const [open, setOpen] = useState(false); + + return ( + + + {/* Labelled in words. No `aria-label`: it would override the + visible text as the accessible name, leaving the two out of + step. */} + + + + {/* Keyed so reopening starts from the current view's name. */} + { + setOpen(false); + }} + /> + + + ); +} diff --git a/apps/admin/src/posts/list/components/modals/add-tag-modal.tsx b/apps/admin/src/posts/list/components/modals/add-tag-modal.tsx new file mode 100644 index 00000000000..7f98e4063b5 --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/add-tag-modal.tsx @@ -0,0 +1,99 @@ +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@tryghost/shade/components'; +import {TagPicker} from '@/posts/list/components/modals/tag-picker'; +import {tagKey, type TagToAdd} from '@/posts/list/components/modals/tag-selection'; +import {useBrowseTags} from '@tryghost/admin-x-framework/api/tags'; +import {useMemo, useState} from 'react'; + +export type {TagToAdd}; + +interface AddTagModalProps { + isRunning: boolean; + onConfirm: (tags: TagToAdd[]) => void; + onCancel: () => void; +} + +/** + * Bulk "Add a tag", ported from + * `apps/ember-admin/app/components/posts-list/modals/add-tag.hbs`. + * + * Ember allows creating a tag inline (`@allowCreation={{true}}`) and refuses to + * submit with none selected. Both are kept: a tag typed but not matching any + * existing one is offered as a new one, and the server creates it. + * + * Tags already on the selected posts are deliberately **not** shown. An earlier + * version listed them ticked and disabled, which read as a set you could edit + * while offering no way to untick one — this action can only add. Ember shows + * only what you are adding, and so does this. + */ +export function AddTagModal({isRunning, onConfirm, onCancel}: AddTagModalProps) { + const [selected, setSelected] = useState([]); + const [search, setSearch] = useState(''); + + const {data: tagsData} = useBrowseTags({searchParams: {limit: '100', order: 'name asc'}, filter: {}}); + const tags = useMemo(() => tagsData?.tags ?? [], [tagsData]); + + // Keyed on the id, not the name: two tags can share a name and differ only + // by slug, and comparing names ticked and unticked both at once. + const toggle = (tag: TagToAdd) => { + const key = tagKey(tag); + + setSelected(current => (current.some(item => tagKey(item) === key) + ? current.filter(item => tagKey(item) !== key) + : [...current, tag])); + }; + + return ( + { + if (!open) { + onCancel(); + } + }} + > + { + if (selected.length > 0 || search.length > 0) { + event.preventDefault(); + } + }} + > + + Add tags + + Added to everything selected, on top of any tags already applied. + + + + + + + + + + + + ); +} diff --git a/apps/admin/src/posts/list/components/modals/change-access-modal.tsx b/apps/admin/src/posts/list/components/modals/change-access-modal.tsx new file mode 100644 index 00000000000..bd7d46deed9 --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/change-access-modal.tsx @@ -0,0 +1,148 @@ +import { + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@tryghost/shade/components'; +import {getAccessModalTitle} from '@/posts/list/post-bulk-modal-copy'; +import {getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; +import {Inline, Stack} from '@tryghost/shade/primitives'; +import {useBrowseTiers} from '@tryghost/admin-x-framework/api/tiers'; +import {useState} from 'react'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * Ported from `gh-psm-visibility-input.js`. "Specific tier(s)" is appended to + * the fixed three, and picking it reveals the tier picker below. + */ +const VISIBILITIES = [ + {value: 'public', label: 'Public'}, + {value: 'members', label: 'Members only'}, + {value: 'paid', label: 'Paid-members only'}, + {value: 'tiers', label: 'Specific tier(s)'} +]; + +interface ChangeAccessModalProps { + resource: PostResource; + count: number; + isSingle: boolean; + /** The single post's current access, when exactly one is selected. */ + currentVisibility?: string; + /** The single post's current tiers, so the picker opens on them. */ + currentTiers?: {id: string}[]; + isRunning: boolean; + onConfirm: (access: {visibility: string; tiers: {id: string}[]}) => void; + onCancel: () => void; +} + +/** + * Bulk "Change access", ported from + * `apps/ember-admin/app/components/posts-list/modals/edit-posts-access.hbs`. + * + * With one post selected it opens on that post's current access; with several + * it opens on the site default, because there is no single current value to + * show. Ember does the same, via a throwaway post model it uses only to borrow + * the validations. + */ +export function ChangeAccessModal({ + resource, count, isSingle, currentVisibility, currentTiers, isRunning, onConfirm, onCancel +}: ChangeAccessModalProps) { + const {data: settingsData} = useBrowseSettings(); + const defaultVisibility = getSettingValue(settingsData?.settings, 'default_content_visibility') ?? 'public'; + + const [visibility, setVisibility] = useState( + isSingle && currentVisibility ? currentVisibility : defaultVisibility + ); + // Ember seeds these from the post as well as the visibility, so opening the + // modal on a tiers-gated post shows the tiers it already has rather than an + // empty list with a permanently disabled Save. + const [tierIds, setTierIds] = useState( + isSingle && currentTiers ? currentTiers.map(tier => tier.id) : [] + ); + + const {data: tiersData} = useBrowseTiers(); + const paidTiers = (tiersData?.tiers ?? []).filter(tier => tier.type === 'paid' && tier.active); + + // Ember refuses to save "specific tiers" with none chosen. + const canSave = visibility !== 'tiers' || tierIds.length > 0; + + return ( + { + if (!open) { + onCancel(); + } + }} + > + + + {getAccessModalTitle({count, resource, isSingle})} + + Controls who can read the selected {(resource === 'pages' ? 'page' : 'post') + (isSingle ? '' : 's')}. + + + + + + + {visibility === 'tiers' && ( + + {paidTiers.map(tier => ( + + { + setTierIds(current => (checked + ? [...current, tier.id] + : current.filter(id => id !== tier.id))); + }} + /> + + + ))} + + )} + + + + + + + + + ); +} diff --git a/apps/admin/src/posts/list/components/modals/confirm-bulk-action-modal.tsx b/apps/admin/src/posts/list/components/modals/confirm-bulk-action-modal.tsx new file mode 100644 index 00000000000..ca9766f9070 --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/confirm-bulk-action-modal.tsx @@ -0,0 +1,73 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle +} from '@tryghost/shade/components'; +import {getBulkConfirmCopy, type BulkConfirmKey} from '@/posts/list/post-bulk-modal-copy'; +import type {PostResource} from '@/posts/list/post-resource'; + +interface ConfirmBulkActionModalProps { + action: BulkConfirmKey; + resource: PostResource; + /** The selection count — after Cmd+A this is the server total. */ + count: number; + /** Only used when exactly one post is selected. */ + title?: string; + /** Ember's `isSingle` — one id selected and not inverted. */ + isSingle?: boolean; + isRunning: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +/** + * The confirmation shared by Delete, Unpublish and Unschedule. All three have + * the same shape in Ember and differ only in wording, which lives in + * `post-bulk-modal-copy.ts`. + * + * All three are destructive in the sense that matters here: they cannot be + * undone from the list. + */ +export function ConfirmBulkActionModal({ + action, resource, count, title, isSingle, isRunning, onConfirm, onCancel +}: ConfirmBulkActionModalProps) { + const copy = getBulkConfirmCopy(action, {count, resource, title, isSingle}); + + return ( + { + if (!open) { + onCancel(); + } + }} + > + + + {copy.title} + {copy.body} + + + Cancel + { + event.preventDefault(); + onConfirm(); + }} + > + {isRunning ? copy.runningLabel : copy.confirmLabel} + + + + + ); +} diff --git a/apps/admin/src/posts/list/components/modals/tag-picker.tsx b/apps/admin/src/posts/list/components/modals/tag-picker.tsx new file mode 100644 index 00000000000..14c3eaa90e7 --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/tag-picker.tsx @@ -0,0 +1,284 @@ +import {Badge} from '@tryghost/shade/components'; +import {cn, LucideIcon} from '@tryghost/shade/utils'; +import {useEffect, useRef, useState} from 'react'; +import type {KeyboardEvent} from 'react'; +import {tagKey, type TagToAdd} from '@/posts/list/components/modals/tag-selection'; +import type {Tag} from '@tryghost/admin-x-framework/api/tags'; + +interface TagPickerProps { + /** Every tag on the site, already ordered for display. */ + tags: Tag[]; + selected: TagToAdd[]; + onToggle: (tag: TagToAdd) => void; + /** + * Reports whether anything has been typed, so the dialog can refuse to + * close on Escape while there is work to lose. The chips it already knows + * about; the search term lives in here. + */ + onSearchChange?: (search: string) => void; +} + +/** + * A tag is internal when its visibility says so. A name the user has typed + * counts too: Ghost's own rule is that a leading `#` makes a tag internal, and + * the server applies it on create — so the chip should say so before the save + * rather than changing appearance afterwards. + */ +function isInternalTag(tag: {name: string; visibility?: string}): boolean { + return tag.visibility === 'internal' || tag.name.startsWith('#'); +} + +/** + * Internal tags read as a solid dark chip and public ones as an outline, as + * Ember styles them. Adding tags in bulk is exactly where the difference + * matters: an internal tag changes nothing a reader sees, and mistaking one for + * a public one means quietly publishing a label you meant to keep private. + */ +function tagBadgeVariant(tag: {name: string; visibility?: string}) { + return isInternalTag(tag) ? 'default' as const : 'secondary' as const; +} + +/** A row in the list: an existing tag, or the offer to create what was typed. */ +type PickerOption = + | {kind: 'tag'; tag: Tag} + | {kind: 'create'; name: string}; + +/** + * The chips-in-a-field tag picker, modelled on the members label picker but + * add-only: tags already on the posts are not shown and nothing here edits or + * deletes a tag, because this dialog can only add. + * + * The list is built by hand rather than with `cmdk`, which the members picker + * uses. cmdk only drives the keyboard for an input inside its own tree, and + * this input sits in the chip field above the list — so arrow keys did nothing. + * Owning the list also means the highlight covers the "Create" row, which is + * where you want to be after typing a name that does not exist yet. + */ +export function TagPicker({tags, selected, onToggle, onSearchChange}: TagPickerProps) { + const [open, setOpen] = useState(false); + const [search, setSearch] = useState(''); + const [highlighted, setHighlighted] = useState(0); + const inputRef = useRef(null); + const containerRef = useRef(null); + const listRef = useRef(null); + + // Dismissed by a plain document listener rather than a Radix Popover. Two + // reasons: a portalled popover would sit outside the Dialog's subtree, + // where its scroll-lock blocks interaction; and closing on `pointerdown` + // without preventing the default means the `click` that follows still lands + // on whatever is underneath. The list covers the dialog's own footer, so + // that is what lets a single click on Add both close the list and press it. + useEffect(() => { + if (!open) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpen(false); + } + }; + + document.addEventListener('pointerdown', handlePointerDown); + + return () => document.removeEventListener('pointerdown', handlePointerDown); + }, [open]); + + // Escape closes the list. Bound to the document rather than the input + // because it must not depend on where focus happens to be — clicking a row + // with the mouse moves focus off the input, and an Escape after that never + // reached a handler bound there. + // + // Whether the *dialog* also closes is not decided here: the modal answers + // that through Radix's own `onEscapeKeyDown`, which is the supported way and + // avoids racing two capture-phase listeners on the same node. + useEffect(() => { + if (!open) { + return; + } + + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (event.key === 'Escape') { + setOpen(false); + } + }; + + document.addEventListener('keydown', handleKeyDown, true); + + return () => document.removeEventListener('keydown', handleKeyDown, true); + }, [open]); + + // Back to the top whenever the list narrows, so the highlight never points + // past the end of what is on screen. + useEffect(() => { + setHighlighted(0); + }, [search]); + + // Keeps the highlighted row in view while arrowing through a long list. + useEffect(() => { + listRef.current?.querySelector('[data-highlighted="true"]')?.scrollIntoView({block: 'nearest'}); + }, [highlighted, open]); + + const term = search.trim(); + const selectedKeys = new Set(selected.map(tagKey)); + const matches = term + ? tags.filter(tag => tag.name.toLowerCase().includes(term.toLowerCase())) + : tags; + + // Offered only when nothing existing carries that name — otherwise + // "Create" would make a duplicate of something one row below it. + const canCreate = term.length > 0 + && !tags.some(tag => tag.name.toLowerCase() === term.toLowerCase()); + + const options: PickerOption[] = [ + ...matches.map(tag => ({kind: 'tag' as const, tag})), + ...(canCreate ? [{kind: 'create' as const, name: term}] : []) + ]; + + const updateSearch = (value: string) => { + setSearch(value); + onSearchChange?.(value); + }; + + const choose = (option: PickerOption) => { + onToggle(option.kind === 'tag' + ? {id: option.tag.id, name: option.tag.name, slug: option.tag.slug} + : {name: option.name}); + // Cleared either way: leaving the term in the field means the next + // thing typed appends to a search already acted on. + updateSearch(''); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + // Backspace on an empty field removes the last chip, as the members + // picker does — the chips are otherwise only removable by mouse. + if (event.key === 'Backspace' && !search && selected.length > 0) { + onToggle(selected[selected.length - 1]); + return; + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault(); + + if (!open) { + setOpen(true); + return; + } + + if (options.length > 0) { + const step = event.key === 'ArrowDown' ? 1 : -1; + + setHighlighted(current => (current + step + options.length) % options.length); + } + + return; + } + + if (event.key === 'Enter' && open && options[highlighted]) { + event.preventDefault(); + choose(options[highlighted]); + + return; + } + + }; + + return ( +
+
{ + inputRef.current?.focus(); + setOpen(true); + }} + > + {selected.map(tag => ( + { + event.stopPropagation(); + onToggle(tag); + }} + > + {tag.name} + + + ))} + { + updateSearch(event.target.value); + setOpen(true); + }} + onKeyDown={handleKeyDown} + /> + {/* Says the field opens a list. Without it, a bordered box with + a placeholder reads as a plain text input. */} + +
+ {open && ( +
+ {options.length === 0 && ( +
No tags found
+ )} + {options.map((option, index) => { + const isHighlighted = index === highlighted; + const isChosen = option.kind === 'tag' && selectedKeys.has(option.tag.id); + + return ( +
choose(option)} + // Keeps focus in the input, which clicking a + // plain div would otherwise drop. Two things + // depend on it: you can keep typing after + // picking, and Escape still reaches the handler + // above rather than going straight to Radix. + onMouseDown={event => event.preventDefault()} + onMouseEnter={() => setHighlighted(index)} + > + {option.kind === 'create' ? ( + <> + + Create “{option.name}” + + ) : ( + <> + + {option.tag.name} + + {/* Names are not unique; the slug is + what tells two of them apart. */} + + {option.tag.slug} + + {isChosen && } + + )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/apps/admin/src/posts/list/components/modals/tag-selection.ts b/apps/admin/src/posts/list/components/modals/tag-selection.ts new file mode 100644 index 00000000000..1ce9443eb5d --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/tag-selection.ts @@ -0,0 +1,18 @@ +/** A tag being added. No `id` when it is one the user just typed. */ +export interface TagToAdd { + id?: string; + name: string; + slug?: string; +} + +/** + * What makes two tags the same for selection. + * + * The id, whenever there is one. Names are not unique — a site can carry two + * tags called "broaf" with different slugs — so comparing by name ticked and + * unticked both at once. A tag the user has only typed has no id yet and falls + * back to its name. + */ +export function tagKey(tag: TagToAdd): string { + return tag.id ?? `new:${tag.name.toLowerCase()}`; +} diff --git a/apps/admin/src/posts/list/components/post-celebration-modal.tsx b/apps/admin/src/posts/list/components/post-celebration-modal.tsx new file mode 100644 index 00000000000..a004a064b02 --- /dev/null +++ b/apps/admin/src/posts/list/components/post-celebration-modal.tsx @@ -0,0 +1,52 @@ +import {getCelebrationCopy} from '@/posts/list/post-celebration-copy'; +import PostShareModal from '@/shared/analytics/post-share-modal'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; + +interface PostCelebrationModalProps { + post: PostListItem; + /** 'post' or 'page', as the editor wrote it. */ + type: string; + wasPublished: boolean; + /** Total published posts. Absent until the count request lands. */ + postCount?: number; + siteTitle: string; + onClose: () => void; +} + +/** + * The post-publish celebration, shown when the Ember editor hands one over. + * + * Wraps `PostShareModal`, which takes all of its copy as props — so the wording + * lives in `post-celebration-copy.ts` and this is only assembly. + */ +export function PostCelebrationModal({ + post, type, wasPublished, postCount, siteTitle, onClose +}: PostCelebrationModalProps) { + const copy = getCelebrationCopy({ + wasPublished, + type, + emailOnly: post.email_only === true, + postCount + }); + + return ( + { + if (!open) { + onClose(); + } + }} + /> + ); +} diff --git a/apps/admin/src/posts/list/components/post-list-row.tsx b/apps/admin/src/posts/list/components/post-list-row.tsx new file mode 100644 index 00000000000..5a19aac61df --- /dev/null +++ b/apps/admin/src/posts/list/components/post-list-row.tsx @@ -0,0 +1,296 @@ +import {Button} from '@tryghost/shade/components'; +import {Inline, Stack, Text} from '@tryghost/shade/primitives'; +import {cn, LucideIcon} from '@tryghost/shade/utils'; +import FeatureImagePlaceholder from '@/shared/feature-image-placeholder'; +import {PostsContextMenu} from '@/posts/list/components/posts-context-menu'; +import type {PostContextMenuItem, PostContextMenuKey} from '@/posts/list/post-context-menu-items'; +import { + didPostEmailFail, + getPostDateTooltip, + getPostMetaParts, + getPostStatusDetail, + getPostStatusLabel +} from '@/posts/list/post-row-copy'; +import {hasPostAnalyticsPage, type PostMetricsSettings} from '@/posts/list/post-metrics'; +import {PostMetricsCells} from '@/posts/list/components/post-metrics-cells'; +import {forwardRef, memo, useState} from 'react'; +import type {ComponentPropsWithoutRef, MouseEvent as ReactMouseEvent} from 'react'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; +import type {PostResource} from '@/posts/list/post-resource'; + +interface PostListRowProps extends Omit, 'onClick'> { + post: PostListItem; + resource: PostResource; + timezone?: string; + /** + * Contributors get a link out to the live post instead of the editor, for + * published posts they can no longer edit. + */ + isContributor?: boolean; + /** Owner or Administrator — the roles Ember's `isAdmin` covers. */ + hasAdminAccess?: boolean; + paidMembersEnabled?: boolean; + isSelected?: boolean; + /** Capture-phase, so it beats the row's own link. */ + onSelectMouseDown?: (event: ReactMouseEvent, id: string) => void; + onSelectClick?: (event: ReactMouseEvent) => void; + /** + * The right-click menu is rendered *inside* this component rather than + * wrapped around it. A wrapper's `children` is a fresh React element on + * every parent render, so `memo` on the wrapper can never hold and the + * whole list re-renders on every selection change. Rendering it here puts + * the menu's children inside this memo boundary instead. + * + * These props are all primitives or ref-stable getters for the same reason. + */ + getMenuItems: () => PostContextMenuItem[]; + showGiftLink?: boolean; + menuEnabled?: boolean; + menuOnOpenChange?: (open: boolean) => void; + menuOnAction?: (key: PostContextMenuKey) => void | Promise; + metricsSettings: PostMetricsSettings; + visitorCounts?: Record; + memberCounts?: Record; +} + +/** + * Status colour, following `app/styles/layouts/content.css`. Only three states + * are coloured there — `.draft` pink (985), `.scheduled` green (992), `.error` + * red (1017). Published and sent have **no** rule, so they inherit the muted + * grey of `.gh-content-entry-status` (#99a3ad, ≈ `muted-foreground`). That is + * most rows on a real site, so colouring them would change the whole feel of + * the screen. + * + * Red uses Shade's semantic danger token; pink and green have no semantic + * equivalent (a draft is not a warning, a schedule is not a success) so they + * use the aliases that resolve to the same values Ember's `var(--pink)` and + * `var(--green)` do. + */ +function statusTone(post: PostListItem, isFailed: boolean): string { + if (isFailed) { + return 'text-state-danger'; + } + + switch (post.status) { + case 'draft': + return 'text-pink'; + case 'scheduled': + return 'text-green'; + default: + return 'text-muted-foreground'; + } +} + +/** + * The thumbnail, matched to the analytics dashboard's: a 16/10 landscape + * thumbnail rather than the square this list used to draw, at the same widths + * and corner radius. Ember's own list is 16/10 too, so this lands on both at + * once. + * + * The empty state is analytics' shared placeholder component rather than a + * restyle of it, so the two lists cannot drift apart. + */ +const FEATURE_IMAGE_GEOMETRY = 'aspect-[16/10] w-[80px] shrink-0 rounded-sm lg:w-[100px]'; + +function FeatureImage({post}: {post: PostListItem}) { + if (post.feature_image) { + return ( +
+ ); + } + + // `p-0` because the placeholder's own padding is sized for a larger box; + // here the icon just centres in the thumbnail. + return ; +} + +const PostListRowComponent = forwardRef(function PostListRowComponent({ + post, resource, timezone, isContributor, hasAdminAccess, paidMembersEnabled, + isSelected, onSelectMouseDown, onSelectClick, + getMenuItems, showGiftLink, menuEnabled, menuOnOpenChange, menuOnAction, + metricsSettings, visitorCounts, memberCounts, + // Everything else lands on the
  • : the context menu wraps each row with + // `asChild`, so Radix hands its trigger props and ref straight through. + ...rest +}, ref) { + const [isHovered, setIsHovered] = useState(false); + + const metaParts = getPostMetaParts(post, {timezone}); + const dateTooltip = getPostDateTooltip(post, {timezone}); + const statusLabel = getPostStatusLabel(post, resource); + const statusDetail = getPostStatusDetail(post, {timezone, resource}); + const isFailed = didPostEmailFail(post, resource); + + // Strictly `published`, matching Ember's `isPublished`. An email-only + // `sent` post still opens in the editor for a contributor. + const isPublished = post.status === 'published'; + const editorType = resource === 'pages' ? 'page' : 'post'; + const linksOffsite = Boolean(isContributor && isPublished); + const href = linksOffsite ? post.url : `#/editor/${editorType}/${post.id}`; + + const goesToAnalytics = hasPostAnalyticsPage(post, metricsSettings, resource, Boolean(hasAdminAccess)); + const action = goesToAnalytics + ? {href: `#/posts/analytics/${post.id}`, label: 'Go to Analytics', external: false, Icon: LucideIcon.ChartNoAxesColumn} + : linksOffsite + // "View post" on both resources, as Ember hardcodes it. Only ever + // reached by a contributor, who has no page access anyway. + ? {href: post.url, label: 'View post', external: true, Icon: LucideIcon.ArrowUpRight} + : {href, label: 'Go to Editor', external: false, Icon: LucideIcon.Pen}; + + const row = ( +
  • { + onSelectMouseDown?.(event, post.id); + }} + onMouseEnter={() => { + setIsHovered(true); + }} + onMouseLeave={() => { + setIsHovered(false); + }} + > + {/* `center`, not `start`: Ember centres everything on the right + against the feature image. + + Padding is even on all four sides. It lives on the children + rather than the row because the link and the trailing button + each need to fill the row's full height to stay clickable — + so the row's own box has to stay flush. */} + + + + + + {post.featured && ( + + )} + + {post.title} + + + + {metaParts.length > 0 && ( + // Joined from parts so a missing piece takes its + // separator with it — no dangling " – date". + // + // Truncated like the title above it. Left to wrap, + // a long author-and-tag line runs to three lines on + // a narrow window and drives the row's height, + // which reads as the metrics squashing it. + + {metaParts.join(' - ')} + + )} + + + {statusLabel} + {/* Mounted only while hovered, as Ember does. A CSS + opacity fade would keep it in the DOM, so a screen + reader would read every scheduled row's full + dispatch details aloud, always. */} + {isHovered && statusDetail && {statusDetail}} + + + + + {/* Always visible, as in Ember: `.gh-post-list-cta` is a + bordered white button and `.is-hovered` only changes its + border colour. Revealing it on hover would make it + undiscoverable, and an invisible target on touch. */} + + +
  • + ); + + return ( + {})} + onOpenChange={menuOnOpenChange ?? (() => {})} + > + {row} + + ); +}); + +/** + * Memoised. Selection state and modifier "select mode" both live above the + * list, so without this every cmd-click and every press of the Cmd key + * re-renders every row — and each row carries its own Radix hover cards, which + * is by far the most expensive thing on the screen. + * + * Every prop is either a primitive or memoised upstream; `metricsSettings` in + * particular is built with `useMemo` for this reason. + */ +export const PostListRow = memo(PostListRowComponent); diff --git a/apps/admin/src/posts/list/components/post-metric-tooltip.tsx b/apps/admin/src/posts/list/components/post-metric-tooltip.tsx new file mode 100644 index 00000000000..ffe3a837c79 --- /dev/null +++ b/apps/admin/src/posts/list/components/post-metric-tooltip.tsx @@ -0,0 +1,61 @@ +import {HoverCard, HoverCardContent, HoverCardTrigger} from '@tryghost/shade/components'; +import {Inline, Stack, Text} from '@tryghost/shade/primitives'; +import {formatNumber} from '@tryghost/shade/utils'; +import {POST_METRIC_ICONS} from '@/posts/list/post-metric-icons'; +import type {PostMetricTooltipRow} from '@/posts/list/post-metric-tooltips'; +import type {ReactNode} from 'react'; + +interface PostMetricTooltipProps { + title: string; + rows: PostMetricTooltipRow[]; + children: ReactNode; +} + +/** + * The breakdown Ember reveals on hovering a metric — "Web traffic", "Newsletter + * performance", "New members". Ember positions and flips these by hand; + * Shade's Radix wrapper already does that. + * + * `HoverCard`, not `Tooltip`: Shade's tooltip is the small dark chip + * (`bg-primary` / `text-primary-foreground`), which is both the wrong shape for + * a labelled table and the wrong colour — the semantic text tones inside would + * be dark-on-dark. Ember's is a white elevated card, which is what `HoverCard` + * is. No delay, so it behaves like Ember's CSS-only hover. + */ +export function PostMetricTooltip({title, rows, children}: PostMetricTooltipProps) { + return ( + + {children} + {/* `pointer-events-none`, as Ember's tooltip is. Radix's default + keeps the card interactive, but it portals outside the row — so + moving onto it would fire the row's mouseleave and drop the row + hover, the visible CTA border and the status detail. Nothing in + the card is clickable, so nothing is lost. */} + {/* Above the metric, as Ember's is: its `.above` positioning is the + default and it drops below only when there is no room. Radix + flips on collision by itself, so `side` sets the preference and + the fallback comes free. */} + + + {title} + {rows.map((row) => { + const Icon = POST_METRIC_ICONS[row.icon]; + + return ( + + + + {row.label} + + {/* Mono, as the analytics tables are: the + figures line up on their digits when several + rows sit under each other. */} + {formatNumber(row.value)} + + ); + })} + + + + ); +} diff --git a/apps/admin/src/posts/list/components/post-metrics-cells.tsx b/apps/admin/src/posts/list/components/post-metrics-cells.tsx new file mode 100644 index 00000000000..1a5ec35a339 --- /dev/null +++ b/apps/admin/src/posts/list/components/post-metrics-cells.tsx @@ -0,0 +1,165 @@ +import {Inline, Text} from '@tryghost/shade/primitives'; +import {getPostClickRate, getPostMetricColumns, getPostOpenRate, type PostMetricColumn, type PostMetricKey, type PostMetricsSettings} from '@/posts/list/post-metrics'; +import {POST_METRIC_ICONS} from '@/posts/list/post-metric-icons'; +import {PostMetricTooltip} from '@/posts/list/components/post-metric-tooltip'; +import {cn, formatNumber} from '@tryghost/shade/utils'; +import {getPostMetricTooltip} from '@/posts/list/post-metric-tooltips'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; +import type {PostResource} from '@/posts/list/post-resource'; + +interface PostMetricsCellsProps { + post: PostListItem; + settings: PostMetricsSettings; + resource: PostResource; + /** + * Visitor and member counts, keyed by post uuid and id respectively. These + * arrive from separate batched requests and fill in after the row has + * rendered — as they do in Ember, where the loads are deliberately not + * awaited so a slow analytics service can't hold up the list. + */ + visitorCounts?: Record; + memberCounts?: Record; + paidMembersEnabled?: boolean; + className?: string; +} + +const EMAIL_KEYS: PostMetricKey[] = ['opens', 'clicks', 'sent']; + +function metricValue( + column: PostMetricColumn, + post: PostListItem, + visitorCounts?: Record, + memberCounts?: Record +): string { + switch (column.key) { + case 'visitors': + return formatNumber(visitorCounts?.[post.uuid ?? ''] ?? 0); + case 'opens': + return `${getPostOpenRate(post)}%`; + case 'clicks': + return `${getPostClickRate(post)}%`; + case 'sent': + return formatNumber(post.email?.email_count ?? 0); + case 'members': { + const counts = memberCounts?.[post.id]; + return formatNumber((counts?.free ?? 0) + (counts?.paid ?? 0)); + } + default: + return ''; + } +} + +/** + * The right-hand metric columns. Which appear is decided by `post-metrics.ts`; + * each links into the matching analytics tab, as Ember's do. + * + * The columns are grouped before rendering because the hover panel belongs to + * the *group*, not the column: Ember hangs one "Newsletter performance" tooltip + * off the wrapper around Opens, Clicks and Sent. One trigger per column would + * flash the identical panel closed and open again as the pointer crossed from + * Opens to Clicks. + */ +export function PostMetricsCells({ + post, settings, resource, visitorCounts, memberCounts, paidMembersEnabled, className +}: PostMetricsCellsProps) { + const columns = getPostMetricColumns(post, settings, resource); + const shown = new Set(columns.map(column => column.key)); + const members = memberCounts?.[post.id]; + + if (columns.length === 0) { + return null; + } + + const groups: PostMetricColumn[][] = []; + + columns.forEach((column) => { + const previous = groups[groups.length - 1]; + const joinsPrevious = EMAIL_KEYS.includes(column.key) + && previous !== undefined + && EMAIL_KEYS.includes(previous[0].key); + + if (joinsPrevious) { + previous.push(column); + } else { + groups.push([column]); + } + }); + + return ( + // Hidden below 1200px, as Ember hides `.gh-post-list-metrics-container` + // at the same width: on a narrow window there is no room for both the + // title and four columns, and Ember's answer is to drop the columns + // rather than let them crowd the title. + // + // The literal 1200 rather than a named breakpoint: Shade's nearest is + // `sidebarlg` at 1240px, which exists to describe the sidebar and would + // tie this rule to something it has nothing to do with. + + {groups.map((group) => { + const tooltip = getPostMetricTooltip(group[0].key, post, { + visitors: visitorCounts?.[post.uuid ?? ''], + freeMembers: members?.free, + paidMembers: members?.paid, + paidMembersEnabled, + showOpens: shown.has('opens'), + showClicks: shown.has('clicks') + }); + + return ( + + + {group.map((column) => { + const Icon = POST_METRIC_ICONS[column.key]; + const value = metricValue(column, post, visitorCounts, memberCounts); + + return ( + + + + {/* Small and unbolded, as Ember's + are — its `.gh-post-list-analytics-metric` + is midgrey at normal weight. The + figures are secondary to the + title, not competing with it. */} + {value} + + + ); + })} + + + ); + })} + + ); +} diff --git a/apps/admin/src/posts/list/components/posts-context-menu.tsx b/apps/admin/src/posts/list/components/posts-context-menu.tsx new file mode 100644 index 00000000000..d53dd3e99ac --- /dev/null +++ b/apps/admin/src/posts/list/components/posts-context-menu.tsx @@ -0,0 +1,106 @@ +import {ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger} from '@tryghost/shade/components'; +import {IMPLEMENTED_POST_ACTIONS} from '@/posts/list/hooks/use-post-actions'; +import type {PostContextMenuItem, PostContextMenuKey} from '@/posts/list/post-context-menu-items'; +import {Fragment, type ReactNode} from 'react'; +import {LucideIcon} from '@tryghost/shade/utils'; + +/** + * One icon per action. Kept here rather than on the item itself so + * `post-context-menu-items.ts` stays a plain module with no React in it — it is + * the piece the unit tests lean on hardest. + * + * Star, Tag and Lock match the icons Ember uses for the same three actions. + */ +const POST_MENU_ICONS: Record = { + 'copy-link': LucideIcon.Link, + 'copy-preview': LucideIcon.Link, + 'gift-link': LucideIcon.Gift, + unpublish: LucideIcon.Undo2, + unschedule: LucideIcon.CalendarX, + feature: LucideIcon.Star, + unfeature: LucideIcon.StarOff, + 'add-tag': LucideIcon.Tag, + 'change-access': LucideIcon.Lock, + duplicate: LucideIcon.Copy, + delete: LucideIcon.Trash2 +}; + +interface PostsContextMenuProps { + children: ReactNode; + /** + * Read when the menu opens rather than passed as a value: the items change + * on every selection change, and a changing prop would defeat the row's + * memo, which is the whole point of rendering the menu inside it. + */ + getItems: () => PostContextMenuItem[]; + /** Only this row may offer a gift link, which is a single-post action. */ + showGiftLink: boolean; + /** + * Off for authors and contributors. Ember bails before intercepting the + * event, letting the browser's own menu through; anything else would swap a + * working native menu for an empty box. + */ + enabled: boolean; + onOpenChange: (open: boolean) => void; + onAction: (key: PostContextMenuKey) => void | Promise; +} + +/** + * The right-click menu, rendered inside a row. Which items appear is decided + * by `post-context-menu-items.ts` from the whole selection, not from the row + * under the cursor. + */ +export function PostsContextMenu({ + children, getItems, showGiftLink, enabled, onOpenChange, onAction +}: PostsContextMenuProps) { + + // Ember bails before intercepting the event for roles that cannot act, so + // they keep the browser's own menu rather than getting an empty box. + // + // Deliberately *not* also gating on `visible.length === 0`: with nothing + // selected the list is empty until the right-click's transient selection + // lands, so refusing to render the trigger would mean the menu could never + // open at all. Ember has the same ordering — `openContextMenu` selects the + // row first, then opens. + if (!enabled) { + return <>{children}; + } + + const visible = showGiftLink ? getItems() : getItems().filter(item => item.key !== 'gift-link'); + + return ( + + {children} + + {visible.map((item, index) => { + const Icon = POST_MENU_ICONS[item.key]; + + return ( + // Fragment, not a div: a `role="menu"` may only contain + // menuitem, group and separator children. + + {/* The gift-link rule comes from adjacency, not a + `separated` flag: the gift link is filtered out + here per row, so a flag on the item after it + would draw a stray rule when it goes. Never + above the first item. */} + {index > 0 && (item.separated || visible[index - 1].key === 'gift-link') && } + { + void onAction(item.key); + }} + > + + {item.label} + + + ); + })} + + + ); +} + diff --git a/apps/admin/src/posts/list/components/posts-empty-state.tsx b/apps/admin/src/posts/list/components/posts-empty-state.tsx new file mode 100644 index 00000000000..4000fa88ba8 --- /dev/null +++ b/apps/admin/src/posts/list/components/posts-empty-state.tsx @@ -0,0 +1,53 @@ +import {Button, EmptyIndicator} from '@tryghost/shade/components'; +import {LucideIcon} from '@tryghost/shade/utils'; +import {type PostResource, getPostResourceCopy} from '@/posts/list/post-resource'; + +interface PostsEmptyStateProps { + resource: PostResource; + /** + * Whether any filter is active. Sorting deliberately doesn't count — Ember + * excludes `order` from this check, so re-sorting an empty list still + * offers "write your first post" rather than "clear your filters". + */ + hasFilters: boolean; + onClearFilters: () => void; +} + +/** + * The two empty states from `apps/ember-admin/app/templates/posts.hbs`: a cold + * start with a call to action, and a filtered-to-nothing state offering a way + * back. + */ +export function PostsEmptyState({resource, hasFilters, onClearFilters}: PostsEmptyStateProps) { + const copy = getPostResourceCopy(resource); + + if (hasFilters) { + return ( + + Show all {copy.plural} + + } + data-testid='posts-empty-filtered' + title={`No ${copy.plural} match the current filter`} + > + + + ); + } + + return ( + + {copy.emptyAction} + + } + data-testid='posts-empty-cold' + title={copy.emptyTitle} + > + + + ); +} diff --git a/apps/admin/src/posts/list/components/posts-filters.tsx b/apps/admin/src/posts/list/components/posts-filters.tsx new file mode 100644 index 00000000000..42e93f25cc8 --- /dev/null +++ b/apps/admin/src/posts/list/components/posts-filters.tsx @@ -0,0 +1,96 @@ +import {type Filter, Filters} from '@tryghost/shade/patterns'; +import {Button} from '@tryghost/shade/components'; +import {Inline} from '@tryghost/shade/primitives'; +import type {ReactNode} from 'react'; +import {cn, LucideIcon} from '@tryghost/shade/utils'; +import {usePostFilterFields} from '@/posts/list/use-post-filter-fields'; +import type {PostResource} from '@/posts/list/post-resource'; +import type {User} from '@tryghost/admin-x-framework/api/users'; + +interface PostsFiltersProps { + resource: PostResource; + filters: Filter[]; + params?: Parameters[2]; + currentUser?: User; + /** + * Renders the trigger for the page header rather than the filter bar: on + * narrow viewports it collapses to its icon, and expands again from `lg`. + * Only has an effect while there are no filters — once there are, the + * component belongs in the filter bar at full size. + */ + iconOnly?: boolean; + /** + * Save/Edit view, pinned to the right of the bar beside Clear. Passed in + * rather than rendered here, because whether it belongs in the bar at all + * depends on state this component does not have. + */ + viewActions?: ReactNode; + onFiltersChange: (filters: Filter[]) => void; +} + +/** + * The filter chips, using the same Shade `Filters` pattern as the Members list. + * + * `allowMultiple` is off: each field maps to one URL param, which can only hold + * one value, so a second chip on the same field would be unrepresentable — and + * saved views compare those params verbatim across both implementations. + */ +export function PostsFilters({resource, filters, params, currentUser, iconOnly = false, viewActions, onFiltersChange}: PostsFiltersProps) { + const fields = usePostFilterFields(resource, currentUser, params); + const hasFilters = filters.length > 0; + const showIconOnlyTrigger = iconOnly && !hasFilters; + + // Outlined and pinned right: inline it read as another chip's own X, and + // in normal flow wrapping chips would drag it down off the first row. + const trailingActions = hasFilters ? ( + + + {viewActions} + + ) : undefined; + + return ( + // Testid on the wrapper — `Filters` doesn't forward arbitrary props. + + : } + addButtonText={hasFilters ? 'Add filter' : 'Filter'} + // Each field maps to one URL param holding one value; a second + // chip per field would sit there without being in the URL. + allowMultiple={false} + // `order-last` keeps the trailing buttons after the chips; + // `pr-40` reserves the lane the pinned actions occupy. + className={cn( + '[&>button]:order-last', + iconOnly ? 'w-auto' : 'w-full', + hasFilters && 'sm:!pr-40' + )} + clearButton={trailingActions} + fields={fields} + filters={filters} + keyboardShortcut='f' + popoverAlign='start' + showClearButton={hasFilters} + // Hides only the four-item *field* list's search; the tag and + // author value pickers keep their own. + showSearchInput={false} + onChange={onFiltersChange} + /> + + ); +} diff --git a/apps/admin/src/posts/list/components/posts-sort-menu.tsx b/apps/admin/src/posts/list/components/posts-sort-menu.tsx new file mode 100644 index 00000000000..558719befb4 --- /dev/null +++ b/apps/admin/src/posts/list/components/posts-sort-menu.tsx @@ -0,0 +1,54 @@ +import {Button, DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger} from '@tryghost/shade/components'; +import {DEFAULT_ORDER_LABEL, ORDER_OPTIONS, getOrderLabel} from '@/posts/list/post-filter-fields'; +import {LucideIcon} from '@tryghost/shade/utils'; + +interface PostsSortMenuProps { + order: string | null; + onOrderChange: (order: string | null) => void; +} + +/** + * The sort control, separate from the filter chips. + * + * A sort has no operator, so "Sort is Newest first" would be a nonsense chip — + * and `order` also feeds each status bucket's default ordering, which is data + * plumbing rather than filtering. Ember shows it as its own dropdown too. + * + * "Newest first" is the *absence* of an `order` param, not a value. + */ +export function PostsSortMenu({order, onOrderChange}: PostsSortMenuProps) { + return ( + + + {/* + The label names the control *and* its value: a bare + aria-label of "Sort" would override the button text, so + assistive tech would never hear which sort is active. + */} + + + + {/* Radio items so the active sort is announced, and visible. */} + { + onOrderChange(value || null); + }} + > + + {DEFAULT_ORDER_LABEL} + + {ORDER_OPTIONS.map(option => ( + + {option.label} + + ))} + + + + ); +} diff --git a/apps/admin/src/posts/list/compose-post-buckets.test.ts b/apps/admin/src/posts/list/compose-post-buckets.test.ts new file mode 100644 index 00000000000..da0681532a3 --- /dev/null +++ b/apps/admin/src/posts/list/compose-post-buckets.test.ts @@ -0,0 +1,210 @@ +import {describe, expect, it} from 'vitest'; +import {composePostBuckets, type PostBucketResult} from './compose-post-buckets'; + +interface Item { + id: string; +} + +function bucketResult(overrides: Partial> = {}): PostBucketResult { + return { + bucket: 'scheduled', + items: [], + total: 0, + hasNextPage: false, + isLoading: false, + isFetchingNextPage: false, + isError: false, + fetchNextPage: () => {}, + ...overrides + }; +} + +const item = (id: string): Item => ({id}); + +describe('composePostBuckets', () => { + it('is empty with no buckets', () => { + const result = composePostBuckets([]); + + expect(result.items).toEqual([]); + expect(result.totalItems).toBe(0); + expect(result.hasNextPage).toBe(false); + expect(result.isLoading).toBe(false); + }); + + it('reports loading while any bucket is still on its first page', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', isLoading: true}), + bucketResult({bucket: 'draft', isLoading: true}) + ]); + + expect(result.isLoading).toBe(true); + expect(result.items).toEqual([]); + }); + + // Ember's route returns RSVP.hash of all three models, so its template + // doesn't render until every first page has landed - it shows a skeleton + // instead. Matching that matters most in the common case below. + it('keeps loading when an earlier bucket has answered but a later one has not', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', items: [item('s1')], total: 1}), + bucketResult({bucket: 'draft', isLoading: true}) + ]); + + expect(result.isLoading).toBe(true); + expect(result.items).toEqual([]); + }); + + // The regression this guards, and why it matters: almost every site has + // zero scheduled posts, so that bucket answers first and instantly. If the + // composer called the list settled at that point, the screen would render + // an empty list - and, once Phase 2 lands the real empty state, flash + // "Start creating content" on nearly every page load. + it('does not claim to be complete while a bucket is still in flight', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', items: [], total: 0}), + bucketResult({bucket: 'draft', isLoading: true}), + bucketResult({bucket: 'publishedAndSent', isLoading: true}) + ]); + + expect(result.isLoading).toBe(true); + expect(result.items).toEqual([]); + expect(result.hasNextPage).toBe(false); + }); + + it('hides a later bucket while an earlier one is still loading', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', isLoading: true}), + bucketResult({bucket: 'draft', items: [item('d1')], total: 1}) + ]); + + expect(result.items).toEqual([]); + expect(result.isLoading).toBe(true); + }); + + it('concatenates buckets in order once everything has loaded', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', items: [item('s1')], total: 1}), + bucketResult({bucket: 'draft', items: [item('d1')], total: 1}), + bucketResult({bucket: 'publishedAndSent', items: [item('p1')], total: 1}) + ]); + + expect(result.items.map(entry => entry.id)).toEqual(['s1', 'd1', 'p1']); + expect(result.totalItems).toBe(3); + expect(result.isLoading).toBe(false); + }); + + // Ember drains each bucket fully before the next one renders. + it('hides later buckets until the earlier ones are exhausted', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', items: [item('s1')], total: 40, hasNextPage: true}), + bucketResult({bucket: 'draft', items: [item('d1')], total: 5}), + bucketResult({bucket: 'publishedAndSent', items: [item('p1')], total: 5}) + ]); + + expect(result.items.map(entry => entry.id)).toEqual(['s1']); + }); + + it('opens the next bucket once the one before it is exhausted', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', items: [item('s1')], total: 1}), + bucketResult({bucket: 'draft', items: [item('d1')], total: 40, hasNextPage: true}), + bucketResult({bucket: 'publishedAndSent', items: [item('p1')], total: 5}) + ]); + + expect(result.items.map(entry => entry.id)).toEqual(['s1', 'd1']); + }); + + // The common case: most sites have nothing scheduled, so that bucket + // reports "no more pages" on its first response and drafts show at once. + it('skips straight past an empty bucket', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', items: [], total: 0}), + bucketResult({bucket: 'draft', items: [item('d1')], total: 1}), + bucketResult({bucket: 'publishedAndSent', items: [item('p1')], total: 1}) + ]); + + expect(result.items.map(entry => entry.id)).toEqual(['d1', 'p1']); + }); + + it('counts every bucket, including ones not yet visible', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', items: [item('s1')], total: 40, hasNextPage: true}), + bucketResult({bucket: 'draft', items: [], total: 12}), + bucketResult({bucket: 'publishedAndSent', items: [], total: 100}) + ]); + + expect(result.totalItems).toBe(152); + }); + + describe('paging', () => { + it('has a next page while any bucket does', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', total: 1}), + bucketResult({bucket: 'draft', total: 40, hasNextPage: true}) + ]); + + expect(result.hasNextPage).toBe(true); + }); + + it('pages the earliest bucket that still has more', () => { + let scheduledFetched = 0; + let draftFetched = 0; + + const result = composePostBuckets([ + bucketResult({ + bucket: 'scheduled', total: 40, hasNextPage: true, fetchNextPage: () => { + scheduledFetched += 1; + } + }), + bucketResult({ + bucket: 'draft', total: 40, hasNextPage: true, fetchNextPage: () => { + draftFetched += 1; + } + }) + ]); + + result.fetchNextPage(); + + expect(scheduledFetched).toBe(1); + expect(draftFetched).toBe(0); + }); + + it('does nothing when everything is loaded', () => { + const result = composePostBuckets([bucketResult({bucket: 'draft', total: 1})]); + + expect(() => { + result.fetchNextPage(); + }).not.toThrow(); + expect(result.hasNextPage).toBe(false); + }); + + it('reports fetching only for the bucket currently paging', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', total: 40, hasNextPage: true, isFetchingNextPage: true}), + bucketResult({bucket: 'draft', total: 40, hasNextPage: true}) + ]); + + expect(result.isFetchingNextPage).toBe(true); + }); + }); + + it('reports an error if any bucket failed', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled'}), + bucketResult({bucket: 'draft', isError: true}) + ]); + + expect(result.isError).toBe(true); + }); + + // A failed bucket never drained, so treating it as exhausted would let the + // next one silently take its place in the list. + it('does not open a later bucket behind a failed one', () => { + const result = composePostBuckets([ + bucketResult({bucket: 'scheduled', isError: true}), + bucketResult({bucket: 'draft', items: [item('d1')], total: 1}) + ]); + + expect(result.items).toEqual([]); + }); +}); diff --git a/apps/admin/src/posts/list/compose-post-buckets.ts b/apps/admin/src/posts/list/compose-post-buckets.ts new file mode 100644 index 00000000000..4df78d15072 --- /dev/null +++ b/apps/admin/src/posts/list/compose-post-buckets.ts @@ -0,0 +1,99 @@ +import type {PostBucket} from './post-query-params'; + +/** + * Assembles the three per-status queries into the single list the screen + * renders. + * + * Ported from the sequenced infinity loaders in + * `apps/ember-admin/app/templates/posts.hbs`. All three queries run from the + * start; what is sequenced is *rendering*. A bucket only becomes visible once + * every earlier bucket has loaded every page, so the list reads scheduled, + * then drafts, then published/sent - with each bucket internally sorted by its + * own default (drafts by `updated_at`, the rest by `published_at`). + * + * Pure so the ordering rules can be tested without mocking queries. + */ + +export interface PostBucketResult { + bucket: PostBucket; + items: TItem[]; + /** Server-reported total for this bucket, including unloaded pages. */ + total: number; + hasNextPage: boolean; + isLoading: boolean; + isFetchingNextPage: boolean; + isError: boolean; + fetchNextPage: () => void; +} + +export interface ComposedPostList { + items: TItem[]; + totalItems: number; + hasNextPage: boolean; + isFetchingNextPage: boolean; + isLoading: boolean; + isError: boolean; + fetchNextPage: () => void; +} + +const noop = () => {}; + +export function composePostBuckets(results: PostBucketResult[]): ComposedPostList { + const totalItems = results.reduce((total, result) => total + result.total, 0); + const isError = results.some(result => result.isError); + + // Nothing renders until every bucket's first page has landed. This mirrors + // Ember, whose route returns `RSVP.hash` of all three models and shows a + // skeleton until they all resolve (`routes/posts.js:177`, + // `templates/posts-loading.hbs`). + // + // Releasing earlier looks tempting - one slow query then can't hide the + // rest - but it is wrong in a way that bites on nearly every site: most + // have zero scheduled posts, so that bucket answers first and instantly. + // Releasing there renders an empty list, and once the real empty state + // exists it would flash "Start creating content" on almost every page + // load, while also reporting the list complete with two queries still in + // flight. Progressive rendering would need a "settled" signal distinct + // from "complete", not an earlier release here. + if (results.some(result => result.isLoading)) { + return { + items: [], + totalItems, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: true, + isError, + fetchNextPage: noop + }; + } + + // Everything has answered; now apply the sequential-drain rule - a bucket + // only opens once every earlier one has loaded all of its pages. + const visible: PostBucketResult[] = []; + let paging: PostBucketResult | undefined; + + for (const result of results) { + visible.push(result); + + // A bucket that failed never drained, so nothing after it may open - + // otherwise a transient error silently reorders the list. + if (result.isError) { + break; + } + + if (result.hasNextPage) { + paging = result; + break; + } + } + + return { + items: visible.flatMap(result => result.items), + totalItems, + hasNextPage: Boolean(paging), + isFetchingNextPage: paging?.isFetchingNextPage ?? false, + isLoading: false, + isError, + fetchNextPage: paging?.fetchNextPage ?? noop + }; +} diff --git a/apps/admin/src/posts/list/compute-shift-range.test.ts b/apps/admin/src/posts/list/compute-shift-range.test.ts new file mode 100644 index 00000000000..eedbfd44aad --- /dev/null +++ b/apps/admin/src/posts/list/compute-shift-range.test.ts @@ -0,0 +1,90 @@ +import {computeShiftRange} from './compute-shift-range'; +import {describe, expect, it} from 'vitest'; + +/** + * Shift-click selects everything between the last-clicked row and this one. + * + * In Ember this walks the three infinity models in bucket order as if they were + * one flat list. Our composed array already *is* that flat list, so the whole + * thing collapses to a slice between two indexes — which is why it gets its own + * pure function and its own tests rather than living inside the reducer. + * + * Ember's walk is **asymmetric**, and that asymmetry is load-bearing. Going + * forward it hits the anchor first, flips its `running` flag and `continue`s, + * so the anchor is skipped. Going backward it hits the *target* first, and + * there is no `continue` on that branch — so the target is taken, and when the + * walk later reaches the anchor it lands in the `else` and takes the anchor + * too. Forward excludes the anchor; backward includes it. + * + * That looks like an accident, but it is observable: the range is remembered so + * the next shift-click can undo it. Excluding the anchor going backward leaves + * it selected after an undo that should have cleared it. + */ + +const ids = ['a', 'b', 'c', 'd', 'e']; + +describe('computeShiftRange', () => { + it('covers the rows after the anchor, excluding it', () => { + expect(computeShiftRange(ids, 'b', 'd')).toEqual(['c', 'd']); + }); + + it('covers the rows before the anchor, including it', () => { + expect(computeShiftRange(ids, 'd', 'b')).toEqual(['b', 'c', 'd']); + }); + + it('covers a single row when the two are adjacent going forward', () => { + expect(computeShiftRange(ids, 'b', 'c')).toEqual(['c']); + }); + + it('covers both rows when the two are adjacent going backward', () => { + expect(computeShiftRange(ids, 'c', 'b')).toEqual(['b', 'c']); + }); + + /** + * A deliberate divergence, and the only one in this file. + * + * Ember's loop enters the endpoint branch, flips `running` on and + * `continue`s — and then never meets a second endpoint to turn it off + * again. Everything below the clicked row is selected, to the end of every + * bucket. Shift-clicking the row you are already anchored to therefore + * means "select to the end of the list" in Ember, which is plainly not + * what anyone intends and is about to be wired to a bulk delete. + * + * Selecting nothing is the conservative reading of an ambiguous gesture. + */ + it('selects nothing when shift-clicking the anchor itself', () => { + expect(computeShiftRange(ids, 'c', 'c')).toEqual([]); + }); + + it('spans the whole list from one end to the other', () => { + expect(computeShiftRange(ids, 'a', 'e')).toEqual(['b', 'c', 'd', 'e']); + }); + + // The composed array is bucket-ordered — scheduled, then drafts, then + // published — so a range spanning a bucket boundary is just a slice. This + // is the case the Ember implementation has to walk three models for. + it('spans a bucket boundary without noticing there was one', () => { + const buckets = ['sched-1', 'sched-2', 'draft-1', 'draft-2', 'pub-1']; + + expect(computeShiftRange(buckets, 'sched-2', 'pub-1')) + .toEqual(['draft-1', 'draft-2', 'pub-1']); + }); + + it('spans a bucket boundary backwards, taking the anchor with it', () => { + const buckets = ['sched-1', 'sched-2', 'draft-1', 'draft-2', 'pub-1']; + + expect(computeShiftRange(buckets, 'draft-2', 'sched-2')) + .toEqual(['sched-2', 'draft-1', 'draft-2']); + }); + + // A row can leave the list between clicks — a bulk edit prunes it, or a + // filter changes. Returning nothing beats throwing or selecting a + // half-range against a stale index. + it('is empty when the anchor is no longer in the list', () => { + expect(computeShiftRange(ids, 'gone', 'c')).toEqual([]); + }); + + it('is empty when the target is no longer in the list', () => { + expect(computeShiftRange(ids, 'c', 'gone')).toEqual([]); + }); +}); diff --git a/apps/admin/src/posts/list/compute-shift-range.ts b/apps/admin/src/posts/list/compute-shift-range.ts new file mode 100644 index 00000000000..d1dc8b9e240 --- /dev/null +++ b/apps/admin/src/posts/list/compute-shift-range.ts @@ -0,0 +1,43 @@ +/** + * The rows a shift-click adds to the selection: everything between the anchor + * (the last row clicked) and the row just clicked. + * + * Ember's version (`shiftItem` in `posts-list/selection-list.js`) walks the + * scheduled, draft and published models in order, flipping a `running` flag as + * it passes either endpoint. It has to, because it has three arrays. We compose + * the buckets into one ordered array upstream, so the same rule is a slice — + * and a slice can't get the boundary conditions subtly wrong. + * + * Ember's walk is asymmetric, and faithfully so. Going forward it meets the + * anchor first, flips `running` on and `continue`s past it — anchor excluded. + * Going backward it meets the *target* first, where there is no `continue`, so + * the target is taken and the anchor is later taken by the `else` branch — + * anchor included. + * + * That reads like an accident of the loop, but it is observable rather than + * cosmetic: the range is remembered so the next shift-click can undo it, and + * an anchor left out of the group survives an undo that should have cleared it. + */ +export function computeShiftRange(orderedIds: string[], anchorId: string, targetId: string): string[] { + const anchor = orderedIds.indexOf(anchorId); + const target = orderedIds.indexOf(targetId); + + // Either row can have left the list since it was clicked — pruned by a bulk + // edit, or filtered away. A stale index would select an arbitrary range. + if (anchor === -1 || target === -1) { + return []; + } + + if (target === anchor) { + // Ember flips `running` on here and never meets a second endpoint to + // turn it off, selecting every row to the end of every bucket. That is + // a runaway about to be wired to a bulk delete, so this is the one + // place the port deliberately differs: an ambiguous gesture selects + // nothing. + return []; + } + + return target > anchor + ? orderedIds.slice(anchor + 1, target + 1) + : orderedIds.slice(target, anchor + 1); +} diff --git a/apps/admin/src/posts/list/hooks/use-post-actions.ts b/apps/admin/src/posts/list/hooks/use-post-actions.ts new file mode 100644 index 00000000000..6739d11bea5 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-actions.ts @@ -0,0 +1,145 @@ +import {getPostActionMessage} from '@/posts/list/post-action-messages'; +import {useCopyPage} from '@tryghost/admin-x-framework/api/pages'; +import {useCopyPost} from '@tryghost/admin-x-framework/api/posts'; +import {useQueryClient} from '@tanstack/react-query'; +import {getPostPreviewUrl} from '@/posts/list/post-preview-url'; +import {toast} from 'sonner'; +import {useCallback} from 'react'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import type {BulkActionSnapshot} from '@/posts/list/hooks/use-post-bulk-actions'; +import type {PostContextMenuKey} from '@/posts/list/post-context-menu-items'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * The single-post actions from the right-click menu. The bulk actions and their + * modals land in Phase 8; this covers the ones that need no confirmation. + */ + +/** + * The keys the menu can actually carry out. Anything absent renders disabled — + * a menu item that closes the menu and does nothing is worse than one that says + * it isn't ready. + */ +export const IMPLEMENTED_POST_ACTIONS: ReadonlySet = new Set([ + 'copy-link', + 'copy-preview', + 'duplicate', + 'gift-link', + 'delete', + 'unpublish', + 'unschedule', + 'feature', + 'unfeature', + 'add-tag', + 'change-access' +]); + +interface UsePostActionsOptions { + resource: PostResource; + /** The selected posts that are loaded — Ember's `availableModels`. */ + posts: PostListItem[]; + /** The screen owns the modal; the hook just says which post to open it for. */ + onShareAsGift?: (postId: string) => void; + /** + * How many posts the action applies to — the *selection* count, which after + * Cmd+A is the server total rather than the rows in memory. Ember + * interpolates the same number into its toasts. + */ + count: number; + /** Bulk keys are handed upward with the selection captured at this moment. */ + onBulkAction?: (key: PostContextMenuKey, snapshot: BulkActionSnapshot) => void; + /** The NQL filter describing the selection, possibly inverted. */ + selectionFilter: string; + /** The bucket filters currently on screen — see `BulkActionSnapshot`. */ + bucketFilters: string[]; + /** Ember's `isSingle`, captured with the rest of the selection. */ + isSingle: boolean; + /** Whether the selection is inverted (Cmd+A) — see `BulkActionSnapshot`. */ + inverted: boolean; +} + +export function usePostActions({ + resource, posts, onShareAsGift, count, onBulkAction, selectionFilter, bucketFilters, isSingle, inverted +}: UsePostActionsOptions) { + const {data: siteData} = useBrowseSite(); + const siteUrl = siteData?.site.url ?? ''; + + // Both are called unconditionally and picked by resource — hooks can't be + // called behind a branch. + const copyPost = useCopyPost(); + const copyPage = useCopyPage(); + const queryClient = useQueryClient(); + + return useCallback(async (key: PostContextMenuKey) => { + const first = posts[0]; + + if (!first) { + return; + } + + const notify = (message: Parameters[0]) => { + toast.success(getPostActionMessage(message, {count, resource, isSingle})); + }; + + // Ember wraps every one of these in a try/catch and surfaces the error; + // without it a failed copy or a clipboard the browser refuses to write + // to (it rejects when the document isn't focused) is completely silent + // — no toast, no change, nothing to retry. + try { + switch (key) { + case 'copy-link': + await navigator.clipboard.writeText(first.url); + notify('copiedPostUrl'); + break; + case 'copy-preview': + // The preview URL, not `first.url`. Ember copies the latter here, + // which for a draft is a permalink to a page that does not exist + // yet — and is the identical string its "Copy link to post" action + // produces, making the two menu items indistinguishable. + await navigator.clipboard.writeText(getPostPreviewUrl(first, siteUrl)); + notify('copiedPreviewUrl'); + break; + case 'duplicate': { + if (resource === 'pages') { + await copyPage.mutateAsync(first.id); + } else { + await copyPost.mutateAsync(first.id); + } + + // Ember unshifts the copy straight into its draft bucket. We + // refetch instead: a duplicate is always a draft whatever the + // source was, so it belongs in a different bucket from the row it + // came from, and the buckets are separate queries here. One list + // refetch is cheap, and unlike the bulk actions in Phase 8 there is + // no long selection whose scroll position needs preserving. + const dataType = resource === 'pages' ? 'PagesResponseType' : 'PostsResponseType'; + + // Not awaited: `invalidateQueries` settles only once every active + // query has refetched, and nothing here depends on that having + // finished. + void queryClient.invalidateQueries({queryKey: [dataType]}); + + notify('duplicated'); + break; + } + case 'gift-link': + onShareAsGift?.(first.id); + break; + default: + // Everything else is a bulk action. The selection is captured now, + // because the menu is about to close and take a transient selection + // with it. + onBulkAction?.(key, {filter: selectionFilter, posts, count, bucketFilters, isSingle, inverted}); + break; + } + } catch (error) { + toast.error(error instanceof Error && error.message + ? error.message + : `Could not complete that action on this ${resource === 'pages' ? 'page' : 'post'}.`); + } + }, [ + posts, resource, siteUrl, copyPost, copyPage, queryClient, + onShareAsGift, count, onBulkAction, selectionFilter, bucketFilters, isSingle, inverted + ]); +} diff --git a/apps/admin/src/posts/list/hooks/use-post-analytics-counts.ts b/apps/admin/src/posts/list/hooks/use-post-analytics-counts.ts new file mode 100644 index 00000000000..3a18af6ed48 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-analytics-counts.ts @@ -0,0 +1,41 @@ +import {usePostMemberCounts, usePostVisitorCounts} from '@tryghost/admin-x-framework/api/stats'; +import {useMemo} from 'react'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; + +/** + * Visitor and member counts for the rows currently on screen. + * + * Batched into one request each, and only for published posts — the columns + * they feed are hidden otherwise. Both are gated on the matching site setting, + * so a site without web analytics never asks for visitor counts. + * + * Deliberately not blocking: the list renders with zeroes and the numbers fill + * in, as they do in Ember, where the loads are explicitly not awaited so a slow + * analytics service can't hold up the screen. + */ +export interface UsePostAnalyticsCountsOptions { + items: PostListItem[]; + webAnalyticsEnabled: boolean; + membersTrackSources: boolean; +} + +export function usePostAnalyticsCounts({ + items, webAnalyticsEnabled, membersTrackSources +}: UsePostAnalyticsCountsOptions) { + const published = useMemo( + () => items.filter(item => item.status === 'published'), + [items] + ); + + const postUuids = useMemo( + () => published.map(item => item.uuid).filter((uuid): uuid is string => Boolean(uuid)), + [published] + ); + + const postIds = useMemo(() => published.map(item => item.id), [published]); + + const {data: visitorCounts} = usePostVisitorCounts(postUuids, {enabled: webAnalyticsEnabled}); + const {data: memberCounts} = usePostMemberCounts(postIds, {enabled: membersTrackSources}); + + return {visitorCounts, memberCounts}; +} diff --git a/apps/admin/src/posts/list/hooks/use-post-bulk-actions.test.tsx b/apps/admin/src/posts/list/hooks/use-post-bulk-actions.test.tsx new file mode 100644 index 00000000000..369290520aa --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-bulk-actions.test.tsx @@ -0,0 +1,130 @@ +import {QueryClient, QueryClientProvider} from '@tanstack/react-query'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {renderHook} from '@testing-library/react'; +import {usePostBulkActions, type BulkActionSnapshot} from './use-post-bulk-actions'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; +import type {ReactNode} from 'react'; + +vi.mock('@tryghost/admin-x-framework/api/posts', () => ({ + useBulkEditPosts: () => ({mutateAsync: vi.fn().mockResolvedValue({})}), + useBulkDeletePosts: () => ({mutateAsync: vi.fn().mockResolvedValue({})}) +})); + +vi.mock('@tryghost/admin-x-framework/api/pages', () => ({ + useBulkEditPages: () => ({mutateAsync: vi.fn().mockResolvedValue({})}), + useBulkDeletePages: () => ({mutateAsync: vi.fn().mockResolvedValue({})}) +})); + +vi.mock('sonner', () => ({ + toast: {success: vi.fn(), error: vi.fn()} +})); + +/** + * The cache surgery `run()` performs — pruning, the exact-filter predicate, + * and the `meta.pagination.total` arithmetic. The user-visible half lives in + * the acceptance tests; the totals are not observable there, and they feed + * the selection count, so they get pinned here. + */ +describe('usePostBulkActions cache patching', () => { + const PUBLISHED_FILTER = 'status:[published,sent]'; + const DRAFT_FILTER = 'status:draft'; + + const bucketUrl = (filter: string) => `/ghost/api/admin/posts/?filter=${encodeURIComponent(filter)}&order=published_at%20desc&limit=30`; + + const listPost = (id: string, status: string): PostListItem => ({id, status, title: id} as PostListItem); + + const seedBucket = (queryClient: QueryClient, filter: string, posts: PostListItem[], total: number) => { + queryClient.setQueryData(['PostsResponseType', bucketUrl(filter)], { + pageParams: [undefined], + pages: [{posts, meta: {pagination: {total, page: 1, limit: 30, pages: 1, next: null, prev: null}}}] + }); + }; + + const readBucket = (queryClient: QueryClient, filter: string) => { + return queryClient.getQueryData<{ + pages: {posts: PostListItem[]; meta: {pagination: {total: number}}}[]; + }>(['PostsResponseType', bucketUrl(filter)]); + }; + + const snapshot = (overrides: Partial = {}): BulkActionSnapshot => ({ + filter: 'id:[p1]', + posts: [listPost('p1', 'published')], + count: 1, + isSingle: true, + inverted: false, + bucketFilters: [PUBLISHED_FILTER], + ...overrides + }); + + let queryClient: QueryClient; + + const renderBulkActions = () => { + const wrapper = ({children}: {children: ReactNode}) => ( + {children} + ); + + return renderHook(() => usePostBulkActions({resource: 'posts'}), {wrapper}).result; + }; + + beforeEach(() => { + queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}); + }); + + it('removes deleted rows and decrements the total', async () => { + seedBucket(queryClient, PUBLISHED_FILTER, [listPost('p1', 'published'), listPost('p2', 'published')], 40); + const result = renderBulkActions(); + + await result.current.run('delete', snapshot()); + + const bucket = readBucket(queryClient, PUBLISHED_FILTER); + expect(bucket?.pages[0].posts.map(post => post.id)).toEqual(['p2']); + expect(bucket?.pages[0].meta.pagination.total).toBe(39); + }); + + it('sets the total to the surviving exclusions after an inverted delete', async () => { + // Cmd+A minus p2: the server deletes everything matching the filter, + // including rows never loaded — the old total is meaningless. + seedBucket(queryClient, PUBLISHED_FILTER, [listPost('p1', 'published'), listPost('p2', 'published')], 40); + const result = renderBulkActions(); + + await result.current.run('delete', snapshot({ + filter: `(${PUBLISHED_FILTER})+id:-[p2]`, + posts: [listPost('p1', 'published')], + count: 39, + isSingle: false, + inverted: true + })); + + const bucket = readBucket(queryClient, PUBLISHED_FILTER); + expect(bucket?.pages[0].posts.map(post => post.id)).toEqual(['p2']); + expect(bucket?.pages[0].meta.pagination.total).toBe(1); + }); + + it('prunes an edited row from its bucket without touching the total', async () => { + // The post still exists after an unpublish — it only left this bucket. + // Decrementing here would shrink the list-wide count the selection + // reads on the next Cmd+A. + seedBucket(queryClient, PUBLISHED_FILTER, [listPost('p1', 'published'), listPost('p2', 'published')], 40); + const result = renderBulkActions(); + + await result.current.run('unpublish', snapshot()); + + const bucket = readBucket(queryClient, PUBLISHED_FILTER); + expect(bucket?.pages[0].posts.map(post => post.id)).toEqual(['p2']); + expect(bucket?.pages[0].meta.pagination.total).toBe(40); + }); + + it('leaves a list whose filter merely extends the bucket filter alone', async () => { + // `status:draft` is a prefix of `status:draft+featured:true`; substring + // matching patched the featured list too and pruned it against a + // filter that was not its own. + seedBucket(queryClient, DRAFT_FILTER, [listPost('p1', 'draft')], 10); + seedBucket(queryClient, `${DRAFT_FILTER}+featured:true`, [listPost('p1', 'draft')], 5); + const result = renderBulkActions(); + + await result.current.run('delete', snapshot({bucketFilters: [DRAFT_FILTER], posts: [listPost('p1', 'draft')]})); + + expect(readBucket(queryClient, DRAFT_FILTER)?.pages[0].posts).toEqual([]); + expect(readBucket(queryClient, `${DRAFT_FILTER}+featured:true`)?.pages[0].posts.map(post => post.id)).toEqual(['p1']); + }); +}); diff --git a/apps/admin/src/posts/list/hooks/use-post-bulk-actions.ts b/apps/admin/src/posts/list/hooks/use-post-bulk-actions.ts new file mode 100644 index 00000000000..96fbd1ebb79 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-bulk-actions.ts @@ -0,0 +1,412 @@ +import {getPostActionMessage, type PostActionMessageKey} from '@/posts/list/post-action-messages'; +import {pruneNonMatchingPosts} from '@/posts/list/prune-non-matching-posts'; +import {toast} from 'sonner'; +import {useCallback, useState} from 'react'; +import {useBulkDeletePages, useBulkEditPages} from '@tryghost/admin-x-framework/api/pages'; +import {useBulkDeletePosts, useBulkEditPosts} from '@tryghost/admin-x-framework/api/posts'; +import {useQueryClient} from '@tanstack/react-query'; +import type {PostBulkAction} from '@tryghost/admin-x-framework/api/posts'; +import type {PostContextMenuKey} from '@/posts/list/post-context-menu-items'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * The bulk actions reachable from the right-click menu. + * + * Two things make this more than a mutation call. + * + * **The selection is snapshotted when the action starts.** Radix closes the + * menu the moment an item is chosen, which clears a transient selection — so a + * confirmation modal that read the live selection would find it empty by the + * time the user pressed Delete. Ember solves this by freezing the selection + * list for the whole modal lifetime; taking a copy up front is the same + * guarantee with none of the state machine. + * + * **Edited rows are pruned client-side, not refetched.** That is what makes + * unfeaturing a post while viewing `?type=featured` remove it immediately + * without losing scroll position. See `prune-non-matching-posts.ts`. + */ + +export interface BulkActionSnapshot { + /** The NQL filter describing the selection — possibly inverted. */ + filter: string; + /** The selected posts that are loaded, for pruning and for the modal title. */ + posts: PostListItem[]; + /** The selection count, which after Cmd+A is the server total. */ + count: number; + /** + * Ember's `isSingle` — one id selected *and not inverted*. Captured here + * rather than read at modal-render time, because by then the transient + * selection is already gone. Deriving it from `count === 1` instead is + * actively dangerous: `getPostSelectionCount` floors at 1, so an inverted + * selection over a stale total would name a single post in the modal while + * the request carried a filter matching the whole site. + */ + isSingle: boolean; + /** + * Whether the selection is inverted (Cmd+A). An inverted delete covers + * rows that were never loaded, so the cached totals can't be decremented + * by counting removed rows — what remains is the exclusions. + */ + inverted: boolean; + /** + * The bucket filters the screen is currently showing. Ember iterates only + * the three infinity models on screen; `setQueriesData` would otherwise + * reach every cached list for the resource — including a Featured list + * cached from an earlier visit — and prune it against the *current* + * screen's filter, which is not its own. + */ + bucketFilters: string[]; +} + +/** + * Maps a menu item to the server's bulk action and its toast. + * + * Feature and unfeature have no message: Ember shows no notification for + * either, because the star appearing or disappearing on the row is the + * feedback. Both are also unconfirmed — they apply straight away. + */ +const BULK_ACTIONS: Partial> = { + unpublish: {action: 'unpublish', message: 'unpublished'}, + unschedule: {action: 'unschedule', message: 'unscheduled'}, + feature: {action: 'feature'}, + unfeature: {action: 'unfeature'} +}; + +/** + * How each action changes a row locally, so the pruner decides against the + * post's *new* state. Ember pushes the same fields into its store for the same + * reason — without it, unpublishing while viewing `?type=published` would leave + * the rows in place until a refetch. + */ +function applyLocalEdit(post: PostListItem, key: PostContextMenuKey): PostListItem { + switch (key) { + case 'unpublish': + return post.status === 'published' ? {...post, status: 'draft'} : post; + case 'unschedule': + return post.status === 'scheduled' + ? {...post, status: 'draft', published_at: undefined} + : post; + case 'feature': + return {...post, featured: true}; + case 'unfeature': + return {...post, featured: false}; + default: + return post; + } +} + +/** + * The decoded `filter` param of a cached query's key. Keys are + * `[dataType, url]` — see `createInfiniteQuery`. + */ +function getQueryKeyFilter(queryKey: readonly unknown[]): string | null { + const url = queryKey[1]; + + if (typeof url !== 'string') { + return null; + } + + try { + return new URL(url, window.location.origin).searchParams.get('filter'); + } catch { + // Matching nothing is safer than patching a list this action is not + // about. + return null; + } +} + +interface UsePostBulkActionsOptions { + resource: PostResource; + /** + * Called after a delete. Ember force-clears the whole selection here, + * because every selected row is gone. + */ + onDeleted?: () => void; + /** + * Called after an edit, with the ids still present in the list. Ember calls + * `clearUnavailableItems` — it keeps the selection on the rows that are + * still visible, so a second action can follow the first. + */ + onEdited?: (remainingIds: Set) => void; +} + +export function usePostBulkActions({resource, onDeleted, onEdited}: UsePostBulkActionsOptions) { + const [isRunning, setIsRunning] = useState(false); + const queryClient = useQueryClient(); + + /* + * Invalidation goes through TanStack's `invalidateQueries` directly, never + * the framework's `onInvalidate`: `PostsResponseType` has no entry in the + * bridge's `emberDataTypeMapping`, so that call throws outright — and the + * mapping it would need resolves to `store.unloadAll('post')`, which would + * drop the record out from under an editor the user may have open. + */ + + // Called unconditionally and picked by resource — hooks can't be branched. + const bulkEditPosts = useBulkEditPosts(); + const bulkEditPages = useBulkEditPages(); + const bulkDeletePosts = useBulkDeletePosts(); + const bulkDeletePages = useBulkDeletePages(); + + const isPages = resource === 'pages'; + const dataType = isPages ? 'PagesResponseType' : 'PostsResponseType'; + + /** + * Removes the given ids from every cached page of the on-screen bucket + * lists, and prunes any edited row that no longer matches its own bucket's + * filter. Patching rather than refetching is what preserves scroll + * position on a long list. + */ + const patchCaches = useCallback(( + snapshot: BulkActionSnapshot, + action: PostContextMenuKey + ): Set => { + const editedIds = new Set(snapshot.posts.map(post => post.id)); + const isDelete = action === 'delete'; + const remaining = new Set(); + + for (const bucketFilter of snapshot.bucketFilters) { + queryClient.setQueriesData<{ + pageParams?: unknown[]; + pages?: { + posts?: PostListItem[]; + pages?: PostListItem[]; + meta?: {pagination: {total?: number}}; + }[]; + }>( + { + queryKey: [dataType], + // Exact match on the key's decoded `filter` param. A + // substring test would also patch lists this filter merely + // prefixes (`status:draft` vs `status:draft+featured:true`) + // and prune them against a filter that is not their own. + predicate: query => getQueryKeyFilter(query.queryKey) === bucketFilter + }, + (cached) => { + // `pageParams` rather than `pages`: a non-infinite *pages* + // response is literally `{pages: Page[]}`, so testing for + // `pages` would treat each Page as an infinite page and + // rewrite the record to nothing. Only InfiniteData has + // `pageParams`. + if (!Array.isArray(cached?.pageParams) || !cached.pages) { + return cached; + } + + let removed = 0; + let keptCount = 0; + const pages = cached.pages.map((page) => { + const key = isPages ? 'pages' : 'posts'; + const rows = page[key] ?? []; + let kept: PostListItem[]; + + if (isDelete) { + kept = rows.filter(row => !editedIds.has(row.id)); + } else { + // The edit is applied to the cached rows *first*, + // so the pruner sees the post as it now is. + // Applying it to the snapshot's copies instead + // would leave the cache holding the old state and + // prune nothing. + const edited = rows.map(row => ( + editedIds.has(row.id) ? applyLocalEdit(row, action) : row + )); + + // Pruned against the bucket's own filter, not the + // list-wide one: on the unfiltered list an + // unpublished row still matches the every-status + // filter but must leave the published bucket. + kept = pruneNonMatchingPosts({ + posts: edited, + editedIds, + filter: bucketFilter + }); + + kept.forEach((row) => { + if (editedIds.has(row.id)) { + remaining.add(row.id); + } + }); + } + + removed += rows.length - kept.length; + keptCount += kept.length; + + return {...page, [key]: kept}; + }); + + // Totals only move on delete — an edited row that left this + // bucket still exists, so subtracting it would shrink the + // list-wide count the selection reads. Inverted deletes + // covered rows never loaded, so what's left in the bucket + // is exactly the exclusions still cached. + if (!isDelete || (removed === 0 && !snapshot.inverted)) { + return {...cached, pages}; + } + + // The hooks' `returnData` reads `meta` off the last page; + // keep every page consistent so the total tracks the rows. + return { + ...cached, + pages: pages.map(page => (typeof page.meta?.pagination?.total === 'number' ? { + ...page, + meta: { + ...page.meta, + pagination: { + ...page.meta.pagination, + total: snapshot.inverted + ? keptCount + : Math.max(0, page.meta.pagination.total - removed) + } + } + } : page)) + }; + } + ); + } + + return remaining; + }, [queryClient, dataType, isPages]); + + /** + * The two actions that carry a payload. Both refetch rather than prune. + * + * Ember prunes here too, but only after re-fetching every edited post in + * batches of 50 — the new tag or visibility has to be in the store before + * the filter can be evaluated against it. We cannot shortcut that with a + * local edit: a *newly created* tag's slug is decided server-side, so + * pruning against `tag:` locally would be guesswork. Refetching the + * list is the same end state for one request instead of N, and costs only + * the scroll position. + */ + const runWithPayload = useCallback(async ( + key: 'add-tag' | 'change-access', + snapshot: BulkActionSnapshot, + meta: {tags: {id?: string; name: string; slug?: string}[]} + | {visibility: string; tiers?: {id: string}[]} + ) => { + setIsRunning(true); + + try { + const action = (key === 'add-tag' + ? {type: 'addTag', meta} + : {type: 'access', meta}) as PostBulkAction; + + if (isPages) { + await bulkEditPages.mutateAsync({filter: snapshot.filter, action}); + } else { + await bulkEditPosts.mutateAsync({filter: snapshot.filter, action}); + } + + if (key === 'change-access') { + toast.success(getPostActionMessage('accessUpdated', { + count: snapshot.count, resource, isSingle: snapshot.isSingle + })); + } else { + const tagCount = 'tags' in meta ? meta.tags.length : 1; + + toast.success(getPostActionMessage(tagCount > 1 ? 'tagsAdded' : 'tagAdded', { + count: snapshot.count, resource, isSingle: snapshot.isSingle + })); + } + + // Close *before* refetching, not after. `invalidateQueries` + // resolves only once every active query has refetched, so awaiting + // it here leaves the modal open — and a modal open means + // `body { pointer-events: none }`, so nothing on the page is + // clickable until it resolves. If one query is slow or never + // settles, the whole admin appears frozen. + onEdited?.(new Set(snapshot.posts.map(post => post.id))); + + void queryClient.invalidateQueries({queryKey: [dataType]}); + + // Adding a tag can *create* one: a name the server does not + // recognise becomes a new tag as a side effect of saving the post. + // Nothing in the tag cache is told, so without this the tag is + // missing from the filter and from this dialog until the browser is + // refreshed. Invalidated for every add, not only the creating kind + // — the counts on existing tags move too. + if (key === 'add-tag') { + void queryClient.invalidateQueries({queryKey: ['TagsResponseType']}); + } + } catch (error) { + toast.error(error instanceof Error && error.message + ? error.message + : 'That action could not be completed.'); + } finally { + setIsRunning(false); + } + }, [isPages, resource, onEdited, queryClient, dataType, bulkEditPosts, bulkEditPages]); + + const run = useCallback(async (key: PostContextMenuKey, snapshot: BulkActionSnapshot) => { + setIsRunning(true); + + try { + if (key === 'delete') { + if (isPages) { + await bulkDeletePages.mutateAsync({filter: snapshot.filter}); + } else { + await bulkDeletePosts.mutateAsync({filter: snapshot.filter}); + } + + patchCaches(snapshot, 'delete'); + // Other cached lists for the resource (saved views, search + // index, analytics screens) still hold the rows; mark them + // stale without refetching the just-patched active queries. + void queryClient.invalidateQueries({queryKey: [dataType], refetchType: 'none'}); + toast.success(getPostActionMessage('deleted', { + count: snapshot.count, resource, isSingle: snapshot.isSingle + })); + onDeleted?.(); + + return; + } + + const bulk = BULK_ACTIONS[key]; + + if (!bulk) { + return; + } + + const payload = {filter: snapshot.filter, action: {type: bulk.action} as PostBulkAction}; + + if (isPages) { + await bulkEditPages.mutateAsync(payload); + } else { + await bulkEditPosts.mutateAsync(payload); + } + + if (bulk.message) { + toast.success(getPostActionMessage(bulk.message, { + count: snapshot.count, resource, isSingle: snapshot.isSingle + })); + } + + const remaining = patchCaches(snapshot, key); + + void queryClient.invalidateQueries({queryKey: [dataType], refetchType: 'none'}); + + // The rows the edit pushed out of the list are no longer selectable, + // but the ones still on screen stay selected — matching Ember's + // `clearUnavailableItems` rather than a full clear. + onEdited?.(remaining); + } catch (error) { + // Without this the rejection is unhandled and the user sees + // nothing: the modal simply sits there. Ember surfaces the API + // error through `showAPIError`. + toast.error(error instanceof Error && error.message + ? error.message + : 'That action could not be completed.'); + } finally { + setIsRunning(false); + } + }, [ + isPages, resource, onDeleted, onEdited, patchCaches, dataType, queryClient, + bulkEditPosts, bulkEditPages, bulkDeletePosts, bulkDeletePages + ]); + + return {run, runWithPayload, isRunning}; +} diff --git a/apps/admin/src/posts/list/hooks/use-post-publish-celebration.ts b/apps/admin/src/posts/list/hooks/use-post-publish-celebration.ts new file mode 100644 index 00000000000..f7dea2753c4 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-publish-celebration.ts @@ -0,0 +1,91 @@ +import {readPublishCelebration, type PublishCelebration} from '@/posts/list/post-publish-celebration'; +import {useBrowsePages} from '@tryghost/admin-x-framework/api/pages'; +import {useBrowsePosts} from '@tryghost/admin-x-framework/api/posts'; +import {useEffect, useRef, useState} from 'react'; + +/** + * The post-publish celebration, ported from `checkPublishFlowModal` in + * `apps/ember-admin/app/components/posts-list/list.js`. + * + * The Ember editor writes a localStorage key and navigates here; the list reads + * it on mount. The editor stays Ember on both sides of the flag, so only the + * reader moved. + */ +export function usePostPublishCelebration() { + /** + * Read once, on mount, and cleared in the same breath — see + * `readPublishCelebration`. Held in state rather than re-read, because the + * key is gone by the second render. + */ + const [celebration, setCelebration] = useState(null); + + /** + * The read is single-shot — it clears the key as it goes — and StrictMode + * invokes effects twice on mount. Without this guard the first invocation + * consumes the key and sets the state, and the second reads nothing and + * clobbers it back to null, so the celebration never appears at all. + * + * A ref rather than a module-level flag: it survives the double-invoke + * (same component instance) while still letting a genuinely new mount — + * publishing a second post — read a fresh key. + */ + const hasRead = useRef(false); + + useEffect(() => { + if (hasRead.current) { + return; + } + + hasRead.current = true; + setCelebration(readPublishCelebration()); + }, []); + + /** + * Browsed by id rather than read as a single post, and keyed on the type + * the *editor* recorded — Ember does `store.query(post.type, {filter: + * \`id:${post.id}\`})`, where the type is 'post' or 'page'. + * + * Reading it back off the posts endpoint regardless would 404 for a page, + * so publishing a page would never celebrate at all. `include` matters too: + * without it the modal has no author to show. + */ + const isPage = celebration?.type === 'page'; + const searchParams = { + filter: `id:${celebration?.id ?? ''}`, + limit: '1', + include: 'authors,newsletter,email' + }; + + // Both called unconditionally and picked by type — hooks can't be branched. + const {data: postData} = useBrowsePosts({ + searchParams, + enabled: Boolean(celebration) && !isPage + }); + const {data: pageData} = useBrowsePages({ + searchParams, + enabled: Boolean(celebration) && isPage + }); + + /** + * The total published count, for "That's 47 posts published." + * + * Deliberately not blocking the modal: Ember awaits it, which delays the + * celebration behind a second request. The copy falls back to "Spread the + * word!" until it lands, which is a wording Ember also uses. + */ + const {data: countData} = useBrowsePosts({ + searchParams: {filter: 'status:published', limit: '1'}, + enabled: celebration?.wasPublished === true + }); + + const post = isPage ? pageData?.pages?.[0] : postData?.posts?.[0]; + + return { + celebration, + post, + postCount: celebration?.wasPublished ? countData?.meta?.pagination.total : undefined, + dismiss: () => { + setCelebration(null); + } + }; +} diff --git a/apps/admin/src/posts/list/hooks/use-post-selection.ts b/apps/admin/src/posts/list/hooks/use-post-selection.ts new file mode 100644 index 00000000000..b596355642d --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-selection.ts @@ -0,0 +1,278 @@ +import {getPostSelectionFilter} from '@/posts/list/post-selection-filter'; +import {initialPostSelection, isPostSelected, postSelectionReducer} from '@/posts/list/post-selection-state'; +import {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react'; +import type {MouseEvent as ReactMouseEvent} from 'react'; + +/** + * Modifier-click selection for the posts list, ported from + * `apps/ember-admin/app/components/multi-list/list.js` and `item.js`. + * + * There are no checkboxes: cmd-click, shift-click, Cmd+A, Escape, and an + * unmodified click anywhere clears. That means window-level handlers, which is + * where most of the care here goes. + */ + +interface UsePostSelectionOptions { + /** The rows currently on screen, in display order — shift ranges over this. */ + orderedIds: string[]; + /** The filter bounding an inverted selection. */ + allFilter: string; + /** Off for authors and contributors, who cannot bulk-edit anything. */ + enabled: boolean; +} + +/** + * A modifier-click leaves a text selection behind across the rows it dragged + * over, which looks broken. Ember clears it the same way. + */ +function clearTextSelection() { + const selection = window.getSelection(); + + if (!selection) { + return; + } + + if (selection.empty) { + selection.empty(); + } else if (selection.removeAllRanges) { + selection.removeAllRanges(); + } +} + +export function usePostSelection({orderedIds, allFilter, enabled}: UsePostSelectionOptions) { + const [state, dispatch] = useReducer(postSelectionReducer, initialPostSelection); + + /** + * Whether a modifier is being held right now — Ember's `actionKeyPressed`. + * It puts the list into "select mode": the cursor stops being a pointer and + * the rows stop behaving like links, so what a click is about to do is + * visible before the click happens. + * + * Derived from the event's own modifier flags rather than tracked per key. + * `keydown` and `keyup` both report the state *after* the event, so one + * boolean is enough where Ember keeps three. + */ + const [modifierHeld, setModifierHeld] = useState(false); + + // Read by the window handlers, which are registered once. Keeping these in + // a ref rather than in the dependency list means the listeners aren't torn + // down and rebuilt on every keystroke in the filter bar. + const latest = useRef({orderedIds, enabled}); + latest.current = {orderedIds, enabled}; + + /** + * Ember clears the selection on every model refresh — `clearSelection()` in + * `PostsRoute#setupController`, reached whenever one of the five + * `refreshModel: true` query params changes. + * + * This is not tidiness. After Cmd+A the selection is *inverted* and bounded + * by `allFilter`, which is rebuilt from the URL: select all drafts, drop + * the type filter, and the very same selection now means "every post on the + * site". Carrying it across would hand that to a bulk delete. + */ + const previousFilter = useRef(allFilter); + + useEffect(() => { + if (previousFilter.current !== allFilter) { + previousFilter.current = allFilter; + dispatch({type: 'clear'}); + } + }, [allFilter]); + + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (!latest.current.enabled) { + return; + } + + // Cmd+A belongs to whatever is being typed into. Ember has the + // same flaw against its power-select search boxes, but the + // save-view popover is a React-only surface, so this would be a + // new way to lose what you were typing. + const target = event.target as HTMLElement | null; + const isTyping = target instanceof HTMLInputElement + || target instanceof HTMLTextAreaElement + || target?.isContentEditable === true; + + if (isTyping) { + return; + } + + if ((event.metaKey || event.ctrlKey) && !event.shiftKey && event.key === 'a') { + dispatch({type: 'selectAll'}); + // Otherwise the browser selects every word on the page too. + event.preventDefault(); + return; + } + + if (event.key === 'Escape') { + dispatch({type: 'clear'}); + } + } + + function syncModifier(event: KeyboardEvent | MouseEvent) { + setModifierHeld(event.metaKey || event.ctrlKey || event.shiftKey); + } + + // A keyup that never arrives — Cmd+Tab away mid-chord — would otherwise + // leave the list stuck in select mode with nothing clickable. + function onWindowBlur() { + setModifierHeld(false); + } + + function onWindowClick(event: MouseEvent) { + if (!latest.current.enabled) { + return; + } + + // A click inside an open menu or dialog is not "clicking away" — + // without this, confirming "Delete 12 posts?" would clear the very + // selection the confirm handler is about to read. + // + // `alertdialog` is listed separately because Radix's AlertDialog — + // which is exactly what a destructive confirmation uses — renders + // that role and *not* `dialog`, so matching only `dialog` would + // miss the one case that matters most. + // + // Ember has no target check at all here; it freezes the selection + // list while its menu is open instead. Scoping by role is the same + // guarantee without the freeze/unfreeze machinery. + const target = event.target as HTMLElement | null; + + if (target?.closest('[role="menu"], [role="dialog"], [role="alertdialog"], [role="listbox"]')) { + return; + } + + if (!event.metaKey && !event.ctrlKey) { + dispatch({type: 'clear'}); + } + } + + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keydown', syncModifier); + window.addEventListener('keyup', syncModifier); + window.addEventListener('click', onWindowClick); + window.addEventListener('click', syncModifier); + window.addEventListener('blur', onWindowBlur); + + return () => { + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keydown', syncModifier); + window.removeEventListener('keyup', syncModifier); + window.removeEventListener('click', onWindowClick); + window.removeEventListener('click', syncModifier); + window.removeEventListener('blur', onWindowBlur); + }; + }, []); + + /** + * Bound to each row's `mousedown` in the **capture** phase. Mousedown + * rather than click because only mousedown can `preventDefault()` the + * browser's own text selection; capture because it has to win against the + * row's link before the browser starts navigating. + */ + const onRowMouseDown = useCallback((event: ReactMouseEvent, id: string) => { + if (!latest.current.enabled) { + return; + } + + // The metric links and the trailing action button opt out — a + // cmd-click there should follow the link, not select the row. + if ((event.target as HTMLElement).closest('[data-ignore-select]')) { + return; + } + + const useCtrl = event.ctrlKey || event.metaKey; + + if (useCtrl) { + dispatch({type: 'toggle', id}); + } else if (event.shiftKey) { + dispatch({type: 'shift', id, orderedIds: latest.current.orderedIds}); + } else { + return; + } + + event.preventDefault(); + event.stopPropagation(); + clearTextSelection(); + }, []); + + /** + * The matching capture-phase `click`. Its whole job is to stop a modifier + * click from navigating, and to stop it reaching the window handler that + * would immediately clear what was just selected. + */ + const onRowClick = useCallback((event: ReactMouseEvent) => { + if (!latest.current.enabled) { + return; + } + + if ((event.target as HTMLElement).closest('[data-ignore-select]')) { + return; + } + + if (!event.ctrlKey && !event.metaKey && !event.shiftKey) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + clearTextSelection(); + }, []); + + /** + * Radix owns the menu's open state, so this is where Ember's + * freeze/unfreeze pair lands: opening on an unselected row selects just + * that row transiently, and closing drops it again. + */ + const onContextMenuOpenChange = useCallback((open: boolean, id: string) => { + if (!latest.current.enabled) { + return; + } + + dispatch(open ? {type: 'contextMenu', id} : {type: 'closeContextMenu'}); + }, []); + + /** + * One stable handler per row id. An inline `open => handler(open, id)` in + * the list would be a new function on every render, which defeats the + * memoised context menu and with it the memoised row. + */ + const openHandlers = useRef(new Map void>()); + + const getContextMenuOpenHandler = useCallback((id: string) => { + const existing = openHandlers.current.get(id); + + if (existing) { + return existing; + } + + const handler = (open: boolean) => { + onContextMenuOpenChange(open, id); + }; + + openHandlers.current.set(id, handler); + + return handler; + }, [onContextMenuOpenChange]); + + /** Ember's `clearUnavailableItems`, called after a bulk edit prunes rows. */ + const keepOnly = useCallback((ids: Set) => { + dispatch({type: 'keepOnly', ids}); + }, []); + + const filter = useMemo(() => getPostSelectionFilter(state, allFilter), [state, allFilter]); + + return { + state, + filter, + modifierHeld: enabled && modifierHeld, + isSelected: useCallback((id: string) => enabled && isPostSelected(state, id), [enabled, state]), + clear: useCallback(() => dispatch({type: 'clear'}), []), + keepOnly, + onRowMouseDown, + onRowClick, + getContextMenuOpenHandler, + enabled + }; +} diff --git a/apps/admin/src/posts/list/hooks/use-post-views.ts b/apps/admin/src/posts/list/hooks/use-post-views.ts new file mode 100644 index 00000000000..7d98efa72a3 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-views.ts @@ -0,0 +1,93 @@ +import {applyPostViewDelete, applyPostViewSave} from '@/posts/list/post-views-storage'; +import {getSettingValue, useBrowseSettings, useEditSettings} from '@tryghost/admin-x-framework/api/settings'; +import {parseAllSharedViewsJSON, type SharedView} from '@/members/shared-views'; +import {useCallback} from 'react'; +import {useHandleError} from '@tryghost/admin-x-framework/hooks'; +import type {PostListParams} from '@/posts/list/post-query-params'; +import type {PostViewColor} from '@/posts/list/post-views'; + +/** + * Reading and writing saved views for the posts list. + * + * Reads go through the tolerant parser (unreadable entries are simply not + * shown); writes go through `post-views-storage`, which works on the raw array + * so it can never drop an entry it didn't understand. See that file for why. + */ + +const SETTINGS_NOT_LOADED_ERROR = 'Settings are still loading, so nothing was changed'; + +type SettingsData = {settings: Array<{key: string; value: string | boolean | null}>} | undefined; + +/** + * Returns `undefined` — not `'[]'` — while settings are still loading. An + * empty-list default here would let a save replace every existing view with + * just the new one. + */ +function getSharedViewsJSON(settingsData: SettingsData): string | undefined { + if (!settingsData) { + return undefined; + } + + return getSettingValue(settingsData.settings, 'shared_views') ?? '[]'; +} + +/** Just the posts views, for the filter bar's edit/save affordance. */ +export function usePostViews(): SharedView[] { + const {data: settingsData} = useBrowseSettings(); + const json = getSharedViewsJSON(settingsData); + + if (json === undefined) { + return []; + } + + const parsed = parseAllSharedViewsJSON(json); + + return parsed.ok ? parsed.views.filter(view => view.route === 'posts') : []; +} + +function useWriteSharedViews() { + const {data: settingsData} = useBrowseSettings(); + const {mutateAsync: editSettings} = useEditSettings(); + const handleError = useHandleError(); + + return useCallback(async (transform: (json: string) => string) => { + const json = getSharedViewsJSON(settingsData); + + if (json === undefined) { + const error = new Error(SETTINGS_NOT_LOADED_ERROR); + handleError(error, {withToast: false}); + throw error; + } + + // Throws rather than writing if the stored value is unreadable. + const updated = transform(json); + + try { + await editSettings([{key: 'shared_views', value: updated}]); + } catch (error) { + handleError(error, {withToast: false}); + throw error; + } + }, [settingsData, editSettings, handleError]); +} + +export function useSavePostView() { + const writeSharedViews = useWriteSharedViews(); + + return useCallback(async ( + name: string, + params: PostListParams, + color: PostViewColor, + originalView?: SharedView + ) => { + await writeSharedViews(json => applyPostViewSave(json, name, params, color, originalView)); + }, [writeSharedViews]); +} + +export function useDeletePostView() { + const writeSharedViews = useWriteSharedViews(); + + return useCallback(async (view: SharedView) => { + await writeSharedViews(json => applyPostViewDelete(json, view)); + }, [writeSharedViews]); +} diff --git a/apps/admin/src/posts/list/hooks/use-posts-filter-state.test.tsx b/apps/admin/src/posts/list/hooks/use-posts-filter-state.test.tsx new file mode 100644 index 00000000000..bfee10147b7 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-posts-filter-state.test.tsx @@ -0,0 +1,179 @@ +import {MemoryRouter, useSearchParams} from 'react-router'; +import {act, renderHook, waitFor} from '@testing-library/react'; +import {describe, expect, it} from 'vitest'; +import {usePostsFilterState} from './use-posts-filter-state'; +import type {ReactNode} from 'react'; + +function createWrapper(initialEntry: string) { + return function Wrapper({children}: {children: ReactNode}) { + return {children}; + }; +} + +function renderState(initialEntry: string) { + return renderHook(() => { + const state = usePostsFilterState(); + const [searchParams] = useSearchParams(); + + return {...state, query: searchParams.toString()}; + }, {wrapper: createWrapper(initialEntry)}); +} + +describe('usePostsFilterState', () => { + it('starts empty with no params', () => { + const {result} = renderState('/posts'); + + expect(result.current.filters).toEqual([]); + // `params` carries order too - the query layer needs it to resolve each + // bucket's sort - even though it is not part of the chip model. + expect(result.current.params).toEqual({ + type: null, visibility: null, author: null, tag: null, order: null + }); + expect(result.current.order).toBeNull(); + expect(result.current.hasFilters).toBe(false); + }); + + it('hydrates filters from the URL', () => { + const {result} = renderState('/posts?type=draft&tag=news'); + + expect(result.current.filters.map(filter => filter.field)).toEqual(['type', 'tag']); + expect(result.current.params).toMatchObject({type: 'draft', tag: 'news'}); + expect(result.current.hasFilters).toBe(true); + }); + + it('reads order separately from the filters', () => { + const {result} = renderState('/posts?order=updated_at%20desc'); + + expect(result.current.order).toBe('updated_at desc'); + expect(result.current.filters).toEqual([]); + // Sorting is not filtering - it must not trigger the "no posts match + // the current filter" empty state. + expect(result.current.hasFilters).toBe(false); + }); + + // A URL is a saved view's identity. Rewriting it on load - even + // canonicalising it - would silently corrupt the user's view, and the + // Ember screen would then read something different. + it('never rewrites the URL on hydration', async () => { + const {result} = renderState('/posts?type=draft&tag=news&order=updated_at+desc'); + const initial = result.current.query; + + await waitFor(() => { + expect(result.current.filters).toHaveLength(2); + }); + + expect(result.current.query).toBe(initial); + }); + + it('keeps params it does not recognise', async () => { + const {result} = renderState('/posts?type=nonsense&tag=deleted-tag'); + + await waitFor(() => { + expect(result.current.filters).toHaveLength(2); + }); + + expect(result.current.query).toContain('type=nonsense'); + expect(result.current.query).toContain('tag=deleted-tag'); + }); + + it('writes filter changes back to the URL', async () => { + const {result} = renderState('/posts'); + + act(() => { + result.current.setFilters([ + {id: 'type:1', field: 'type', operator: 'is', values: ['draft']} + ]); + }); + + await waitFor(() => { + expect(result.current.query).toBe('type=draft'); + }); + expect(result.current.params).toMatchObject({type: 'draft'}); + }); + + it('removes a param entirely rather than leaving it empty', async () => { + const {result} = renderState('/posts?type=draft'); + + act(() => { + result.current.setFilters([]); + }); + + await waitFor(() => { + expect(result.current.query).toBe(''); + }); + }); + + it('changes the sort without touching the filters', async () => { + const {result} = renderState('/posts?type=draft'); + + act(() => { + result.current.setOrder('published_at asc'); + }); + + await waitFor(() => { + expect(result.current.order).toBe('published_at asc'); + }); + expect(result.current.query).toContain('type=draft'); + expect(result.current.params).toMatchObject({type: 'draft'}); + }); + + it('drops the order param when returning to the default sort', async () => { + const {result} = renderState('/posts?order=published_at+asc'); + + act(() => { + result.current.setOrder(null); + }); + + await waitFor(() => { + expect(result.current.query).toBe(''); + }); + }); + + // "Show all posts" in the filtered empty state. Ember's link resets + // type/author/tag/visibility but deliberately NOT order, so a chosen sort + // survives clearing the filters. + it('clears the filters but keeps the sort', async () => { + const {result} = renderState('/posts?type=draft&tag=news&order=published_at+asc'); + + act(() => { + result.current.clearFilters(); + }); + + await waitFor(() => { + expect(result.current.filters).toEqual([]); + }); + expect(result.current.query).toBe('order=published_at+asc'); + expect(result.current.order).toBe('published_at asc'); + }); + + it('leaves unrelated query params alone', async () => { + const {result} = renderState('/posts?type=draft&somethingElse=keepme'); + + act(() => { + result.current.setFilters([]); + }); + + await waitFor(() => { + expect(result.current.query).toBe('somethingElse=keepme'); + }); + }); + + // Back/forward must re-hydrate rather than replay the last write. + it('follows external URL changes', async () => { + const {result, rerender} = renderHook(() => { + const state = usePostsFilterState(); + const [searchParams, setSearchParams] = useSearchParams(); + + return {...state, query: searchParams.toString(), setSearchParams}; + }, {wrapper: createWrapper('/posts?type=draft')}); + + act(() => { + result.current.setSearchParams(new URLSearchParams('type=scheduled')); + }); + rerender(); + + await waitFor(() => { + expect(result.current.params).toMatchObject({type: 'scheduled'}); + }); + }); +}); diff --git a/apps/admin/src/posts/list/hooks/use-posts-filter-state.ts b/apps/admin/src/posts/list/hooks/use-posts-filter-state.ts new file mode 100644 index 00000000000..f4f80945a1b --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-posts-filter-state.ts @@ -0,0 +1,112 @@ +import {POST_FILTER_PARAMS, parsePostFilters, serializePostFilters} from '@/posts/list/post-filter-query'; +import {useCallback, useMemo} from 'react'; +import {useSearchParams} from 'react-router'; +import type {Filter} from '@tryghost/shade/patterns'; +import type {PostListParams} from '@/posts/list/post-query-params'; + +/** + * Owns the posts/pages screen state that lives in the URL. + * + * The URL is the source of truth, and this hook is deliberately quieter than + * the members equivalent: it never writes on hydration. A posts URL is a saved + * view's identity - the sidebar persists `{type, visibility, author, tag, + * order}` records and compares them verbatim - so canonicalising a URL just + * because we parsed it would silently corrupt the user's view, and the Ember + * screen would then read something different. Values we don't recognise are + * carried through untouched for the same reason. + * + * `order` is kept out of the chip model because it is a sort, not a filter. + */ + +const ORDER_PARAM = 'order'; + +interface SetOptions { + /** Filter changes replace history by default, matching Ember. */ + replace?: boolean; +} + +export interface UsePostsFilterStateReturn { + /** Chip model for the Shade `Filters` component. */ + filters: Filter[]; + /** The raw param record, for building API queries and matching saved views. */ + params: PostListParams; + order: string | null; + setFilters: (filters: Filter[], options?: SetOptions) => void; + setOrder: (order: string | null, options?: SetOptions) => void; + /** Clears the filters but keeps the sort, matching Ember. */ + clearFilters: (options?: SetOptions) => void; + /** Whether any *filter* is active. Sorting deliberately doesn't count. */ + hasFilters: boolean; +} + +function readParams(searchParams: URLSearchParams): PostListParams { + const params: PostListParams = {}; + + POST_FILTER_PARAMS.forEach((param) => { + params[param] = searchParams.get(param); + }); + + params.order = searchParams.get(ORDER_PARAM); + + return params; +} + +/** Applies a param record to a copy of the URL, leaving other params alone. */ +function writeParams( + searchParams: URLSearchParams, + values: Partial> +): URLSearchParams { + const next = new URLSearchParams(searchParams); + + Object.entries(values).forEach(([key, value]) => { + if (value === null || value === undefined || value === '') { + next.delete(key); + } else { + next.set(key, value); + } + }); + + return next; +} + +export function usePostsFilterState(): UsePostsFilterStateReturn { + const [searchParams, setSearchParams] = useSearchParams(); + + const params = useMemo(() => readParams(searchParams), [searchParams]); + const filters = useMemo(() => parsePostFilters(params), [params]); + const order = params.order ?? null; + + const apply = useCallback(( + values: Partial>, + {replace = true}: SetOptions = {} + ) => { + setSearchParams(current => writeParams(current, values), {replace}); + }, [setSearchParams]); + + const setFilters = useCallback((nextFilters: Filter[], options?: SetOptions) => { + apply(serializePostFilters(nextFilters), options); + }, [apply]); + + const setOrder = useCallback((nextOrder: string | null, options?: SetOptions) => { + apply({[ORDER_PARAM]: nextOrder}, options); + }, [apply]); + + /** + * Clears the filters and leaves the sort alone — Ember's "Show all posts" + * link resets `type`, `author`, `tag` and `visibility` but deliberately + * not `order` (`templates/posts.hbs:51`), so a chosen sort survives. + * + * Replaces rather than pushes, like every other filter change here: the + * Ember route forces `transition.method('replace')` for any posts→posts + * transition (`routes/posts.js:60-70`, added for TryGhost/Ghost#11057) so + * filter changes don't pile up history entries — and "Show all posts" is + * one of those transitions. + */ + const clearFilters = useCallback((options?: SetOptions) => { + apply(serializePostFilters([]), options); + }, [apply]); + + const hasFilters = POST_FILTER_PARAMS.some(param => Boolean(params[param])); + + return {filters, params, order, setFilters, setOrder, clearFilters, hasFilters}; +} diff --git a/apps/admin/src/posts/list/hooks/use-posts-list.ts b/apps/admin/src/posts/list/hooks/use-posts-list.ts new file mode 100644 index 00000000000..4f83203cf27 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-posts-list.ts @@ -0,0 +1,93 @@ +import {BUCKET_ORDER, getActiveBuckets, getBucketSearchParams} from '@/posts/list/post-query-params'; +import {composePostBuckets, type ComposedPostList, type PostBucketResult} from '@/posts/list/compose-post-buckets'; +import {keepPreviousData} from '@tanstack/react-query'; +import {useBrowsePagesInfinite} from '@tryghost/admin-x-framework/api/pages'; +import {useBrowsePostsInfinite} from '@tryghost/admin-x-framework/api/posts'; +import type {Page} from '@tryghost/admin-x-framework/api/pages'; +import type {Post} from '@tryghost/admin-x-framework/api/posts'; +import type {PostBucket, PostFilterContext, PostListParams} from '@/posts/list/post-query-params'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** A row in the list. Pages are posts with a different `displayName`. */ +export type PostListItem = Post | Page; + +export interface UsePostsListOptions { + resource: PostResource; + params: PostListParams; + context?: PostFilterContext; +} + +export type UsePostsListReturn = ComposedPostList; + +/** + * Runs one bucket's query. + * + * Both resource hooks are called on every render and gated by `enabled`, + * rather than picking one - a hook chosen at runtime would break the rules of + * hooks. The disabled one never fetches. + */ +function useBucketQuery( + bucket: PostBucket, + {resource, params, context}: UsePostsListOptions, + enabled: boolean +): PostBucketResult { + const searchParams = getBucketSearchParams(bucket, params, context); + + // keepPreviousData so changing a filter doesn't blank the list first. + const postsQuery = useBrowsePostsInfinite({ + searchParams, + enabled: enabled && resource === 'posts', + placeholderData: keepPreviousData + }); + + const pagesQuery = useBrowsePagesInfinite({ + searchParams, + enabled: enabled && resource === 'pages', + placeholderData: keepPreviousData + }); + + const query = resource === 'pages' ? pagesQuery : postsQuery; + const items: PostListItem[] = (resource === 'pages' + ? pagesQuery.data?.pages + : postsQuery.data?.posts) ?? []; + + return { + bucket, + items, + total: query.data?.meta?.pagination.total ?? 0, + hasNextPage: query.hasNextPage, + // A disabled bucket is not loading, it is simply not wanted; treating + // it as loading would hold the whole list forever. + isLoading: enabled && query.isLoading, + isFetchingNextPage: query.isFetchingNextPage, + isError: query.isError, + fetchNextPage: () => { + void query.fetchNextPage(); + } + }; +} + +/** + * The posts/pages list, assembled from up to three per-status queries. + * + * See `compose-post-buckets.ts` for why there are three and how they sequence. + */ +export function usePostsList(options: UsePostsListOptions): UsePostsListReturn { + const activeBuckets = getActiveBuckets(options.params); + + // Hook order has to be stable, so every bucket is queried and the ones the + // current filter doesn't need are disabled. + const scheduled = useBucketQuery('scheduled', options, activeBuckets.includes('scheduled')); + const draft = useBucketQuery('draft', options, activeBuckets.includes('draft')); + const publishedAndSent = useBucketQuery('publishedAndSent', options, activeBuckets.includes('publishedAndSent')); + + const byBucket: Record> = { + scheduled, + draft, + publishedAndSent + }; + + return composePostBuckets( + BUCKET_ORDER.filter(bucket => activeBuckets.includes(bucket)).map(bucket => byBucket[bucket]) + ); +} diff --git a/apps/admin/src/posts/list/humanize-recipient-filter.test.ts b/apps/admin/src/posts/list/humanize-recipient-filter.test.ts new file mode 100644 index 00000000000..1754fdcf368 --- /dev/null +++ b/apps/admin/src/posts/list/humanize-recipient-filter.test.ts @@ -0,0 +1,44 @@ +import {describe, expect, it} from 'vitest'; +import {humanizeRecipientFilter} from './humanize-recipient-filter'; + +// Ported from apps/ember-admin/app/helpers/humanize-recipient-filter.js. + +describe('humanizeRecipientFilter', () => { + it('names both statuses together as everyone', () => { + expect(humanizeRecipientFilter('status:free,status:-free')).toBe('All subscribers'); + }); + + it('names a single status', () => { + expect(humanizeRecipientFilter('status:free')).toBe('Free subscribers'); + expect(humanizeRecipientFilter('status:-free')).toBe('Paid subscribers'); + }); + + it('lists labels, capitalised', () => { + expect(humanizeRecipientFilter('labels:[vip,founder]')).toBe('Labels: Vip, Founder'); + }); + + it('uses the singular for one label', () => { + expect(humanizeRecipientFilter('labels:[vip]')).toBe('Label: Vip'); + expect(humanizeRecipientFilter('label:vip')).toBe('Label: Vip'); + }); + + it('lists products', () => { + expect(humanizeRecipientFilter('products:[gold,silver]')).toBe('Products: Gold, Silver'); + }); + + it('joins a status and a label with an ampersand', () => { + expect(humanizeRecipientFilter('status:free,labels:[vip]')) + .toBe('Free subscribers & Label: Vip'); + }); + + // The helper only understands what the publishing UI can produce; showing + // the raw filter beats guessing wrong. + it('falls back to the raw filter for anything it does not understand', () => { + expect(humanizeRecipientFilter('some:nonsense')).toBe('some:nonsense'); + }); + + it('is empty for an empty filter', () => { + expect(humanizeRecipientFilter('')).toBe(''); + expect(humanizeRecipientFilter()).toBe(''); + }); +}); diff --git a/apps/admin/src/posts/list/humanize-recipient-filter.ts b/apps/admin/src/posts/list/humanize-recipient-filter.ts new file mode 100644 index 00000000000..fc1647c82b9 --- /dev/null +++ b/apps/admin/src/posts/list/humanize-recipient-filter.ts @@ -0,0 +1,59 @@ +/** + * Turns an email segment NQL filter into the phrase Ghost shows a user, e.g. + * "All subscribers", "Paid subscribers", "Labels: VIP, Founder". + * + * Ported from `apps/ember-admin/app/helpers/humanize-recipient-filter.js`. + * Like the original, this only understands the limited set of filters the + * publishing UI can produce, and falls back to the raw filter for anything + * else rather than guessing. + */ + +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + +function pluralLabel(word: string, count: number): string { + return count === 1 ? word : `${word}s`; +} + +function extractList(filter: string, key: string): string[] | null { + const arrayMatch = new RegExp(`${key}s:\\[(.*?)\\]`).exec(filter); + + if (arrayMatch) { + return arrayMatch[1].split(','); + } + + const singleMatches = [...filter.matchAll(new RegExp(`${key}:(.*?)(?:,|$)`, 'g'))] + .map(([, value]) => value) + .filter(Boolean); + + return singleMatches.length ? singleMatches : null; +} + +export function humanizeRecipientFilter(filter = ''): string { + const parts = filter.split(','); + + if (parts.includes('status:free') && parts.includes('status:-free')) { + return 'All subscribers'; + } + + const output: string[] = []; + + if (parts.includes('status:free')) { + output.push('Free subscribers'); + } else if (parts.includes('status:-free')) { + output.push('Paid subscribers'); + } + + const labels = extractList(filter, 'label'); + if (labels) { + output.push(`${pluralLabel('Label', labels.length)}: ${labels.map(capitalize).join(', ')}`); + } + + const products = extractList(filter, 'product'); + if (products) { + output.push(`${pluralLabel('Product', products.length)}: ${products.map(capitalize).join(', ')}`); + } + + return output.length ? output.join(' & ') : filter; +} diff --git a/apps/admin/src/posts/list/pages-route.tsx b/apps/admin/src/posts/list/pages-route.tsx new file mode 100644 index 00000000000..bda98a17d33 --- /dev/null +++ b/apps/admin/src/posts/list/pages-route.tsx @@ -0,0 +1,8 @@ +import {PostsListScreen} from './posts-list-screen'; + +/** + * Route entry for `/pages`. See `posts-route.tsx` — same screen, other resource. + */ +export default function PagesRoute() { + return ; +} diff --git a/apps/admin/src/posts/list/post-action-messages.test.ts b/apps/admin/src/posts/list/post-action-messages.test.ts new file mode 100644 index 00000000000..807cb93cb28 --- /dev/null +++ b/apps/admin/src/posts/list/post-action-messages.test.ts @@ -0,0 +1,75 @@ +import {describe, expect, it} from 'vitest'; +import {getPostActionMessage} from './post-action-messages'; + +/** + * The toast wording, ported from the `messages` table and `#getToastMessage` in + * `apps/ember-admin/app/components/posts-list/context-menu.js`. + * + * Pinned in tests because these strings are the only feedback a bulk action + * gives, they differ between one row and many in ways that aren't guessable, + * and one of them is deliberately wrong (see below). + */ + +describe('getPostActionMessage', () => { + describe('one post', () => { + it.each([ + ['deleted', 'Post deleted'], + ['unpublished', 'Post reverted to a draft'], + ['unscheduled', 'Post unscheduled'], + ['accessUpdated', 'Post access updated'], + ['duplicated', 'Post duplicated'], + ['tagsAdded', 'Tags added'], + ['tagAdded', 'Tag added'] + ] as const)('%s', (action, expected) => { + expect(getPostActionMessage(action, {count: 1, resource: 'posts'})).toBe(expected); + }); + }); + + describe('several posts', () => { + it.each([ + ['deleted', '3 posts deleted'], + ['unpublished', '3 posts reverted to drafts'], + ['unscheduled', '3 posts unscheduled'], + ['duplicated', '3 posts duplicated'], + ['tagsAdded', 'Tags added to 3 posts'], + ['tagAdded', 'Tag added to 3 posts'] + ] as const)('%s', (action, expected) => { + expect(getPostActionMessage(action, {count: 3, resource: 'posts'})).toBe(expected); + }); + + // The odd one out: this one leads with the capitalised type rather than + // the count, so it reads "Post access updated for 3 posts". + it('accessUpdated leads with the type, not the count', () => { + expect(getPostActionMessage('accessUpdated', {count: 3, resource: 'posts'})) + .toBe('Post access updated for 3 posts'); + }); + }); + + describe('pages', () => { + it('uses the page noun, capitalised where the string leads with it', () => { + expect(getPostActionMessage('deleted', {count: 1, resource: 'pages'})).toBe('Page deleted'); + expect(getPostActionMessage('deleted', {count: 4, resource: 'pages'})).toBe('4 pages deleted'); + }); + + /** + * Ember hardcodes "Post link copied" and "Preview link copied" with no + * interpolation, so copying a *page* link still says "Post". Ported + * as-is rather than quietly corrected: it is a visible string, and + * changing it here would make the two implementations disagree while + * the flag is still switchable. + */ + it('still says "Post link copied" on a page, as Ember does', () => { + expect(getPostActionMessage('copiedPostUrl', {count: 1, resource: 'pages'})) + .toBe('Post link copied'); + expect(getPostActionMessage('copiedPreviewUrl', {count: 1, resource: 'pages'})) + .toBe('Preview link copied'); + }); + }); + + // These are only ever reached from a single-post action, and Ember's table + // has no plural for them at all — it would interpolate `undefined`. + it('keeps the copy messages singular whatever the count', () => { + expect(getPostActionMessage('copiedPostUrl', {count: 9, resource: 'posts'})) + .toBe('Post link copied'); + }); +}); diff --git a/apps/admin/src/posts/list/post-action-messages.ts b/apps/admin/src/posts/list/post-action-messages.ts new file mode 100644 index 00000000000..d211699794d --- /dev/null +++ b/apps/admin/src/posts/list/post-action-messages.ts @@ -0,0 +1,70 @@ +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * Toast wording for post actions, ported from the `messages` table and + * `#getToastMessage` in `apps/ember-admin/app/components/posts-list/context-menu.js`. + * + * A table rather than inline strings because these are the only feedback a bulk + * action gives, and the singular and plural forms differ in ways that aren't + * derivable from each other — `accessUpdated` reorders its clauses, and the two + * copy messages have no plural at all. + */ + +export type PostActionMessageKey = + | 'deleted' + | 'unpublished' + | 'unscheduled' + | 'accessUpdated' + | 'tagsAdded' + | 'tagAdded' + | 'duplicated' + | 'copiedPostUrl' + | 'copiedPreviewUrl'; + +interface MessageForms { + single: string; + /** Absent where Ember has no plural — the action is single-only. */ + multiple?: string; +} + +const MESSAGES: Record = { + deleted: {single: '{Type} deleted', multiple: '{count} {type}s deleted'}, + unpublished: {single: '{Type} reverted to a draft', multiple: '{count} {type}s reverted to drafts'}, + unscheduled: {single: '{Type} unscheduled', multiple: '{count} {type}s unscheduled'}, + // Leads with the type rather than the count, unlike every other plural. + accessUpdated: {single: '{Type} access updated', multiple: '{Type} access updated for {count} {type}s'}, + tagsAdded: {single: 'Tags added', multiple: 'Tags added to {count} {type}s'}, + tagAdded: {single: 'Tag added', multiple: 'Tag added to {count} {type}s'}, + duplicated: {single: '{Type} duplicated', multiple: '{count} {type}s duplicated'}, + // Hardcoded "Post" in Ember, even on a page. Ported as-is — it is a visible + // string, and correcting it here alone would make the two implementations + // disagree while the flag is still switchable. + copiedPostUrl: {single: 'Post link copied'}, + copiedPreviewUrl: {single: 'Preview link copied'} +}; + +export function getPostActionMessage( + key: PostActionMessageKey, + {count, resource, isSingle = count === 1}: { + count: number; + resource: PostResource; + /** + * Ember branches on `isSingle` here, not on the count — the same + * predicate the confirmation modal uses. Without it the modal can say + * "these posts" and the toast that follows say "Post deleted". + */ + isSingle?: boolean; + } +): string { + const forms = MESSAGES[key]; + // Falls back to the singular where Ember has no plural, rather than + // interpolating `undefined` into the toast as Ember would. + const template = (isSingle ? forms.single : forms.multiple) ?? forms.single; + + const type = resource === 'pages' ? 'page' : 'post'; + + return template + .replace(/\{Type\}/g, type.charAt(0).toUpperCase() + type.slice(1)) + .replace(/\{type\}/g, type) + .replace(/\{count\}/g, String(count)); +} diff --git a/apps/admin/src/posts/list/post-bulk-modal-copy.test.ts b/apps/admin/src/posts/list/post-bulk-modal-copy.test.ts new file mode 100644 index 00000000000..8c61f0dfaaa --- /dev/null +++ b/apps/admin/src/posts/list/post-bulk-modal-copy.test.ts @@ -0,0 +1,96 @@ +import {describe, expect, it} from 'vitest'; +import {getAccessModalTitle, getBulkConfirmCopy} from './post-bulk-modal-copy'; + +/** + * Wording for the three confirmation modals, ported from + * `apps/ember-admin/app/components/posts-list/modals/*.hbs`. + * + * A single post is named in quotes; several are counted. The count is the + * *selection* count, so after Cmd+A it reads the server total rather than the + * rows in memory. + */ +describe('getBulkConfirmCopy', () => { + it('names a single post in the delete confirmation', () => { + expect(getBulkConfirmCopy('delete', {count: 1, resource: 'posts', title: 'My post'})).toEqual({ + title: 'Are you sure you want to delete this post?', + body: 'You’re about to delete "My post". This is permanent! We warned you, k?', + confirmLabel: 'Delete', + runningLabel: 'Deleting' + }); + }); + + it('counts several posts rather than naming them', () => { + expect(getBulkConfirmCopy('delete', {count: 12, resource: 'posts'})).toEqual({ + title: 'Are you sure you want to delete these posts?', + body: 'You’re about to delete 12 posts. This is permanent! We warned you, k?', + confirmLabel: 'Delete', + runningLabel: 'Deleting' + }); + }); + + // Unpublish and unschedule share a body — both revert to a private draft — + // and neither carries the delete warning. + it('reverts rather than deletes when unpublishing', () => { + expect(getBulkConfirmCopy('unpublish', {count: 1, resource: 'posts', title: 'Live one'})).toEqual({ + title: 'Are you sure you want to unpublish this post?', + body: 'You’re about to revert "Live one" to a private draft.', + confirmLabel: 'Unpublish', + runningLabel: 'Unpublishing' + }); + }); + + it('uses the same body for unschedule', () => { + expect(getBulkConfirmCopy('unschedule', {count: 3, resource: 'posts'})).toEqual({ + title: 'Are you sure you want to unschedule these posts?', + body: 'You’re about to revert 3 posts to a private draft.', + confirmLabel: 'Unschedule', + runningLabel: 'Unscheduling' + }); + }); + + it('says page on the pages screen', () => { + expect(getBulkConfirmCopy('delete', {count: 2, resource: 'pages'}).title) + .toBe('Are you sure you want to delete these pages?'); + }); + + /** + * Ember decides singular from `isSingle` — one id selected *and not + * inverted* — not from the count. Cmd+A on a one-post view is an inverted + * selection of one, and Ember still says "these posts". Deriving it from + * the count alone would name a post the user may not have meant to. + */ + it('stays plural for an inverted selection of one', () => { + const copy = getBulkConfirmCopy('delete', { + count: 1, resource: 'posts', title: 'Only one', isSingle: false + }); + + expect(copy.title).toBe('Are you sure you want to delete these posts?'); + expect(copy.body).toBe('You’re about to delete 1 posts. This is permanent! We warned you, k?'); + }); + + // Ember's task button swaps to a present-participle while the request runs. + it('offers a running label for each action', () => { + expect(getBulkConfirmCopy('delete', {count: 1, resource: 'posts'}).runningLabel).toBe('Deleting'); + expect(getBulkConfirmCopy('unpublish', {count: 1, resource: 'posts'}).runningLabel).toBe('Unpublishing'); + expect(getBulkConfirmCopy('unschedule', {count: 1, resource: 'posts'}).runningLabel).toBe('Unscheduling'); + }); + +}); + +describe('getAccessModalTitle', () => { + it('names the resource for a single post', () => { + expect(getAccessModalTitle({count: 1, resource: 'posts', isSingle: true})) + .toBe('Change post access'); + }); + + // Ember appends the count only when the selection is not single. + it('counts the posts when several are selected', () => { + expect(getAccessModalTitle({count: 7, resource: 'posts', isSingle: false})) + .toBe('Change post access for 7 posts'); + }); + + it('says page on the pages screen', () => { + expect(getAccessModalTitle({count: 1, resource: 'pages', isSingle: true})) + .toBe('Change page access'); + }); +}); diff --git a/apps/admin/src/posts/list/post-bulk-modal-copy.ts b/apps/admin/src/posts/list/post-bulk-modal-copy.ts new file mode 100644 index 00000000000..bc0409ab793 --- /dev/null +++ b/apps/admin/src/posts/list/post-bulk-modal-copy.ts @@ -0,0 +1,69 @@ +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * Wording for the three confirmation modals, ported from + * `apps/ember-admin/app/components/posts-list/modals/{delete,unpublish,unschedule}-posts.hbs`. + */ + +export type BulkConfirmKey = 'delete' | 'unpublish' | 'unschedule'; + +interface CopyInputs { + /** The selection count — after Cmd+A, the server total. */ + count: number; + resource: PostResource; + /** The single post's title, used only when exactly one is selected. */ + title?: string; + /** + * Ember's `isSingle`: one id selected *and not inverted*. Cmd+A on a + * one-post view is an inverted selection of one, and Ember still says + * "these posts" — so this is not derivable from the count. Defaults to + * count === 1 for callers with no inverted selection to worry about. + */ + isSingle?: boolean; +} + +const LABELS: Record = { + delete: {confirm: 'Delete', running: 'Deleting'}, + unpublish: {confirm: 'Unpublish', running: 'Unpublishing'}, + unschedule: {confirm: 'Unschedule', running: 'Unscheduling'} +}; + +export function getBulkConfirmCopy( + key: BulkConfirmKey, + {count, resource, title, isSingle = count === 1}: CopyInputs +) { + const noun = resource === 'pages' ? 'page' : 'post'; + const subject = isSingle ? `this ${noun}` : `these ${noun}s`; + // A single post is named; several are counted. + const target = isSingle ? `"${title ?? ''}"` : `${count} ${noun}s`; + + // Unpublish and unschedule share a body — both revert to a private draft — + // and neither carries the permanence warning, because neither is permanent. + const body = key === 'delete' + ? `You’re about to delete ${target}. This is permanent! We warned you, k?` + : `You’re about to revert ${target} to a private draft.`; + + return { + title: `Are you sure you want to ${key} ${subject}?`, + body, + confirmLabel: LABELS[key].confirm, + runningLabel: LABELS[key].running + }; +} + +/** + * The Change access modal's heading, ported from `edit-posts-access.hbs`. The + * count is appended only when the selection is not single — same `isSingle` + * rule as the confirmations above, so an inverted selection of one still counts. + */ +export function getAccessModalTitle({count, resource, isSingle}: { + count: number; + resource: PostResource; + isSingle: boolean; +}): string { + const noun = resource === 'pages' ? 'page' : 'post'; + + return isSingle + ? `Change ${noun} access` + : `Change ${noun} access for ${count} ${noun}s`; +} diff --git a/apps/admin/src/posts/list/post-celebration-copy.test.ts b/apps/admin/src/posts/list/post-celebration-copy.test.ts new file mode 100644 index 00000000000..b45e2035b00 --- /dev/null +++ b/apps/admin/src/posts/list/post-celebration-copy.test.ts @@ -0,0 +1,62 @@ +import {describe, expect, it} from 'vitest'; +import {getCelebrationCopy} from './post-celebration-copy'; + +/** + * The celebration's headings, ported from the `

    ` in + * `apps/ember-admin/app/components/modal-post-success.hbs`. + * + * Two lines: a primary and a secondary. Which pair you get depends on whether + * the post was scheduled or published, whether it is a page, whether it was + * email-only, and whether a published count was fetched. + */ +describe('getCelebrationCopy', () => { + it('celebrates a scheduled post with a single line', () => { + expect(getCelebrationCopy({wasPublished: false, type: 'post'})).toEqual({ + primary: 'All set!', + secondary: '' + }); + }); + + it('counts the published posts when the count is known', () => { + expect(getCelebrationCopy({wasPublished: true, type: 'post', postCount: 47})).toEqual({ + primary: 'Boom! It’s out there.', + secondary: 'That’s 47 posts published.' + }); + }); + + // The count request is fired but not awaited before showing the modal, so + // this is the state the user sees first. + it('falls back to "Spread the word!" before the count lands', () => { + expect(getCelebrationCopy({wasPublished: true, type: 'post'})).toEqual({ + primary: 'Your post is published.', + secondary: 'Spread the word!' + }); + }); + + it('says so when the post was email-only', () => { + expect(getCelebrationCopy({wasPublished: true, type: 'post', emailOnly: true, postCount: 9}).secondary) + .toBe('Your email has been sent.'); + }); + + it('has its own line for a page, whatever the count says', () => { + expect(getCelebrationCopy({wasPublished: true, type: 'page', postCount: 9}).secondary) + .toBe('Your page is published.'); + }); + + it('says "post" rather than "posts" for the first one', () => { + expect(getCelebrationCopy({wasPublished: true, type: 'post', postCount: 1}).secondary) + .toBe('That’s 1 post published.'); + }); + + it('groups the digits of a large count', () => { + expect(getCelebrationCopy({wasPublished: true, type: 'post', postCount: 1234}).secondary) + .toBe('That’s 1,234 posts published.'); + }); + + // A scheduled page is still just "All set!" — the scheduled branch comes + // first in Ember's template and swallows every other distinction. + it('ignores everything else when scheduled', () => { + expect(getCelebrationCopy({wasPublished: false, type: 'page', emailOnly: true, postCount: 9})) + .toEqual({primary: 'All set!', secondary: ''}); + }); +}); diff --git a/apps/admin/src/posts/list/post-celebration-copy.ts b/apps/admin/src/posts/list/post-celebration-copy.ts new file mode 100644 index 00000000000..7f4067657e5 --- /dev/null +++ b/apps/admin/src/posts/list/post-celebration-copy.ts @@ -0,0 +1,46 @@ +import {formatNumber} from '@tryghost/shade/utils'; + +/** + * Headings for the post-publish celebration, ported from the `

    ` in + * `apps/ember-admin/app/components/modal-post-success.hbs`. + * + * A scheduled post gets one line; everything else gets two, and which second + * line you get depends on the resource, whether it was email-only, and whether + * the published count was fetched in time. + */ + +export interface CelebrationCopyInputs { + wasPublished: boolean; + /** 'post' or 'page', as the editor wrote it. */ + type: string; + emailOnly?: boolean; + /** Total published posts. Absent if the count request hasn't landed. */ + postCount?: number; +} + +export function getCelebrationCopy({ + wasPublished, type, emailOnly, postCount +}: CelebrationCopyInputs): {primary: string; secondary: string} { + if (!wasPublished) { + return {primary: 'All set!', secondary: ''}; + } + + const showCount = typeof postCount === 'number'; + const primary = showCount ? 'Boom! It’s out there.' : 'Your post is published.'; + + if (type === 'page') { + return {primary, secondary: 'Your page is published.'}; + } + + if (emailOnly) { + return {primary, secondary: 'Your email has been sent.'}; + } + + if (showCount) { + const noun = postCount === 1 ? 'post' : 'posts'; + + return {primary, secondary: `That’s ${formatNumber(postCount)} ${noun} published.`}; + } + + return {primary, secondary: 'Spread the word!'}; +} diff --git a/apps/admin/src/posts/list/post-context-menu-items.test.ts b/apps/admin/src/posts/list/post-context-menu-items.test.ts new file mode 100644 index 00000000000..b2840291efd --- /dev/null +++ b/apps/admin/src/posts/list/post-context-menu-items.test.ts @@ -0,0 +1,222 @@ +import {describe, expect, it} from 'vitest'; +import {getPostContextMenuItems, type PostContextMenuInputs} from './post-context-menu-items'; +import type {PostListItem} from './hooks/use-posts-list'; + +/** + * Which items the right-click menu shows, ported from the template and the five + * predicates in `apps/ember-admin/app/components/posts-list/context-menu.js`. + * + * The rule that makes this worth its own module: the menu describes the **whole + * selection**, not the row that was right-clicked. Ember's predicates all loop + * over every selected model, and most of them are "any" rather than "every" — + * so one published post among five drafts is enough to offer Unpublish. + */ + +const post = (overrides: Partial = {}): PostListItem => ({ + id: 'p1', uuid: 'u1', url: 'https://example.com/p', slug: 'p', title: 'A post', status: 'draft', ...overrides +}); + +const inputs = ( + posts: PostListItem[], + overrides: Partial = {} +): PostContextMenuInputs => ({ + posts, + resource: 'posts', + isAdmin: true, + membersEnabled: true, + canCopyGiftLink: false, + ...overrides +}); + +const keys = (input: PostContextMenuInputs) => getPostContextMenuItems(input).map(item => item.key); + +describe('getPostContextMenuItems', () => { + describe('for a single draft', () => { + it('offers the preview link, not the public link', () => { + const items = keys(inputs([post({status: 'draft'})])); + + expect(items).toContain('copy-preview'); + expect(items).not.toContain('copy-link'); + }); + + it('offers no unpublish or unschedule', () => { + const items = keys(inputs([post({status: 'draft'})])); + + expect(items).not.toContain('unpublish'); + expect(items).not.toContain('unschedule'); + }); + }); + + describe('for a single published post', () => { + it('offers the public link and unpublish', () => { + const items = keys(inputs([post({status: 'published'})])); + + expect(items).toContain('copy-link'); + expect(items).toContain('unpublish'); + }); + + // The template nests the preview link in the `else` of `canUnpublish`, + // so a published post never offers both. + it('does not also offer the preview link', () => { + expect(keys(inputs([post({status: 'published'})]))).not.toContain('copy-preview'); + }); + }); + + it('offers unschedule for a scheduled post', () => { + const items = keys(inputs([post({status: 'scheduled'})])); + + expect(items).toContain('unschedule'); + expect(items).toContain('copy-preview'); + }); + + describe('across a mixed selection', () => { + // Every status predicate is "any", not "every". + it('offers unpublish when only one of several is published', () => { + const items = keys(inputs([ + post({id: 'a', status: 'draft'}), + post({id: 'b', status: 'draft'}), + post({id: 'c', status: 'published'}) + ])); + + expect(items).toContain('unpublish'); + }); + + it('offers unschedule when a scheduled post sits among drafts', () => { + const items = keys(inputs([ + post({id: 'a', status: 'draft'}), + post({id: 'b', status: 'scheduled'}) + ])); + + expect(items).toContain('unschedule'); + }); + + // `canCopySelection` is length === 1 — these are single-post actions. + it('drops the per-post actions once more than one row is selected', () => { + const items = keys(inputs([ + post({id: 'a', status: 'published'}), + post({id: 'b', status: 'published'}) + ])); + + expect(items).not.toContain('copy-link'); + expect(items).not.toContain('copy-preview'); + expect(items).not.toContain('duplicate'); + }); + }); + + describe('feature and unfeature', () => { + // The flip is `featured <= length / 2` — the menu offers whichever + // action would affect the majority. + it('offers Feature when none are featured', () => { + expect(keys(inputs([post({featured: false})]))).toContain('feature'); + }); + + it('offers Unfeature when all are featured', () => { + expect(keys(inputs([post({featured: true})]))).toContain('unfeature'); + }); + + it('offers Feature at exactly half, where the comparison is inclusive', () => { + const items = keys(inputs([ + post({id: 'a', featured: true}), + post({id: 'b', featured: false}) + ])); + + expect(items).toContain('feature'); + expect(items).not.toContain('unfeature'); + }); + + it('offers Unfeature once featured posts are the majority', () => { + const items = keys(inputs([ + post({id: 'a', featured: true}), + post({id: 'b', featured: true}), + post({id: 'c', featured: false}) + ])); + + expect(items).toContain('unfeature'); + }); + + // A sent post can't be featured, so a selection of only sent posts + // offers neither. + it('offers neither for a selection of only sent posts', () => { + const items = keys(inputs([post({status: 'sent'})])); + + expect(items).not.toContain('feature'); + expect(items).not.toContain('unfeature'); + }); + + it('still offers them when one non-sent post is in the selection', () => { + const items = keys(inputs([ + post({id: 'a', status: 'sent'}), + post({id: 'b', status: 'draft'}) + ])); + + expect(items).toContain('feature'); + }); + }); + + describe('role and site configuration', () => { + it('hides Delete from anyone who is not an admin', () => { + expect(keys(inputs([post()], {isAdmin: false}))).not.toContain('delete'); + }); + + it('offers Delete to an admin', () => { + expect(keys(inputs([post()]))).toContain('delete'); + }); + + it('hides Change access when memberships are off', () => { + expect(keys(inputs([post()], {membersEnabled: false}))).not.toContain('change-access'); + }); + + it('shows the gift link only when the caller says it is eligible', () => { + const eligible = inputs([post({status: 'published'})], {canCopyGiftLink: true}); + + expect(keys(eligible)).toContain('gift-link'); + expect(keys(inputs([post({status: 'published'})]))).not.toContain('gift-link'); + }); + }); + + // Add a tag is the only item with no condition at all. + it('always offers Add a tag', () => { + expect(keys(inputs([post()], {isAdmin: false, membersEnabled: false}))).toContain('add-tag'); + }); + + it('is empty when nothing is selected', () => { + expect(keys(inputs([]))).toEqual([]); + }); + + it('orders the items as the Ember template does', () => { + const items = keys(inputs([post({status: 'published', featured: false})], {canCopyGiftLink: true})); + + expect(items).toEqual([ + 'copy-link', + 'gift-link', + 'unpublish', + 'feature', + 'add-tag', + 'change-access', + 'duplicate', + 'delete' + ]); + }); + + // Ember puts a separator above Unpublish only when the gift link is + // there — and whether it renders is decided per row, after this list is + // built. The menu draws that rule from adjacency, so Unpublish must not + // carry a flag that survives the gift link being filtered out. + it('leaves the gift-link separator to the menu, not the Unpublish item', () => { + const withGift = getPostContextMenuItems( + inputs([post({status: 'published'})], {canCopyGiftLink: true}) + ); + const withoutGift = getPostContextMenuItems(inputs([post({status: 'published'})])); + + expect(withGift.find(item => item.key === 'unpublish')?.separated).toBe(false); + expect(withoutGift.find(item => item.key === 'unpublish')?.separated).toBe(false); + }); + + // Ember hardcodes the noun here, as it does in the matching toast. Ported + // rather than corrected, so the two implementations read alike. + it('still says "post" on a page, as Ember does', () => { + const items = getPostContextMenuItems(inputs([post({status: 'published'})], {resource: 'pages'})); + + expect(items.find(item => item.key === 'copy-link')?.label).toBe('Copy link to post'); + }); +}); diff --git a/apps/admin/src/posts/list/post-context-menu-items.ts b/apps/admin/src/posts/list/post-context-menu-items.ts new file mode 100644 index 00000000000..ee0b591518b --- /dev/null +++ b/apps/admin/src/posts/list/post-context-menu-items.ts @@ -0,0 +1,143 @@ +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * Which items the right-click menu shows for the current selection. + * + * Ported from `context-menu.hbs` and the predicates at the bottom of + * `apps/ember-admin/app/components/posts-list/context-menu.js`. + * + * The rule worth holding on to: **the menu describes the whole selection**, not + * the row that was right-clicked. Ember's status predicates are all "any" + * rather than "every", so one published post among five drafts is enough to + * offer Unpublish — the action then applies to whichever of the selection it + * can apply to. Getting this backwards would quietly hide actions from mixed + * selections, which is most of them. + */ + +export type PostContextMenuKey = + | 'copy-link' + | 'copy-preview' + | 'gift-link' + | 'unpublish' + | 'unschedule' + | 'feature' + | 'unfeature' + | 'add-tag' + | 'change-access' + | 'duplicate' + | 'delete'; + +export interface PostContextMenuItem { + key: PostContextMenuKey; + label: string; + /** Whether a separator sits above this item. */ + separated: boolean; + destructive?: boolean; +} + +export interface PostContextMenuInputs { + /** + * The selected posts that are actually loaded — Ember's `availableModels`. + * An inverted selection can cover rows that were never fetched, so the menu + * necessarily reasons about the ones in memory. + */ + posts: PostListItem[]; + /** Unused by the item list today — every label Ember emits here is + * hardcoded to "post" — but kept so callers pass a complete description of + * the selection, and for the Phase 8 modals. */ + resource: PostResource; + /** Owner or Administrator. Only they may delete. */ + isAdmin: boolean; + membersEnabled: boolean; + /** Decided by the shared gift-link rules, which need the current user. */ + canCopyGiftLink: boolean; +} + +/** `canCopySelection` — the single-post actions. */ +function isSingle(posts: PostListItem[]): boolean { + return posts.length === 1; +} + +function hasStatus(posts: PostListItem[], status: string): boolean { + return posts.some(post => post.status === status); +} + +/** + * `shouldFeatureSelection`. The menu offers whichever action would affect the + * majority, and the comparison is inclusive — at exactly half featured, it + * still offers Feature. + */ +function shouldFeature(posts: PostListItem[]): boolean { + const featured = posts.filter(post => post.featured).length; + + return featured <= posts.length / 2; +} + +export function getPostContextMenuItems(inputs: PostContextMenuInputs): PostContextMenuItem[] { + const {posts, isAdmin, membersEnabled, canCopyGiftLink} = inputs; + + if (posts.length === 0) { + return []; + } + + const items: PostContextMenuItem[] = []; + const add = (key: PostContextMenuKey, label: string, extra: Partial = {}) => { + items.push({key, label, separated: false, ...extra}); + }; + + // The template makes these two branches exclusive: a selection containing + // anything published offers the public link, and never the preview link. + if (hasStatus(posts, 'published')) { + if (isSingle(posts)) { + // "post" on both resources, as Ember hardcodes it — matching the + // "Post link copied" toast, which is hardcoded the same way. + add('copy-link', 'Copy link to post'); + } + + if (canCopyGiftLink) { + add('gift-link', 'Share as a gift'); + } + + // Ember separates Unpublish from the gift link above it, and only + // then — but whether the gift link renders is decided per row, after + // this list is built, so the menu draws that rule from adjacency. + add('unpublish', 'Unpublish'); + } else { + if (isSingle(posts)) { + add('copy-preview', 'Copy preview link'); + } + + if (hasStatus(posts, 'scheduled')) { + add('unschedule', 'Unschedule'); + } + } + + // A sent post can no longer be featured, so a selection of only sent posts + // offers neither action. + if (posts.some(post => post.status !== 'sent')) { + if (shouldFeature(posts)) { + add('feature', 'Feature'); + } else { + add('unfeature', 'Unfeature'); + } + } + + // The only item with no condition on it at all. + add('add-tag', 'Add a tag'); + + if (membersEnabled) { + add('change-access', 'Change access'); + } + + if (isSingle(posts)) { + add('duplicate', 'Duplicate'); + } + + // Set apart from the rest: it is the one item here that cannot be undone. + if (isAdmin) { + add('delete', 'Delete', {destructive: true, separated: true}); + } + + return items; +} diff --git a/apps/admin/src/posts/list/post-filter-fields.test.ts b/apps/admin/src/posts/list/post-filter-fields.test.ts new file mode 100644 index 00000000000..d26baedf8b0 --- /dev/null +++ b/apps/admin/src/posts/list/post-filter-fields.test.ts @@ -0,0 +1,65 @@ +import {describe, expect, it} from 'vitest'; +import {ORDER_OPTIONS, VISIBILITY_OPTIONS, getOrderLabel, getTypeOptions} from './post-filter-fields'; +import {getStatusesForType} from './post-query-params'; + +describe('getTypeOptions', () => { + it('offers posts the five Ember types', () => { + expect(getTypeOptions('posts').map(option => option.value)) + .toEqual(['draft', 'published', 'sent', 'scheduled', 'featured']); + }); + + // Pages are never emailed. + it('drops "email only" for pages', () => { + expect(getTypeOptions('pages').map(option => option.value)) + .toEqual(['draft', 'published', 'scheduled', 'featured']); + }); + + it('labels the options for the resource', () => { + expect(getTypeOptions('posts')[0].label).toBe('Draft posts'); + expect(getTypeOptions('pages')[0].label).toBe('Draft pages'); + }); + + // If these drift apart, a filter the UI offers would resolve to the wrong + // statuses - or to all of them, silently. + it('only offers types the query layer understands', () => { + const known = ['draft', 'published', 'sent', 'scheduled']; + + getTypeOptions('posts').forEach((option) => { + if (option.value === 'featured') { + expect(getStatusesForType(option.value)).toHaveLength(4); + return; + } + + expect(known).toContain(option.value); + expect(getStatusesForType(option.value)).toEqual([option.value]); + }); + }); +}); + +describe('VISIBILITY_OPTIONS', () => { + it('carries the paid+tiers value as a single opaque string', () => { + expect(VISIBILITY_OPTIONS.map(option => option.value)) + .toEqual(['public', 'members', '[paid,tiers]']); + }); +}); + +describe('getOrderLabel', () => { + // "Newest first" is the absence of an order param, not a value. + it('names the default when no order is set', () => { + expect(getOrderLabel(null)).toBe('Newest first'); + expect(getOrderLabel(undefined)).toBe('Newest first'); + }); + + it('names the known orders', () => { + expect(getOrderLabel('published_at asc')).toBe('Oldest first'); + expect(getOrderLabel('updated_at desc')).toBe('Recently updated'); + }); + + it('shows an unrecognised order rather than hiding it', () => { + expect(getOrderLabel('title asc')).toBe('title asc'); + }); + + it('has an entry for every non-default order', () => { + expect(ORDER_OPTIONS).toHaveLength(2); + }); +}); diff --git a/apps/admin/src/posts/list/post-filter-fields.ts b/apps/admin/src/posts/list/post-filter-fields.ts new file mode 100644 index 00000000000..841e67b6f9e --- /dev/null +++ b/apps/admin/src/posts/list/post-filter-fields.ts @@ -0,0 +1,66 @@ +import type {PostResource} from './post-resource'; + +/** + * The values each filter and the sort can take, ported verbatim from + * `apps/ember-admin/app/controllers/posts.js` and `controllers/pages.js`. + * + * Data only - the Shade field config that renders these (icons, async value + * sources for author and tag) is built on top in the filters UI. + */ + +export interface PostFilterOption { + value: string; + label: string; +} + +/** + * `featured` sits in the same list as the statuses even though it isn't one - + * it means "every status, and featured". The URL schema can't express + * "draft AND featured", so splitting this into a status field plus a featured + * toggle would produce URLs the Ember screen renders as "Unknown". + */ +const POST_TYPE_OPTIONS: PostFilterOption[] = [ + {value: 'draft', label: 'Draft posts'}, + {value: 'published', label: 'Published posts'}, + {value: 'sent', label: 'Email only posts'}, + {value: 'scheduled', label: 'Scheduled posts'}, + {value: 'featured', label: 'Featured posts'} +]; + +/** Pages are never emailed, so they have no "Email only". */ +const PAGE_TYPE_OPTIONS: PostFilterOption[] = [ + {value: 'draft', label: 'Draft pages'}, + {value: 'published', label: 'Published pages'}, + {value: 'scheduled', label: 'Scheduled pages'}, + {value: 'featured', label: 'Featured pages'} +]; + +/** + * `[paid,tiers]` is an opaque option value, not structure - Ember interpolates + * it straight into the filter string. + */ +export const VISIBILITY_OPTIONS: PostFilterOption[] = [ + {value: 'public', label: 'Public'}, + {value: 'members', label: 'Members-only'}, + {value: '[paid,tiers]', label: 'Paid members-only'} +]; + +/** "Newest first" is the absence of an `order` param, so it has no entry. */ +export const ORDER_OPTIONS: PostFilterOption[] = [ + {value: 'published_at asc', label: 'Oldest first'}, + {value: 'updated_at desc', label: 'Recently updated'} +]; + +export const DEFAULT_ORDER_LABEL = 'Newest first'; + +export function getTypeOptions(resource: PostResource): PostFilterOption[] { + return resource === 'pages' ? PAGE_TYPE_OPTIONS : POST_TYPE_OPTIONS; +} + +export function getOrderLabel(order?: string | null): string { + if (!order) { + return DEFAULT_ORDER_LABEL; + } + + return ORDER_OPTIONS.find(option => option.value === order)?.label ?? order; +} diff --git a/apps/admin/src/posts/list/post-filter-query.test.ts b/apps/admin/src/posts/list/post-filter-query.test.ts new file mode 100644 index 00000000000..72febef298e --- /dev/null +++ b/apps/admin/src/posts/list/post-filter-query.test.ts @@ -0,0 +1,118 @@ +import {describe, expect, it} from 'vitest'; +import {POST_FILTER_PARAMS, parsePostFilters, serializePostFilters} from './post-filter-query'; +import type {Filter} from '@tryghost/shade/patterns'; + +/** Ids are asserted separately (they only have to be unique). */ +function withoutIds(filters: Filter[]): Array> { + return filters.map(({id: _id, ...rest}) => rest); +} + +// The posts screen is addressed by five discrete URL params rather than one NQL +// string, because sidebar saved views persist exactly that shape and must keep +// working across both the Ember and React implementations. These tests pin the +// round-trip. + +describe('POST_FILTER_PARAMS', () => { + // `order` is a sort, not a filter - it has no operator and would render as + // a nonsense chip ("Sort is Newest first"), so it lives outside this model. + it('covers the four filterable params and not order', () => { + expect(POST_FILTER_PARAMS).toEqual(['type', 'visibility', 'author', 'tag']); + }); +}); + +describe('parsePostFilters', () => { + it('returns nothing when no params are set', () => { + expect(parsePostFilters({})).toEqual([]); + expect(parsePostFilters({type: null, visibility: null, author: null, tag: null})).toEqual([]); + }); + + it('turns a param into a single-value "is" filter', () => { + expect(withoutIds(parsePostFilters({type: 'draft'}))).toEqual([ + {field: 'type', operator: 'is', values: ['draft']} + ]); + }); + + it('emits filters in a stable param order regardless of input order', () => { + const filters = parsePostFilters({tag: 'news', type: 'draft', author: 'jo', visibility: 'paid'}); + + expect(filters.map(filter => filter.field)).toEqual(['type', 'visibility', 'author', 'tag']); + }); + + it('gives every filter a distinct id', () => { + const filters = parsePostFilters({type: 'draft', tag: 'news'}); + const ids = filters.map(filter => filter.id); + + expect(new Set(ids).size).toBe(ids.length); + }); + + it('ignores empty strings', () => { + expect(parsePostFilters({type: ''})).toEqual([]); + }); + + // A saved view can point at a tag that was later renamed, or at a value a + // newer Ember build understands. Dropping it would silently rewrite the + // user's URL and corrupt their view. + it('keeps values it does not recognise', () => { + expect(withoutIds(parsePostFilters({type: 'nonsense', tag: 'deleted-tag'}))).toEqual([ + {field: 'type', operator: 'is', values: ['nonsense']}, + {field: 'tag', operator: 'is', values: ['deleted-tag']} + ]); + }); + + it('treats the paid+tiers visibility value as one opaque value', () => { + expect(withoutIds(parsePostFilters({visibility: '[paid,tiers]'}))).toEqual([ + {field: 'visibility', operator: 'is', values: ['[paid,tiers]']} + ]); + }); +}); + +describe('serializePostFilters', () => { + it('nulls every param when there are no filters', () => { + expect(serializePostFilters([])).toEqual({ + type: null, visibility: null, author: null, tag: null + }); + }); + + it('writes a filter value back to its param', () => { + expect(serializePostFilters([ + {id: 'type:1', field: 'type', operator: 'is', values: ['draft']} + ])).toEqual({type: 'draft', visibility: null, author: null, tag: null}); + }); + + it('nulls a param whose filter has no value yet', () => { + // Shade creates a filter as soon as a field is picked, before a value. + expect(serializePostFilters([ + {id: 'type:1', field: 'type', operator: 'is', values: []} + ])).toEqual({type: null, visibility: null, author: null, tag: null}); + }); + + it('ignores fields that are not URL params', () => { + expect(serializePostFilters([ + {id: 'order:1', field: 'order', operator: 'is', values: ['published_at asc']} + ])).toEqual({type: null, visibility: null, author: null, tag: null}); + }); + + it('takes the last value when a field somehow appears twice', () => { + expect(serializePostFilters([ + {id: 'type:1', field: 'type', operator: 'is', values: ['draft']}, + {id: 'type:2', field: 'type', operator: 'is', values: ['published']} + ])).toMatchObject({type: 'published'}); + }); +}); + +describe('round-tripping', () => { + it.each([ + {}, + {type: 'draft'}, + {type: 'featured'}, + {visibility: '[paid,tiers]'}, + {type: 'scheduled', visibility: 'members', author: 'jo', tag: 'news'}, + {type: 'nonsense', tag: 'deleted-tag'} + ])('survives parse then serialize: %j', (params) => { + const expected = { + type: null, visibility: null, author: null, tag: null, ...params + }; + + expect(serializePostFilters(parsePostFilters(params))).toEqual(expected); + }); +}); diff --git a/apps/admin/src/posts/list/post-filter-query.ts b/apps/admin/src/posts/list/post-filter-query.ts new file mode 100644 index 00000000000..0706c6d4c3a --- /dev/null +++ b/apps/admin/src/posts/list/post-filter-query.ts @@ -0,0 +1,90 @@ +import type {Filter} from '@tryghost/shade/patterns'; +import type {PostListParams} from './post-query-params'; + +/** + * Bridges the posts/pages URL params and the Shade `Filters` chip model. + * + * Unlike members, which round-trips a single NQL `?filter=` string, posts are + * addressed by discrete params (`?type=draft&tag=news`). That shape is fixed: + * sidebar saved views persist exactly it, and the Ember and React screens have + * to agree on it while both exist. So this is a small dedicated codec rather + * than a use of `@/shared/filters`' NQL engine - only `stampPredicates` is + * shared. + * + * `order` is deliberately absent: it is a sort, not a filter, so it has no + * operator and would read as a nonsense chip. It is carried alongside these. + */ + +export const POST_FILTER_PARAMS = ['type', 'visibility', 'author', 'tag'] as const; + +export type PostFilterParam = (typeof POST_FILTER_PARAMS)[number]; + +export type PostFilterParamValues = Record; + +const EMPTY_PARAMS: PostFilterParamValues = { + type: null, + visibility: null, + author: null, + tag: null +}; + +/** These fields are single-select equality; Ember offers nothing else. */ +const OPERATOR = 'is'; + +function isFilterParam(field: string): field is PostFilterParam { + return (POST_FILTER_PARAMS as readonly string[]).includes(field); +} + +/** + * Values are carried through verbatim, including ones we don't recognise: a + * saved view may point at a since-renamed tag, or at a value only a newer + * build understands. Dropping it would silently rewrite the user's URL. + */ +export function parsePostFilters(params: PostListParams): Filter[] { + return POST_FILTER_PARAMS.flatMap((param, index) => { + const value = params[param]; + + if (value === null || value === undefined || value === '') { + return []; + } + + // Ids only have to be unique and stable for a given params record; + // the param name already is. + return [{id: `${param}:${index + 1}`, field: param, operator: OPERATOR, values: [value]}]; + }); +} + +/** + * URL params are strings. Anything else a chip might carry is not + * representable in the URL, so it clears the param rather than serialising as + * "[object Object]". + */ +function toParamValue(value: unknown): string | null { + if (typeof value === 'string') { + return value === '' ? null : value; + } + + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + + return null; +} + +/** + * The inverse. A filter with no value yet - Shade creates one as soon as a + * field is picked - clears its param rather than writing an empty one. + */ +export function serializePostFilters(filters: Filter[]): PostFilterParamValues { + const params: PostFilterParamValues = {...EMPTY_PARAMS}; + + filters.forEach((filter) => { + if (!isFilterParam(filter.field)) { + return; + } + + params[filter.field] = toParamValue(filter.values[0]); + }); + + return params; +} diff --git a/apps/admin/src/posts/list/post-metric-icons.ts b/apps/admin/src/posts/list/post-metric-icons.ts new file mode 100644 index 00000000000..d4e05bf241f --- /dev/null +++ b/apps/admin/src/posts/list/post-metric-icons.ts @@ -0,0 +1,25 @@ +import {LucideIcon} from '@tryghost/shade/utils'; +import type {PostMetricKey} from '@/posts/list/post-metrics'; +import type {PostMetricRowIcon} from '@/posts/list/post-metric-tooltips'; + +/** + * One icon per metric, shared by the row's columns and the hover panel. + * + * Ember draws the same icon in both places — `{{svg-jar "analytics-opens"}}` + * appears in the column and in the panel beneath it — so they live in one map + * here rather than one per component, where they could drift apart. + * + * These are not approximations of Ember's icons: `app/assets/icons/analytics-*` + * are Lucide icons exported to SVG, and each entry below is the same icon its + * file carries the class of. `analytics-paid-members.svg` is + * `lucide-wallet-cards`, which is the one that would never have been guessed. + */ +export const POST_METRIC_ICONS: Record = { + visitors: LucideIcon.Globe, + opens: LucideIcon.MailOpen, + clicks: LucideIcon.MousePointerClick, + sent: LucideIcon.Send, + members: LucideIcon.UserPlus, + free: LucideIcon.User, + paid: LucideIcon.WalletCards +}; diff --git a/apps/admin/src/posts/list/post-metric-tooltips.test.ts b/apps/admin/src/posts/list/post-metric-tooltips.test.ts new file mode 100644 index 00000000000..c042e403207 --- /dev/null +++ b/apps/admin/src/posts/list/post-metric-tooltips.test.ts @@ -0,0 +1,60 @@ +import {describe, expect, it} from 'vitest'; +import {getPostMetricTooltip} from './post-metric-tooltips'; +import type {PostListItem} from './hooks/use-posts-list'; + +const post = (overrides: Partial = {}): PostListItem => ({ + id: 'p1', uuid: 'u1', url: 'u', slug: 'p', title: 'A post', status: 'published', ...overrides +}); + +const emailed = post({ + email: {status: 'submitted', email_count: 200, opened_count: 100}, + count: {clicks: 20} +}); + +const base = {showOpens: true, showClicks: true}; + +describe('getPostMetricTooltip', () => { + it('titles the visitor tooltip "Web traffic"', () => { + expect(getPostMetricTooltip('visitors', post(), {...base, visitors: 42})).toEqual({ + title: 'Web traffic', + rows: [{label: 'Unique visitors', value: 42, icon: 'visitors'}] + }); + }); + + // All three email columns share one panel in Ember. + // + // The values are the raw counts, not the rates the columns show. The + // fixture is chosen so the two are distinguishable: the Opens column reads + // 50% while the panel reads 100, and Clicks reads 10% against 20. + it.each(['opens', 'clicks', 'sent'] as const)('shows the same newsletter panel for %s', (key) => { + expect(getPostMetricTooltip(key, emailed, base)).toEqual({ + title: 'Newsletter performance', + rows: [ + {label: 'Sent', value: 200, icon: 'sent'}, + {label: 'Opens', value: 100, icon: 'opens'}, + {label: 'Clicks', value: 20, icon: 'clicks'} + ] + }); + }); + + it('lists only what is being tracked', () => { + expect(getPostMetricTooltip('sent', emailed, {showOpens: false, showClicks: false}).rows) + .toEqual([{label: 'Sent', value: 200, icon: 'sent'}]); + }); + + it('breaks new members into free and paid', () => { + expect(getPostMetricTooltip('members', post(), { + ...base, freeMembers: 69, paidMembers: 19, paidMembersEnabled: true + })).toEqual({ + title: 'New members', + rows: [{label: 'Free', value: 69, icon: 'free'}, {label: 'Paid', value: 19, icon: 'paid'}] + }); + }); + + // Ember drops the row entirely rather than showing a zero. + it('omits paid when paid members are off', () => { + expect(getPostMetricTooltip('members', post(), { + ...base, freeMembers: 69, paidMembersEnabled: false + }).rows).toEqual([{label: 'Free', value: 69, icon: 'free'}]); + }); +}); diff --git a/apps/admin/src/posts/list/post-metric-tooltips.ts b/apps/admin/src/posts/list/post-metric-tooltips.ts new file mode 100644 index 00000000000..ce9f48c578d --- /dev/null +++ b/apps/admin/src/posts/list/post-metric-tooltips.ts @@ -0,0 +1,80 @@ +import type {PostMetricKey} from '@/posts/list/post-metrics'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; + +/** + * The breakdown shown when hovering a metric column, ported from the tooltip + * markup in `list-item-analytics.hbs`. + * + * The three email columns share one tooltip — Ember shows the same "Newsletter + * performance" panel with Sent, Opens and Clicks whichever of them you hover, + * and it lists Opens/Clicks only when those are being tracked. + * + * The panel shows **raw counts**, not the rates the columns show: the Opens + * column reads "78%" while the panel underneath it reads "Opens 572". Showing + * the rate in both places would be a plausible-looking lie. + */ + +/** + * Which icon a row carries. A name rather than a component, so this module + * stays plain TypeScript — it is the one the unit tests lean on — and the + * component owns the mapping to actual icons. + */ +export type PostMetricRowIcon = 'visitors' | 'sent' | 'opens' | 'clicks' | 'free' | 'paid'; + +export interface PostMetricTooltipRow { + label: string; + value: number; + icon: PostMetricRowIcon; +} + +export interface PostMetricTooltipContent { + title: string; + rows: PostMetricTooltipRow[]; +} + +export interface TooltipInputs { + visitors?: number; + freeMembers?: number; + paidMembers?: number; + /** Whether the site shows paid figures at all. */ + paidMembersEnabled?: boolean; + showOpens: boolean; + showClicks: boolean; +} + +export function getPostMetricTooltip( + key: PostMetricKey, + post: PostListItem, + inputs: TooltipInputs +): PostMetricTooltipContent { + if (key === 'visitors') { + return { + title: 'Web traffic', + rows: [{label: 'Unique visitors', value: inputs.visitors ?? 0, icon: 'visitors'}] + }; + } + + if (key === 'opens' || key === 'clicks' || key === 'sent') { + const sent = post.email?.email_count ?? 0; + + return { + title: 'Newsletter performance', + rows: [ + {label: 'Sent', value: sent, icon: 'sent'}, + ...(inputs.showOpens ? [{label: 'Opens', value: post.email?.opened_count ?? 0, icon: 'opens' as const}] : []), + ...(inputs.showClicks ? [{label: 'Clicks', value: post.count?.clicks ?? 0, icon: 'clicks' as const}] : []) + ] + }; + } + + // `members` — and the only remaining key, so this is the total case rather + // than a branch with a null fallback. + return { + title: 'New members', + rows: [ + {label: 'Free', value: inputs.freeMembers ?? 0, icon: 'free'}, + // Ember hides the paid row entirely when paid members are off. + ...(inputs.paidMembersEnabled ? [{label: 'Paid', value: inputs.paidMembers ?? 0, icon: 'paid' as const}] : []) + ] + }; +} diff --git a/apps/admin/src/posts/list/post-metrics.test.ts b/apps/admin/src/posts/list/post-metrics.test.ts new file mode 100644 index 00000000000..aee3315e516 --- /dev/null +++ b/apps/admin/src/posts/list/post-metrics.test.ts @@ -0,0 +1,244 @@ +import {describe, expect, it} from 'vitest'; +import {getPostMetricColumns, hasPostAnalyticsPage, type PostMetricsSettings} from './post-metrics'; +import type {PostListItem} from './hooks/use-posts-list'; + +/** + * Which metric columns a row shows. Ported from `list-item-analytics.hbs` and + * the `showEmail*Analytics` / `showAttributionAnalytics` computeds in + * `apps/ember-admin/app/models/post.js`. + * + * Genuinely fiddly, and invisible unless you happen to have a site configured + * the wrong way — hence the exhaustive table. + */ + +const settings = (overrides: Partial = {}): PostMetricsSettings => ({ + webAnalyticsEnabled: false, + membersTrackSources: false, + emailTrackOpens: false, + emailTrackClicks: false, + membersSignupAccess: 'all', + isMembersInviteOnly: false, + isContributor: false, + ...overrides +}); + +const post = (overrides: Partial = {}): PostListItem => ({ + id: 'p1', + uuid: 'u1', + url: 'https://example.com/p', + slug: 'p', + title: 'A post', + status: 'published', + ...overrides +}); + +const emailed = (over: Partial = {}) => post({ + email: {status: 'submitted', email_count: 100, opened_count: 40, track_opens: true, track_clicks: true}, + ...over +}); + +const keys = (...args: Parameters) => + getPostMetricColumns(...args).map(column => column.key); + +describe('the Visitors column', () => { + it('shows for a published post when web analytics is on', () => { + expect(keys(post(), settings({webAnalyticsEnabled: true}), 'posts')).toContain('visitors'); + }); + + it('hides when web analytics is off', () => { + expect(keys(post(), settings(), 'posts')).not.toContain('visitors'); + }); + + it('hides for a draft', () => { + expect(keys(post({status: 'draft'}), settings({webAnalyticsEnabled: true}), 'posts')) + .not.toContain('visitors'); + }); + + // Strictly `published`, so an email-only post has no web traffic column. + it('hides for an email-only post', () => { + expect(keys(post({status: 'sent'}), settings({webAnalyticsEnabled: true}), 'posts')) + .not.toContain('visitors'); + }); +}); + +describe('the email columns', () => { + it('shows Opens when both the post and the site track opens', () => { + expect(keys(emailed(), settings({emailTrackOpens: true}), 'posts')).toContain('opens'); + }); + + it('hides Opens when the site has tracking off', () => { + expect(keys(emailed(), settings(), 'posts')).not.toContain('opens'); + }); + + it('hides Opens when the post itself was sent without tracking', () => { + const untracked = emailed({ + email: {status: 'submitted', email_count: 100, opened_count: 0, track_opens: false, track_clicks: true} + }); + + expect(keys(untracked, settings({emailTrackOpens: true}), 'posts')).not.toContain('opens'); + }); + + it('shows Clicks under the same rules', () => { + expect(keys(emailed(), settings({emailTrackClicks: true}), 'posts')).toContain('clicks'); + expect(keys(emailed(), settings(), 'posts')).not.toContain('clicks'); + }); + + // The one that is easy to get wrong: Sent is a *fallback*, shown only when + // neither of the others is. + it('shows Sent only when neither Opens nor Clicks is shown', () => { + expect(keys(emailed(), settings(), 'posts')).toContain('sent'); + expect(keys(emailed(), settings({emailTrackOpens: true}), 'posts')).not.toContain('sent'); + expect(keys(emailed(), settings({emailTrackClicks: true}), 'posts')).not.toContain('sent'); + }); + + it('shows nothing email-related for a post that was never emailed', () => { + const shown = keys(post(), settings({emailTrackOpens: true, emailTrackClicks: true}), 'posts'); + + expect(shown).not.toContain('opens'); + expect(shown).not.toContain('clicks'); + expect(shown).not.toContain('sent'); + }); + + it('hides the tracked columns when members signup is switched off entirely', () => { + const shown = keys(emailed(), settings({ + emailTrackOpens: true, emailTrackClicks: true, membersSignupAccess: 'none' + }), 'posts'); + + expect(shown).not.toContain('opens'); + expect(shown).not.toContain('clicks'); + // ...but Sent still appears, since it only depends on there being an email. + expect(shown).toContain('sent'); + }); +}); + +describe('the Members column', () => { + it('shows for a published post when source tracking is on', () => { + expect(keys(post(), settings({membersTrackSources: true}), 'posts')).toContain('members'); + }); + + // The column's gate in the template is only "source tracking on, and + // published". The invite-only and email-only exclusions belong to + // `showAttributionAnalytics`, which decides the *trailing button*, not this + // column — Ember shows a Members number here on an invite-only site. + it('shows on an invite-only site, where the analytics button does not', () => { + const inviteOnly = settings({membersTrackSources: true, isMembersInviteOnly: true}); + + expect(keys(post(), inviteOnly, 'posts')).toContain('members'); + expect(hasPostAnalyticsPage(post(), inviteOnly, 'posts', true)).toBe(false); + }); + + it('shows for an email-only post, where the analytics button does not', () => { + const emailOnly = post({status: 'published', email_only: true}); + const tracking = settings({membersTrackSources: true}); + + expect(keys(emailOnly, tracking, 'posts')).toContain('members'); + expect(hasPostAnalyticsPage(emailOnly, tracking, 'posts', true)).toBe(false); + }); + + it('shows for a published page', () => { + expect(keys(post(), settings({membersTrackSources: true}), 'pages')).toContain('members'); + }); + + it('hides for an unpublished post', () => { + expect(keys(post({status: 'draft'}), settings({membersTrackSources: true}), 'posts')) + .not.toContain('members'); + }); +}); + +describe('contributors', () => { + // Ember gates opens/clicks on `!isContributor` inside the computeds, but + // the Visitors and Members columns are gated in the template on the site + // settings alone — a contributor does see those on their own posts. + it('see no newsletter rates, but do see the site-level columns', () => { + const shown = keys(emailed(), settings({ + webAnalyticsEnabled: true, + membersTrackSources: true, + emailTrackOpens: true, + emailTrackClicks: true, + isContributor: true + }), 'posts'); + + expect(shown).not.toContain('opens'); + expect(shown).not.toContain('clicks'); + // Sent is the fallback, and it depends only on there being an email. + expect(shown).toEqual(['visitors', 'sent', 'members']); + }); + + it('never get an analytics page', () => { + expect(hasPostAnalyticsPage(emailed(), settings({ + webAnalyticsEnabled: true, + membersTrackSources: true, + emailTrackOpens: true, + emailTrackClicks: true, + isContributor: true + }), 'posts', false)).toBe(false); + }); +}); + +describe('column order', () => { + it('reads visitors, opens, clicks, then members', () => { + expect(keys(emailed(), settings({ + webAnalyticsEnabled: true, + membersTrackSources: true, + emailTrackOpens: true, + emailTrackClicks: true + }), 'posts')).toEqual(['visitors', 'opens', 'clicks', 'members']); + }); +}); + +describe('hasPostAnalyticsPage', () => { + const emailedPost = { + id: '1', + status: 'published', + email: {opened_count: 5, email_count: 10, track_opens: true, track_clicks: true} + } as PostListItem; + + const tracked = settings({emailTrackOpens: true}); + + it('is true for an admin on a post with newsletter engagement', () => { + expect(hasPostAnalyticsPage(emailedPost, tracked, 'posts', true)).toBe(true); + }); + + it('is false for a non-admin, however the post is configured', () => { + expect(hasPostAnalyticsPage(emailedPost, tracked, 'posts', false)).toBe(false); + }); + + it('is false for pages, which have no analytics screen', () => { + expect(hasPostAnalyticsPage(emailedPost, tracked, 'pages', true)).toBe(false); + }); + + // The distinction the Ember computed makes and the columns don't: web + // analytics alone does not earn a post an analytics page. + it('is false when only the Visitors column shows', () => { + const webOnlyPost = {id: '1', status: 'published'} as PostListItem; + const onlyWeb = settings({webAnalyticsEnabled: true, membersTrackSources: false}); + + expect(getPostMetricColumns(webOnlyPost, onlyWeb, 'posts').map(column => column.key)).toEqual(['visitors']); + expect(hasPostAnalyticsPage(webOnlyPost, onlyWeb, 'posts', true)).toBe(false); + }); + + // The most common real configuration — source tracking on, newsletter + // tracking off — and the one the email-shaped cases above would miss. + it('is true on the attribution path alone, with no email at all', () => { + const published = {id: '1', status: 'published'} as PostListItem; + + expect(hasPostAnalyticsPage(published, settings({membersTrackSources: true}), 'posts', true)).toBe(true); + }); + + // An emailed post with tracking off shows a Sent column but earns no page. + it('is false when the only email column is the Sent fallback', () => { + const untracked = settings(); + + expect(getPostMetricColumns(emailedPost, untracked, 'posts').map(column => column.key)) + .toEqual(['sent']); + expect(hasPostAnalyticsPage(emailedPost, untracked, 'posts', true)).toBe(false); + }); + + it('is false for a contributor even where the settings would allow it', () => { + const asContributor = settings({ + emailTrackOpens: true, membersTrackSources: true, isContributor: true + }); + + expect(hasPostAnalyticsPage(emailedPost, asContributor, 'posts', true)).toBe(false); + }); +}); diff --git a/apps/admin/src/posts/list/post-metrics.ts b/apps/admin/src/posts/list/post-metrics.ts new file mode 100644 index 00000000000..1356d6f2080 --- /dev/null +++ b/apps/admin/src/posts/list/post-metrics.ts @@ -0,0 +1,182 @@ +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * Which metric columns a row shows, and where each links. + * + * Ported from `list-item-analytics.hbs` plus the `showEmailOpenAnalytics`, + * `showEmailClickAnalytics` and `showAttributionAnalytics` computeds in + * `apps/ember-admin/app/models/post.js`. + * + * Two rules are easy to get wrong and invisible unless a site is configured a + * particular way: **Sent is a fallback**, shown only when neither Opens nor + * Clicks is; and the email columns need tracking enabled *both* site-wide and + * on the individual email, because a post sent before the setting changed + * carries its own flags. + */ + +export type PostMetricKey = 'visitors' | 'opens' | 'clicks' | 'sent' | 'members'; + +export interface PostMetricsSettings { + webAnalyticsEnabled: boolean; + membersTrackSources: boolean; + emailTrackOpens: boolean; + emailTrackClicks: boolean; + /** `'none'` means memberships are off, which hides email engagement. */ + membersSignupAccess: string; + isMembersInviteOnly: boolean; + isContributor: boolean; +} + +export interface PostMetricColumn { + key: PostMetricKey; + label: string; + /** Which analytics tab the column links to. */ + tab: 'web' | 'newsletter' | 'growth'; +} + +const COLUMNS: Record> = { + visitors: {label: 'Visitors', tab: 'web'}, + opens: {label: 'Opens', tab: 'newsletter'}, + clicks: {label: 'Clicks', tab: 'newsletter'}, + sent: {label: 'Sent', tab: 'newsletter'}, + members: {label: 'Members', tab: 'growth'} +}; + +/** + * Ember's `hasBeenEmailed` — a post (never a page) that went out and didn't + * fail. Gates the *rate* columns; the Sent column has a weaker gate, below. + */ +function hasBeenEmailed(post: PostListItem, resource: PostResource): boolean { + return resource === 'posts' + && (post.status === 'published' || post.status === 'sent') + && Boolean(post.email) + && post.email?.status !== 'failed'; +} + +/** + * `showEmailOpenAnalytics` / `showEmailClickAnalytics`. Tracking must be on + * both site-wide and on the individual email, because an email sent before the + * setting changed keeps the flags it went out with. + */ +function showsOpens(post: PostListItem, settings: PostMetricsSettings, resource: PostResource): boolean { + return hasBeenEmailed(post, resource) + && !settings.isContributor + && settings.membersSignupAccess !== 'none' + && settings.emailTrackOpens + && post.email?.track_opens === true; +} + +function showsClicks(post: PostListItem, settings: PostMetricsSettings, resource: PostResource): boolean { + return hasBeenEmailed(post, resource) + && !settings.isContributor + && settings.membersSignupAccess !== 'none' + && settings.emailTrackClicks + && post.email?.track_clicks === true; +} + +/** `showAttributionAnalytics`. Note this is *not* the Members column's gate. */ +function showsAttribution(post: PostListItem, settings: PostMetricsSettings, resource: PostResource): boolean { + return (resource === 'pages' || !post.email_only) + && post.status === 'published' + && settings.membersTrackSources + && !settings.isMembersInviteOnly + && !settings.isContributor; +} + +/** + * Which columns a row shows. + * + * These conditions come from `list-item-analytics.hbs`, **not** from the + * `show*Analytics` computeds above — Ember deliberately renders two of the + * columns under weaker conditions than the computeds that gate the trailing + * button, and collapsing the two loses real columns: + * + * - the Members column asks only "is source tracking on and is this + * published?", so an invite-only site still gets it; + * - the email block renders whenever the post has an `email` at all, so a + * *failed* send still shows its Sent count. + */ +export function getPostMetricColumns( + post: PostListItem, + settings: PostMetricsSettings, + resource: PostResource +): PostMetricColumn[] { + const keys: PostMetricKey[] = []; + const isPublished = post.status === 'published'; + + if (settings.webAnalyticsEnabled && isPublished) { + keys.push('visitors'); + } + + if (post.email) { + const opens = showsOpens(post, settings, resource); + const clicks = showsClicks(post, settings, resource); + + if (opens) { + keys.push('opens'); + } + + if (clicks) { + keys.push('clicks'); + } + + // Fallback only — the raw count stands in when neither rate is shown. + if (!opens && !clicks) { + keys.push('sent'); + } + } + + if (settings.membersTrackSources && isPublished) { + keys.push('members'); + } + + return keys.map(key => ({key, ...COLUMNS[key]})); +} + +/** `round(clicks / emailCount * 100)`, matching Ember's `clickRate`. */ +export function getPostClickRate(post: PostListItem): number { + const sent = post.email?.email_count; + const clicks = post.count?.clicks; + + if (!sent || !clicks) { + return 0; + } + + return Math.round((clicks / sent) * 100); +} + +/** Ember stores this as an already-computed percentage on the email. */ +export function getPostOpenRate(post: PostListItem): number { + const sent = post.email?.email_count; + const opened = post.email?.opened_count; + + if (!sent || !opened) { + return 0; + } + + return Math.round((opened / sent) * 100); +} + +/** + * Whether the row's trailing button goes to the post's analytics screen, per + * `hasAnalyticsPage` in `apps/ember-admin/app/models/post.js`. + * + * Note what it is *not*: web analytics ("Visitors") does not count. A post can + * show a Visitors column and still have no analytics page — the button falls + * back to the editor. Pages never have one. + */ +export function hasPostAnalyticsPage( + post: PostListItem, + settings: PostMetricsSettings, + resource: PostResource, + isAdmin: boolean +): boolean { + if (resource !== 'posts' || !isAdmin) { + return false; + } + + return showsOpens(post, settings, resource) + || showsClicks(post, settings, resource) + || showsAttribution(post, settings, resource); +} diff --git a/apps/admin/src/posts/list/post-preview-url.test.ts b/apps/admin/src/posts/list/post-preview-url.test.ts new file mode 100644 index 00000000000..1f2c28bbcc9 --- /dev/null +++ b/apps/admin/src/posts/list/post-preview-url.test.ts @@ -0,0 +1,29 @@ +import {describe, expect, it} from 'vitest'; +import {getPostPreviewUrl} from './post-preview-url'; + +/** + * The shareable preview link for an unpublished post, ported from the + * `previewUrl` computed in `apps/ember-admin/app/models/post.js`. + * + * Ember's own "Copy preview link" action does not use it — it copies `.url`, + * the public permalink, which for a draft points at a page that does not exist + * yet. That is a bug worth not porting; see the test at the bottom. + */ +describe('getPostPreviewUrl', () => { + it('builds a /p// link under the site url', () => { + expect(getPostPreviewUrl({uuid: 'abc-123'}, 'https://example.com')) + .toBe('https://example.com/p/abc-123/'); + }); + + it('trims a trailing slash off the site url rather than doubling it', () => { + expect(getPostPreviewUrl({uuid: 'abc-123'}, 'https://example.com/')) + .toBe('https://example.com/p/abc-123/'); + }); + + // A post that has never been saved has no uuid, so there is nothing to + // preview. Ember returns '' here; an empty string is what the caller + // checks, so a '/p/undefined/' link would be worse than useless. + it('has no link for a post with no uuid', () => { + expect(getPostPreviewUrl({}, 'https://example.com')).toBe(''); + }); +}); diff --git a/apps/admin/src/posts/list/post-preview-url.ts b/apps/admin/src/posts/list/post-preview-url.ts new file mode 100644 index 00000000000..649f9f69e9e --- /dev/null +++ b/apps/admin/src/posts/list/post-preview-url.ts @@ -0,0 +1,12 @@ +/** + * The shareable preview link for a post, ported from the `previewUrl` computed + * in `apps/ember-admin/app/models/post.js`. `p` is Ghost's preview route + * keyword. + */ +export function getPostPreviewUrl(post: {uuid?: string}, siteUrl: string): string { + if (!post.uuid) { + return ''; + } + + return `${siteUrl.replace(/\/$/, '')}/p/${post.uuid}/`; +} diff --git a/apps/admin/src/posts/list/post-publish-celebration.test.ts b/apps/admin/src/posts/list/post-publish-celebration.test.ts new file mode 100644 index 00000000000..035c76ffdaf --- /dev/null +++ b/apps/admin/src/posts/list/post-publish-celebration.test.ts @@ -0,0 +1,75 @@ +import {afterEach, describe, expect, it} from 'vitest'; +import {readPublishCelebration} from './post-publish-celebration'; + +/** + * The editor→list handoff, ported from `checkPublishFlowModal` in + * `apps/ember-admin/app/components/posts-list/list.js`. + */ +afterEach(() => { + localStorage.clear(); +}); + +describe('readPublishCelebration', () => { + it('reads a published post', () => { + localStorage.setItem('ghost-last-published-post', JSON.stringify({id: 'p1', type: 'post'})); + + expect(readPublishCelebration()).toEqual({id: 'p1', type: 'post', wasPublished: true}); + }); + + it('reads a scheduled post', () => { + localStorage.setItem('ghost-last-scheduled-post', JSON.stringify({id: 'p2', type: 'post'})); + + expect(readPublishCelebration()).toEqual({id: 'p2', type: 'post', wasPublished: false}); + }); + + it('reads a page', () => { + localStorage.setItem('ghost-last-published-post', JSON.stringify({id: 'g1', type: 'page'})); + + expect(readPublishCelebration()?.type).toBe('page'); + }); + + it('is nothing when neither key is set', () => { + expect(readPublishCelebration()).toBeNull(); + }); + + /** + * The key is cleared as it is read, *before* anything is fetched. Ember + * clears it after the modal opens, so a failed request leaves the key in + * place and the celebration re-fires on every visit to the list until it + * happens to succeed. + */ + it('clears the key as it reads it', () => { + localStorage.setItem('ghost-last-published-post', JSON.stringify({id: 'p1', type: 'post'})); + + readPublishCelebration(); + + expect(localStorage.getItem('ghost-last-published-post')).toBeNull(); + expect(readPublishCelebration()).toBeNull(); + }); + + // Whatever wrote this is gone; the only sane thing is to drop it rather + // than throw on every mount of the list for the rest of the session. + it('discards an unparseable entry, and clears it', () => { + localStorage.setItem('ghost-last-published-post', 'not json'); + + expect(readPublishCelebration()).toBeNull(); + expect(localStorage.getItem('ghost-last-published-post')).toBeNull(); + }); + + it('discards an entry with no id', () => { + localStorage.setItem('ghost-last-published-post', JSON.stringify({type: 'post'})); + + expect(readPublishCelebration()).toBeNull(); + }); + + // Published wins if both are somehow set, matching Ember's order — though + // it opens two modals in that case and we open one. + it('prefers the published entry when both are set', () => { + localStorage.setItem('ghost-last-published-post', JSON.stringify({id: 'p1', type: 'post'})); + localStorage.setItem('ghost-last-scheduled-post', JSON.stringify({id: 'p2', type: 'post'})); + + expect(readPublishCelebration()?.id).toBe('p1'); + // ...and both are cleared, so the other cannot fire on the next mount. + expect(localStorage.getItem('ghost-last-scheduled-post')).toBeNull(); + }); +}); diff --git a/apps/admin/src/posts/list/post-publish-celebration.ts b/apps/admin/src/posts/list/post-publish-celebration.ts new file mode 100644 index 00000000000..a6becf3e2e9 --- /dev/null +++ b/apps/admin/src/posts/list/post-publish-celebration.ts @@ -0,0 +1,64 @@ +/** + * The editor→list handoff for the post-publish celebration. + * + * The Ember editor writes a localStorage key on publish or schedule and then + * navigates to the list; the list reads it on mount and shows the modal. The + * editor stays Ember on both sides of the flag, so only the reader moves here. + * + * See `setCompleted` in `apps/ember-admin/app/components/editor/modals/publish-flow.js`. + */ + +const KEYS = { + published: 'ghost-last-published-post', + scheduled: 'ghost-last-scheduled-post' +} as const; + +export interface PublishCelebration { + id: string; + /** 'post' or 'page', as the editor writes it. */ + type: string; + /** Whether it was published (vs scheduled) — decides the modal's copy. */ + wasPublished: boolean; +} + +/** + * Reads the handoff and **clears both keys as it does so**, before anything is + * fetched. + * + * Ember clears after opening the modal, which means a failed request leaves the + * key in place and the celebration re-fires on every visit to the list until it + * happens to succeed. Clearing first costs at most one missed celebration and + * cannot loop. + */ +export function readPublishCelebration(): PublishCelebration | null { + const published = localStorage.getItem(KEYS.published); + const scheduled = localStorage.getItem(KEYS.scheduled); + + localStorage.removeItem(KEYS.published); + localStorage.removeItem(KEYS.scheduled); + + // Published first, matching the order Ember checks them in. + const raw = published ?? scheduled; + + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as {id?: unknown; type?: unknown}; + + if (typeof parsed?.id !== 'string' || !parsed.id) { + return null; + } + + return { + id: parsed.id, + type: typeof parsed.type === 'string' ? parsed.type : 'post', + wasPublished: published !== null + }; + } catch { + // Whatever wrote this is gone. Dropping it beats throwing on every + // mount of the list for the rest of the session. + return null; + } +} diff --git a/apps/admin/src/posts/list/post-query-params.test.ts b/apps/admin/src/posts/list/post-query-params.test.ts new file mode 100644 index 00000000000..eb0b38e0f79 --- /dev/null +++ b/apps/admin/src/posts/list/post-query-params.test.ts @@ -0,0 +1,171 @@ +import {describe, expect, it} from 'vitest'; +import { + BUCKET_ORDER, + buildAllFilter, + buildBucketFilter, + getActiveBuckets, + getBucketOrder, + getBucketSearchParams, + getStatusesForType +} from './post-query-params'; + +// Ported from apps/ember-admin/app/routes/posts.js. These strings go straight +// into API filters, and the same builder feeds the inverted "select all" filter +// that bulk delete runs against, so the exact output matters. + +describe('getStatusesForType', () => { + it('returns every status when no type is set', () => { + expect(getStatusesForType(null)).toEqual(['draft', 'scheduled', 'published', 'sent']); + expect(getStatusesForType(undefined)).toEqual(['draft', 'scheduled', 'published', 'sent']); + }); + + it.each([ + ['draft', ['draft']], + ['published', ['published']], + ['scheduled', ['scheduled']], + ['sent', ['sent']] + ])('maps type=%s to its own status', (type, expected) => { + expect(getStatusesForType(type)).toEqual(expected); + }); + + // `featured` is not a status - it is every status plus featured:true. + it('treats featured as every status', () => { + expect(getStatusesForType('featured')).toEqual(['draft', 'scheduled', 'published', 'sent']); + }); + + it('falls back to every status for an unrecognised type', () => { + expect(getStatusesForType('nonsense')).toEqual(['draft', 'scheduled', 'published', 'sent']); + }); +}); + +describe('buildAllFilter', () => { + it('filters by the full status set when nothing else is set', () => { + expect(buildAllFilter({})).toBe('status:[draft,scheduled,published,sent]'); + }); + + it('omits blank params rather than emitting empty clauses', () => { + expect(buildAllFilter({type: null, visibility: null, author: null, tag: null})) + .toBe('status:[draft,scheduled,published,sent]'); + }); + + // Ember's isBlank counts whitespace-only strings, so `?tag=%20%20` has to + // produce no clause here too or the strings stop matching. + it('treats a whitespace-only value as blank, like Ember', () => { + expect(buildAllFilter({tag: ' ', author: ' '})) + .toBe('status:[draft,scheduled,published,sent]'); + }); + + // Key order is load-bearing only in that it must stay stable; this locks + // the order Ember produced so filters compare equal across the two apps. + it('orders clauses tag, visibility, status, featured, authors', () => { + expect(buildAllFilter({tag: 'news', visibility: 'paid', type: 'featured', author: 'jo'})) + .toBe('tag:news+visibility:paid+status:[draft,scheduled,published,sent]+featured:true+authors:jo'); + }); + + it('adds featured:true only for type=featured', () => { + expect(buildAllFilter({type: 'featured'})) + .toBe('status:[draft,scheduled,published,sent]+featured:true'); + expect(buildAllFilter({type: 'draft'})).toBe('status:draft'); + }); + + it('passes the paid+tiers visibility value through untouched', () => { + // Ember treats this as an opaque option value, not as structure. + expect(buildAllFilter({visibility: '[paid,tiers]'})) + .toBe('visibility:[paid,tiers]+status:[draft,scheduled,published,sent]'); + }); + + it('narrows to the current user for authors and contributors', () => { + expect(buildAllFilter({author: 'someone-else'}, {ownAuthorSlug: 'me'})) + .toBe('status:[draft,scheduled,published,sent]+authors:me'); + }); + + it('uses the author param when the user is not scoped to their own posts', () => { + expect(buildAllFilter({author: 'jo'}, {})) + .toBe('status:[draft,scheduled,published,sent]+authors:jo'); + }); +}); + +describe('getActiveBuckets', () => { + it('runs all three buckets in order when no type is set', () => { + expect(getActiveBuckets({})).toEqual(['scheduled', 'draft', 'publishedAndSent']); + }); + + it('matches the documented render order', () => { + expect(BUCKET_ORDER).toEqual(['scheduled', 'draft', 'publishedAndSent']); + }); + + it.each([ + ['draft', ['draft']], + ['scheduled', ['scheduled']], + ['published', ['publishedAndSent']], + ['sent', ['publishedAndSent']] + ])('runs a single bucket for type=%s', (type, expected) => { + expect(getActiveBuckets({type})).toEqual(expected); + }); + + it('runs all three for featured, which spans every status', () => { + expect(getActiveBuckets({type: 'featured'})).toEqual(['scheduled', 'draft', 'publishedAndSent']); + }); +}); + +describe('buildBucketFilter', () => { + it('replaces the status clause in place, keeping clause order', () => { + expect(buildBucketFilter('scheduled', {tag: 'news', author: 'jo'})) + .toBe('tag:news+status:scheduled+authors:jo'); + }); + + it('combines published and sent into one bucket', () => { + expect(buildBucketFilter('publishedAndSent', {})).toBe('status:[published,sent]'); + }); + + // With type=published only published is wanted, even though the bucket is + // shared with sent - otherwise filtering by Published would show emails. + it('narrows the shared bucket to just the requested status', () => { + expect(buildBucketFilter('publishedAndSent', {type: 'published'})).toBe('status:published'); + expect(buildBucketFilter('publishedAndSent', {type: 'sent'})).toBe('status:sent'); + }); + + it('keeps featured:true on every bucket', () => { + expect(buildBucketFilter('draft', {type: 'featured'})).toBe('status:draft+featured:true'); + }); +}); + +describe('getBucketOrder', () => { + // Drafts have no published_at, so they sort by when they were last touched. + it('defaults drafts to recently updated and the rest to publish date', () => { + expect(getBucketOrder('draft', null)).toBe('updated_at desc'); + expect(getBucketOrder('scheduled', null)).toBe('published_at desc'); + expect(getBucketOrder('publishedAndSent', null)).toBe('published_at desc'); + }); + + it('lets an explicit order override every bucket', () => { + expect(getBucketOrder('draft', 'published_at asc')).toBe('published_at asc'); + expect(getBucketOrder('scheduled', 'published_at asc')).toBe('published_at asc'); + expect(getBucketOrder('publishedAndSent', 'updated_at desc')).toBe('updated_at desc'); + }); +}); + +describe('getBucketSearchParams', () => { + it('requests 30 per page, matching Ember', () => { + expect(getBucketSearchParams('draft', {})).toMatchObject({limit: '30'}); + }); + + it('carries the bucket filter and order', () => { + expect(getBucketSearchParams('scheduled', {tag: 'news'})).toEqual({ + filter: 'tag:news+status:scheduled', + order: 'published_at desc', + limit: '30' + }); + }); + + // Omitting these is not an optimisation - the server fills both in + // (defaultFormat, defaultRelations). Sending `columns` would actively hurt: + // it suppresses the default relations the list needs. + it('sends neither formats nor include, leaving the server defaults', () => { + const params = getBucketSearchParams('draft', {}); + + expect(params).not.toHaveProperty('formats'); + expect(params).not.toHaveProperty('include'); + expect(params).not.toHaveProperty('columns'); + }); +}); diff --git a/apps/admin/src/posts/list/post-query-params.ts b/apps/admin/src/posts/list/post-query-params.ts new file mode 100644 index 00000000000..8d3778c1725 --- /dev/null +++ b/apps/admin/src/posts/list/post-query-params.ts @@ -0,0 +1,180 @@ +/** + * Turns the posts/pages URL params into the API queries that back the list. + * + * Ported from `apps/ember-admin/app/routes/posts.js`. Two things here are + * load-bearing beyond "it fetches posts": + * + * - The list is not one query but three, drained in order (scheduled, then + * drafts, then published/sent), each with its own default sort. Drafts have + * no `published_at`, so they sort by when they were last touched. + * - `buildAllFilter` also feeds the inverted "select all" filter that bulk + * edit and bulk delete run against server-side, including posts that were + * never loaded. Its exact output matters. + */ + +export type PostStatus = 'draft' | 'scheduled' | 'published' | 'sent'; + +/** The three queries the list is assembled from, in render order. */ +export type PostBucket = 'scheduled' | 'draft' | 'publishedAndSent'; + +export const BUCKET_ORDER: readonly PostBucket[] = ['scheduled', 'draft', 'publishedAndSent']; + +const ALL_STATUSES: readonly PostStatus[] = ['draft', 'scheduled', 'published', 'sent']; + +const TYPE_TO_STATUSES: Record = { + draft: ['draft'], + published: ['published'], + scheduled: ['scheduled'], + sent: ['sent'] +}; + +/** Rows per request. Matches Ember; also decides when a bucket "opens". */ +export const POSTS_PER_PAGE = 30; + +/** + * The five URL params the screen is addressed by. This shape is the source of + * truth: it is what the URL carries and what sidebar saved views persist, so + * it must round-trip byte-identically between the Ember and React screens. + */ +export interface PostListParams { + type?: string | null; + visibility?: string | null; + author?: string | null; + tag?: string | null; + order?: string | null; +} + +export interface PostFilterContext { + /** + * Set for authors and contributors, who may only ever see their own posts. + * When set it wins over the `author` param entirely. + */ + ownAuthorSlug?: string | null; +} + +/** + * `featured` is not a status - it means every status *and* `featured:true`. + * An unrecognised type falls back to everything, matching Ember's `switch`. + */ +export function getStatusesForType(type?: string | null): PostStatus[] { + return [...(TYPE_TO_STATUSES[type ?? ''] ?? ALL_STATUSES)]; +} + +function statusClause(statuses: PostStatus[]): string { + return statuses.length === 1 ? statuses[0] : `[${statuses.join(',')}]`; +} + +/** + * Joins `key:value` pairs with `+`, dropping blanks. Values are interpolated + * verbatim - `visibility=[paid,tiers]` is an opaque option value, not + * structure to be parsed. + * + * "Blank" matches Ember's `isBlank`, which counts whitespace-only strings, so + * `?tag=%20%20` produces no clause in either implementation. These strings are + * compared against saved views and run server-side by bulk delete, so they have + * to agree exactly. + */ +function toFilterString(clauses: Array<[string, string | null | undefined]>): string { + return clauses + .filter((entry): entry is [string, string] => { + const value = entry[1]; + return value !== null && value !== undefined && value.trim() !== ''; + }) + .map(([key, value]) => `${key}:${value}`) + .join('+'); +} + +/** + * Clause order is fixed (tag, visibility, status, featured, authors) so filters + * built here compare equal to the ones Ember builds. + */ +function filterClauses( + params: PostListParams, + statuses: PostStatus[], + {ownAuthorSlug}: PostFilterContext +): Array<[string, string | null | undefined]> { + return [ + ['tag', params.tag], + ['visibility', params.visibility], + ['status', statusClause(statuses)], + ['featured', params.type === 'featured' ? 'true' : null], + ['authors', ownAuthorSlug || params.author] + ]; +} + +/** + * The filter for everything matching the current params, across all statuses. + * Used as the parent filter for bulk actions on an inverted selection. + */ +export function buildAllFilter(params: PostListParams, context: PostFilterContext = {}): string { + return toFilterString(filterClauses(params, getStatusesForType(params.type), context)); +} + +/** Which of the three queries the current params need, in render order. */ +export function getActiveBuckets(params: PostListParams): PostBucket[] { + const statuses = getStatusesForType(params.type); + + return BUCKET_ORDER.filter((bucket) => { + if (bucket === 'publishedAndSent') { + return statuses.includes('published') || statuses.includes('sent'); + } + return statuses.includes(bucket); + }); +} + +function bucketStatuses(bucket: PostBucket, params: PostListParams): PostStatus[] { + if (bucket !== 'publishedAndSent') { + return [bucket]; + } + + // The bucket is shared, but filtering by Published must not return emails. + return getStatusesForType(params.type).filter( + (status): status is PostStatus => status === 'published' || status === 'sent' + ); +} + +export function buildBucketFilter( + bucket: PostBucket, + params: PostListParams, + context: PostFilterContext = {} +): string { + return toFilterString(filterClauses(params, bucketStatuses(bucket, params), context)); +} + +/** + * An explicit `order` param overrides every bucket. Otherwise drafts sort by + * `updated_at` (they have no publish date) and the rest by `published_at`. + */ +export function getBucketOrder(bucket: PostBucket, order?: string | null): string { + if (order) { + return order; + } + + return bucket === 'draft' ? 'updated_at desc' : 'published_at desc'; +} + +/** + * Search params for one bucket's request. + * + * `include` is omitted on purpose: with neither `include` nor `columns` set, + * the server's `defaultRelations` attaches exactly what the list renders - + * tags, authors, authors.roles, email, tiers, newsletter, count.clicks + * (`ghost/core/.../serializers/input/posts.js:81`). Sending `columns` would + * *disable* that, so don't. + * + * `formats` is omitted because it makes no difference: `defaultFormat` fills + * in `mobiledoc,lexical` server-side when the client leaves it out, so this + * matches Ember byte for byte. Trimming the post bodies out of a 30-row list + * would need an explicit narrower `formats`, which is a separate change. + */ +export function getBucketSearchParams( + bucket: PostBucket, + params: PostListParams, + context: PostFilterContext = {} +): Record { + return { + filter: buildBucketFilter(bucket, params, context), + order: getBucketOrder(bucket, params.order), + limit: String(POSTS_PER_PAGE) + }; +} diff --git a/apps/admin/src/posts/list/post-resource.ts b/apps/admin/src/posts/list/post-resource.ts new file mode 100644 index 00000000000..203c04e9739 --- /dev/null +++ b/apps/admin/src/posts/list/post-resource.ts @@ -0,0 +1,47 @@ +/** + * The posts and pages list screens are one implementation with two resources. + * They differ only in the API path, the `type` filter options (pages have no + * "Email only"), the editor link, and whether "Save as view" is offered — so + * everything is parameterised by this rather than forked. + */ +export type PostResource = 'posts' | 'pages'; + +interface PostResourceCopy { + /** Screen title. */ + title: string; + /** Lowercase plural, for sentences like "No posts match the current filter". */ + plural: string; + /** Label and href for the primary "new" action in the page header. */ + newLabel: string; + newHref: string; + /** + * Cold-start empty state. Wording is verbatim from the Ember templates, + * terminal full stops included — Ember shows a heading and a button with + * no supporting description. + */ + emptyTitle: string; + emptyAction: string; +} + +const COPY: Record = { + posts: { + title: 'Posts', + plural: 'posts', + newLabel: 'New post', + newHref: '#/editor/post', + emptyTitle: 'Start creating content.', + emptyAction: 'Write a new post' + }, + pages: { + title: 'Pages', + plural: 'pages', + newLabel: 'New page', + newHref: '#/editor/page', + emptyTitle: 'Tell the world about yourself.', + emptyAction: 'Create a new page' + } +}; + +export function getPostResourceCopy(resource: PostResource): PostResourceCopy { + return COPY[resource]; +} diff --git a/apps/admin/src/posts/list/post-row-copy.test.ts b/apps/admin/src/posts/list/post-row-copy.test.ts new file mode 100644 index 00000000000..20b09444470 --- /dev/null +++ b/apps/admin/src/posts/list/post-row-copy.test.ts @@ -0,0 +1,229 @@ +import {describe, expect, it} from 'vitest'; +import { + getPostAuthorNames, + getPostDateField, + getPostDateTooltip, + getPostMetaLine, + getPostMetaParts, + getPostStatusDetail, + getPostStatusLabel +} from './post-row-copy'; +import type {PostListItem} from './hooks/use-posts-list'; + +// Every string a row renders, as pure functions. Ported from +// apps/ember-admin/app/components/posts-list/list-item-analytics.hbs and its +// helpers. Parity lives here, and it is the part hand-testing is worst at. + +const post = (overrides: Partial = {}): PostListItem => ({ + id: 'p1', + uuid: 'u1', + url: 'https://example.com/p1', + slug: 'p1', + title: 'A post', + status: 'published', + ...overrides +}); + +describe('getPostAuthorNames', () => { + it('joins author names with commas', () => { + expect(getPostAuthorNames(post({ + authors: [{id: '1', name: 'Ada'}, {id: '2', name: 'Grace'}] + }))).toBe('Ada, Grace'); + }); + + // Invited-but-not-yet-named staff have an email and no name. + it('falls back to the email when an author has no name', () => { + expect(getPostAuthorNames(post({ + authors: [{id: '1', email: 'ada@example.com'}] + }))).toBe('ada@example.com'); + }); + + it('is empty when there are no authors', () => { + expect(getPostAuthorNames(post())).toBe(''); + expect(getPostAuthorNames(post({authors: []}))).toBe(''); + }); +}); + +describe('getPostDateField', () => { + // Drafts and scheduled posts have no meaningful published_at, so Ember + // shows when they were last touched instead. + it.each([ + ['draft', 'updated_at'], + ['scheduled', 'updated_at'], + ['published', 'published_at'], + ['sent', 'published_at'] + ] as const)('uses %s -> %s', (status, field) => { + expect(getPostDateField(post({status}))).toBe(field); + }); +}); + +describe('getPostMetaLine', () => { + it('reads "By "', () => { + expect(getPostMetaLine(post({authors: [{id: '1', name: 'Ada'}]}))) + .toEqual({byline: 'By Ada', primaryTagName: null}); + }); + + it('adds the primary tag when there is one', () => { + expect(getPostMetaLine(post({ + authors: [{id: '1', name: 'Ada'}], + primary_tag: {id: 't1', name: 'News'} + }))).toEqual({byline: 'By Ada', primaryTagName: 'News'}); + }); + + it('omits the byline entirely when there are no authors', () => { + expect(getPostMetaLine(post()).byline).toBeNull(); + }); +}); + +describe('getPostMetaParts', () => { + // Joined by the row, so a missing piece must drop its separator too - + // otherwise a post with no authors reads " - 13 Jul 2026". + it('joins byline, tag and date', () => { + expect(getPostMetaParts(post({ + authors: [{id: '1', name: 'Ada'}], + primary_tag: {id: 't1', name: 'News'}, + published_at: '2026-07-13T09:00:00.000Z' + }), {timezone: 'UTC', now: new Date('2026-08-04T09:00:00.000Z')})) + .toEqual(['By Ada', 'in News', '13 Jul 2026']); + }); + + it('drops the byline and its separator when there are no authors', () => { + expect(getPostMetaParts(post({ + published_at: '2026-07-13T09:00:00.000Z' + }), {timezone: 'UTC', now: new Date('2026-08-04T09:00:00.000Z')})) + .toEqual(['13 Jul 2026']); + }); + + it('is empty when there is nothing to say', () => { + expect(getPostMetaParts(post({status: 'draft'}), {timezone: 'UTC'})).toEqual([]); + }); +}); + +describe('getPostDateTooltip', () => { + // Ember prefixes the title attribute so the date has context. + it('says "Updated" for a draft', () => { + expect(getPostDateTooltip(post({ + status: 'draft', + updated_at: '2026-07-13T09:00:00.000Z' + }), {timezone: 'UTC', now: new Date('2026-08-04T09:00:00.000Z')})) + .toBe('Updated 09:00 (UTC) 13 Jul 2026'); + }); + + it('says "Published" for a published post', () => { + expect(getPostDateTooltip(post({ + status: 'published', + published_at: '2026-07-13T09:00:00.000Z' + }), {timezone: 'UTC', now: new Date('2026-08-04T09:00:00.000Z')})) + .toContain('Published '); + }); + + it('is undefined with no date', () => { + expect(getPostDateTooltip(post({status: 'draft'}), {timezone: 'UTC'})).toBeUndefined(); + }); +}); + +describe('getPostStatusLabel', () => { + it.each([ + ['draft', 'Draft'], + ['scheduled', 'Scheduled'], + ['published', 'Published'], + ['sent', 'Sent'] + ] as const)('labels %s as "%s"', (status, label) => { + expect(getPostStatusLabel(post({status}))).toBe(label); + }); + + it('reports a failed newsletter on a published post', () => { + expect(getPostStatusLabel(post({ + status: 'published', + email: {status: 'failed', email_count: 10, opened_count: 0} + }))).toBe('Published but failed to send newsletter'); + }); + + // An email-only post that failed doesn't say "Sent" at all. + it('reports a failed newsletter on an email-only post', () => { + expect(getPostStatusLabel(post({ + status: 'sent', + email: {status: 'failed', email_count: 10, opened_count: 0} + }))).toBe('Failed to send newsletter'); + }); + + it('notes that a published post was also emailed', () => { + expect(getPostStatusLabel(post({ + status: 'published', + email: {status: 'submitted', email_count: 10, opened_count: 0} + }))).toBe('Published and sent'); + }); +}); + +describe('getPostStatusDetail', () => { + const timezone = 'UTC'; + + it('has no detail for a draft', () => { + expect(getPostStatusDetail(post({status: 'draft'}), {timezone})).toBeNull(); + }); + + // Revealed on hover in Ember. + it('names the recipient count for a post that was emailed', () => { + expect(getPostStatusDetail(post({ + status: 'published', + email: {status: 'submitted', email_count: 1200, opened_count: 0} + }), {timezone})).toBe('to 1,200 members'); + }); + + it('uses the singular for a single recipient', () => { + expect(getPostStatusDetail(post({ + status: 'sent', + email: {status: 'submitted', email_count: 1, opened_count: 0} + }), {timezone})).toBe('to 1 member'); + }); + + it('has no detail for a published post that was never emailed', () => { + expect(getPostStatusDetail(post({status: 'published'}), {timezone})).toBeNull(); + }); + + it('has no detail for a post whose newsletter failed', () => { + expect(getPostStatusDetail(post({ + status: 'published', + email: {status: 'failed', email_count: 10, opened_count: 0} + }), {timezone})).toBeNull(); + }); + + describe('scheduled posts', () => { + const publishedAt = '2026-08-05T09:00:00.000Z'; + + it('says when it will be published', () => { + const detail = getPostStatusDetail(post({ + status: 'scheduled', + published_at: publishedAt + }), {timezone, now: new Date('2026-08-04T09:00:00.000Z')}); + + expect(detail).toContain('to be published'); + expect(detail).toContain('tomorrow'); + }); + + it('says "and sent" and names the segment when a newsletter is attached', () => { + const detail = getPostStatusDetail(post({ + status: 'scheduled', + published_at: publishedAt, + newsletter: {id: 'n1'}, + email_segment: 'status:free' + }), {timezone, now: new Date('2026-08-04T09:00:00.000Z')}); + + expect(detail).toContain('to be published and sent'); + expect(detail).toContain('to Free subscribers'); + }); + + // Email-only posts are never "published". + it('says "to be sent" for an email-only post', () => { + const detail = getPostStatusDetail(post({ + status: 'scheduled', + published_at: publishedAt, + email_only: true, + email_segment: 'all' + }), {timezone, now: new Date('2026-08-04T09:00:00.000Z')}); + + expect(detail).toContain('to be sent'); + expect(detail).not.toContain('to be published'); + }); + }); +}); diff --git a/apps/admin/src/posts/list/post-row-copy.ts b/apps/admin/src/posts/list/post-row-copy.ts new file mode 100644 index 00000000000..38993710f8d --- /dev/null +++ b/apps/admin/src/posts/list/post-row-copy.ts @@ -0,0 +1,184 @@ +import {formatNumber} from '@tryghost/shade/utils'; +import {formatPostTime} from '@/posts/list/post-time'; +import {humanizeRecipientFilter} from '@/posts/list/humanize-recipient-filter'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * Every string a post row renders, as pure functions. + * + * Ported from `apps/ember-admin/app/components/posts-list/list-item-analytics.hbs` + * and its helpers. Kept out of the component because this is where parity + * actually lives — the wording and the conditions behind it are far easier to + * get subtly wrong than the layout, and far harder to eyeball. + */ + +type PostStatus = 'draft' | 'scheduled' | 'published' | 'sent'; + +function statusOf(post: PostListItem): PostStatus { + return (post.status ?? 'draft') as PostStatus; +} + +/** + * Ember's `didEmailFail`: a *post*, live, whose email failed. The status gate + * matters — un-publishing a post whose newsletter failed leaves the email + * record attached, and without it that draft would render as an error. + */ +export function didPostEmailFail(post: PostListItem, resource: PostResource = 'posts'): boolean { + const status = statusOf(post); + + return resource === 'posts' + && (status === 'published' || status === 'sent') + && post.email?.status === 'failed'; +} + +function didEmailFail(post: PostListItem): boolean { + return post.email?.status === 'failed'; +} + +/** + * Ember's `hasBeenEmailed`: a *post* (never a page), live, with a non-failed + * email. The page guard matters because a page carrying stray email data would + * otherwise be described as sent. + */ +function wasEmailed(post: PostListItem, resource: PostResource = 'posts'): boolean { + const status = statusOf(post); + + return resource === 'posts' + && (status === 'published' || status === 'sent') + && Boolean(post.email) + && !didEmailFail(post); +} + +/** Comma-joined author names, falling back to the email for un-named staff. */ +export function getPostAuthorNames(post: PostListItem): string { + return (post.authors ?? []) + .map(author => author.name || author.email) + .filter(Boolean) + .join(', '); +} + +/** + * Drafts and scheduled posts have no meaningful publish date, so the list + * shows when they were last touched instead. + */ +export function getPostDateField(post: PostListItem): 'updated_at' | 'published_at' { + const status = statusOf(post); + + return status === 'draft' || status === 'scheduled' ? 'updated_at' : 'published_at'; +} + +export function getPostDate(post: PostListItem): string | undefined { + return getPostDateField(post) === 'updated_at' ? post.updated_at : post.published_at; +} + +export interface PostMetaLine { + /** "By Ada, Grace" — null when the post has no authors at all. */ + byline: string | null; + primaryTagName: string | null; +} + +export function getPostMetaLine(post: PostListItem): PostMetaLine { + const authors = getPostAuthorNames(post); + + return { + byline: authors ? `By ${authors}` : null, + primaryTagName: post.primary_tag?.name ?? null + }; +} + +/** + * The meta line as separable parts, so the row can join them without emitting + * a dangling separator — a post with no authors would otherwise read + * " – 13 Jul 2026". + */ +export function getPostMetaParts( + post: PostListItem, + {timezone, now}: PostStatusDetailOptions = {} +): string[] { + const {byline, primaryTagName} = getPostMetaLine(post); + const date = getPostDate(post); + + return [ + byline, + primaryTagName ? `in ${primaryTagName}` : null, + date ? formatPostTime(date, {timezone, absolute: true, short: true, now}) : null + ].filter((part): part is string => Boolean(part)); +} + +/** Ember prefixes the date's title attribute so it has context. */ +export function getPostDateTooltip( + post: PostListItem, + {timezone, now}: PostStatusDetailOptions = {} +): string | undefined { + const date = getPostDate(post); + + if (!date) { + return undefined; + } + + const prefix = getPostDateField(post) === 'updated_at' ? 'Updated' : 'Published'; + + return `${prefix} ${formatPostTime(date, {timezone, absolute: true, now})}`; +} + +/** The always-visible status text. */ +export function getPostStatusLabel(post: PostListItem, resource: PostResource = 'posts'): string { + switch (statusOf(post)) { + case 'scheduled': + return 'Scheduled'; + case 'published': + if (didEmailFail(post)) { + return 'Published but failed to send newsletter'; + } + return wasEmailed(post, resource) ? 'Published and sent' : 'Published'; + case 'sent': + return didEmailFail(post) ? 'Failed to send newsletter' : 'Sent'; + default: + return 'Draft'; + } +} + +export interface PostStatusDetailOptions { + timezone?: string; + now?: Date; + resource?: PostResource; +} + +/** + * The extra text Ember reveals on hover: who a scheduled post will go to, or + * how many members already received it. `null` when there is nothing to add. + */ +export function getPostStatusDetail( + post: PostListItem, + {timezone, now, resource = 'posts'}: PostStatusDetailOptions = {} +): string | null { + const status = statusOf(post); + + if (status === 'scheduled') { + // Joined rather than interpolated: a post with no publish date yields + // an empty `when`, which interpolation would leave as a double space. + const when = formatPostTime(post.published_at, {timezone, scheduled: true, now}); + const segment = post.email_segment + ? `to ${humanizeRecipientFilter(post.email_segment)}` + : null; + + // Email-only posts are never "published". + const lead = post.email_only + ? 'to be sent' + : `to be published${post.newsletter ? ' and sent' : ''}`; + + const showSegment = post.email_only || post.newsletter; + + return [lead, when, showSegment ? segment : null] + .filter(Boolean) + .join(' '); + } + + if (wasEmailed(post, resource)) { + const count = post.email?.email_count ?? 0; + return `to ${formatNumber(count)} ${count === 1 ? 'member' : 'members'}`; + } + + return null; +} diff --git a/apps/admin/src/posts/list/post-selection-filter.test.ts b/apps/admin/src/posts/list/post-selection-filter.test.ts new file mode 100644 index 00000000000..5adb1b38371 --- /dev/null +++ b/apps/admin/src/posts/list/post-selection-filter.test.ts @@ -0,0 +1,81 @@ +import {describe, expect, it} from 'vitest'; +import {getPostSelectionFilter, type PostSelection} from './post-selection-filter'; + +/** + * The highest-stakes string in the port: this is what gets appended to + * `DELETE /posts/?filter=` and `PUT /posts/bulk?filter=`. A wrong branch here + * doesn't misrender something — it edits or deletes the wrong posts. + * + * Ported branch-for-branch from the `filter` getter in + * `apps/ember-admin/app/components/posts-list/selection-list.js`. + */ + +const selection = (overrides: Partial = {}): PostSelection => ({ + selectedIds: new Set(), + inverted: false, + ...overrides +}); + +describe('getPostSelectionFilter', () => { + // Not the empty string: an empty filter means "everything", so getting this + // branch wrong turns "delete nothing" into "delete the whole site". + it('matches nothing when nothing is selected', () => { + expect(getPostSelectionFilter(selection(), '')).toBe('id:nothing'); + }); + + it('still matches nothing when a filter is active but nothing is selected', () => { + expect(getPostSelectionFilter(selection(), 'status:draft')).toBe('id:nothing'); + }); + + it('lists the ids of an ordinary selection', () => { + expect(getPostSelectionFilter(selection({selectedIds: new Set(['a', 'b'])}), '')) + .toBe('id:[\'a\',\'b\']'); + }); + + // An ordinary selection ignores the list's filter entirely — the ids are + // already the complete answer. + it('ignores the active filter for an ordinary selection', () => { + expect(getPostSelectionFilter(selection({selectedIds: new Set(['a'])}), 'status:draft')) + .toBe('id:[\'a\']'); + }); + + describe('after Select All', () => { + // Empty means unbounded, which is exactly right here and exactly wrong + // in the "nothing selected" case above. + it('matches everything when the list is unfiltered', () => { + expect(getPostSelectionFilter(selection({inverted: true}), '')).toBe(''); + }); + + it('matches the list filter when one is active', () => { + expect(getPostSelectionFilter(selection({inverted: true}), 'status:draft')) + .toBe('status:draft'); + }); + + // The parenthesis matters: without it the `+` would bind against the + // last term of the filter rather than the whole of it. + it('subtracts deselected ids from the list filter', () => { + expect(getPostSelectionFilter( + selection({inverted: true, selectedIds: new Set(['a', 'b'])}), + 'status:draft' + )).toBe('(status:draft)+id:-[\'a\',\'b\']'); + }); + + it('subtracts deselected ids with no filter to intersect', () => { + expect(getPostSelectionFilter( + selection({inverted: true, selectedIds: new Set(['a'])}), + '' + )).toBe('id:-[\'a\']'); + }); + }); + + // Insertion order, as Ember's Set does — so the string is stable across + // renders and a request can be compared against an expectation. + it('emits ids in the order they were selected', () => { + const ids = new Set(); + ids.add('c'); + ids.add('a'); + ids.add('b'); + + expect(getPostSelectionFilter(selection({selectedIds: ids}), '')).toBe('id:[\'c\',\'a\',\'b\']'); + }); +}); diff --git a/apps/admin/src/posts/list/post-selection-filter.ts b/apps/admin/src/posts/list/post-selection-filter.ts new file mode 100644 index 00000000000..65fdfc52dec --- /dev/null +++ b/apps/admin/src/posts/list/post-selection-filter.ts @@ -0,0 +1,51 @@ +/** + * The NQL filter describing the current selection. + * + * This is what bulk actions send to the server — `DELETE /posts/?filter=…` and + * `PUT /posts/bulk?filter=…` — so it is the string with the largest blast + * radius in the whole screen. Ported branch-for-branch from the `filter` getter + * in `apps/ember-admin/app/components/posts-list/selection-list.js`. + * + * The reason it isn't just a list of ids: after Cmd+A the selection is + * *inverted* — "everything matching the list's filter, except these" — and may + * cover posts that were never loaded into the browser. Enumerating ids would + * silently act on only the first few pages. + */ + +export interface PostSelection { + /** + * While `inverted`, these are the ids the user has taken *out* of the + * selection rather than put into it. + */ + selectedIds: Set; + inverted: boolean; +} + +function idList(ids: Set): string { + return `'${[...ids].join('\',\'')}'`; +} + +/** + * @param allFilter the filter describing the list the user is looking at, which + * bounds an inverted selection. Empty means an unfiltered list. + */ +export function getPostSelectionFilter(selection: PostSelection, allFilter: string): string { + const {selectedIds, inverted} = selection; + + if (inverted) { + if (allFilter) { + // Parenthesised: `+` binds tighter than the filter's own operators, + // so without them the subtraction would apply to its last term. + return selectedIds.size === 0 + ? allFilter + : `(${allFilter})+id:-[${idList(selectedIds)}]`; + } + + // No bound and nothing removed: everything. An empty filter is the only + // way to say that, which is why the branch below can't share it. + return selectedIds.size === 0 ? '' : `id:-[${idList(selectedIds)}]`; + } + + // Deliberately not `''` — that would mean "everything" to the server. + return selectedIds.size === 0 ? 'id:nothing' : `id:[${idList(selectedIds)}]`; +} diff --git a/apps/admin/src/posts/list/post-selection-state.test.ts b/apps/admin/src/posts/list/post-selection-state.test.ts new file mode 100644 index 00000000000..9af1ccd59e2 --- /dev/null +++ b/apps/admin/src/posts/list/post-selection-state.test.ts @@ -0,0 +1,296 @@ +import {describe, expect, it} from 'vitest'; +import { + getPostSelectionCount, + initialPostSelection, + isPostSelected, + isSinglePostSelected, + postSelectionReducer, + type PostSelectionAction, + type PostSelectionState +} from './post-selection-state'; + +/** + * Selection semantics, ported from `posts-list/selection-list.js`. + * + * The load-bearing idea: after Select All the selection is *inverted*, and + * `selectedIds` flips meaning from "the chosen rows" to "the rows taken out". + * Every operation has to read correctly in both modes, and most of the bugs + * available here are a branch that only considered one of them. + */ + +const ids = ['a', 'b', 'c', 'd', 'e']; + +function run(actions: PostSelectionAction[], from: PostSelectionState = initialPostSelection): PostSelectionState { + return actions.reduce(postSelectionReducer, from); +} + +const selected = (state: PostSelectionState) => ids.filter(id => isPostSelected(state, id)); + +describe('postSelectionReducer', () => { + describe('toggle', () => { + it('selects an unselected row', () => { + expect(selected(run([{type: 'toggle', id: 'b'}]))).toEqual(['b']); + }); + + it('deselects a selected row', () => { + expect(selected(run([{type: 'toggle', id: 'b'}, {type: 'toggle', id: 'b'}]))).toEqual([]); + }); + + it('anchors the next shift-click on the row just toggled', () => { + expect(run([{type: 'toggle', id: 'b'}]).lastSelectedId).toBe('b'); + }); + + // Otherwise a shift-click after deselecting would range from a row the + // user just took out of the selection. + it('drops the anchor when the anchored row is deselected', () => { + expect(run([{type: 'toggle', id: 'b'}, {type: 'toggle', id: 'b'}]).lastSelectedId).toBeNull(); + }); + + // Ember only clears the anchor when the row being deselected *is* the + // anchor; deselecting any other row leaves it where it was. Moving the + // anchor onto a just-deselected row would make the next shift-click + // range from somewhere the user did not choose. + it('leaves the anchor alone when a different row is deselected', () => { + const state = run([ + {type: 'toggle', id: 'c'}, + {type: 'toggle', id: 'a'}, + {type: 'toggle', id: 'c'} + ]); + + expect(state.lastSelectedId).toBe('a'); + }); + + it('ranges from the untouched anchor on the shift-click that follows', () => { + const state = run([ + {type: 'toggle', id: 'c'}, + {type: 'toggle', id: 'a'}, + {type: 'toggle', id: 'c'}, + {type: 'shift', id: 'e', orderedIds: ids} + ]); + + expect(selected(state)).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it('takes a row out of an inverted selection', () => { + const state = run([{type: 'selectAll'}, {type: 'toggle', id: 'b'}]); + + expect(state.inverted).toBe(true); + expect(selected(state)).toEqual(['a', 'c', 'd', 'e']); + }); + }); + + describe('shift', () => { + it('falls back to a plain toggle with no anchor', () => { + expect(selected(run([{type: 'shift', id: 'c', orderedIds: ids}]))).toEqual(['c']); + }); + + it('selects the range from the anchor', () => { + const state = run([{type: 'toggle', id: 'b'}, {type: 'shift', id: 'd', orderedIds: ids}]); + + expect(selected(state)).toEqual(['b', 'c', 'd']); + }); + + // The anchor does not move — Ember's `shiftItem` never reassigns it — + // so a second shift-click re-ranges from the original click. + it('re-ranges from the same anchor and undoes the previous range', () => { + const state = run([ + {type: 'toggle', id: 'b'}, + {type: 'shift', id: 'e', orderedIds: ids}, + {type: 'shift', id: 'c', orderedIds: ids} + ]); + + expect(state.lastSelectedId).toBe('b'); + expect(selected(state)).toEqual(['b', 'c']); + }); + + // A row selected by hand before the shift must survive the undo, which + // is why the previous range is remembered rather than recomputed. + it('leaves rows selected outside the previous range alone', () => { + const state = run([ + {type: 'toggle', id: 'e'}, + {type: 'toggle', id: 'a'}, + {type: 'shift', id: 'c', orderedIds: ids}, + {type: 'shift', id: 'b', orderedIds: ids} + ]); + + expect(selected(state)).toEqual(['a', 'b', 'e']); + }); + + /** + * Inverted shift puts the range *back into* the selection rather than + * taking it out — the opposite of what toggle does in the same mode. + * + * That reads like a bug, and Ember's own source says so: `toggleItem` + * carries a `// Shift behaviour in inverted mode needs a review` + * comment, and no Ember test covers it. It is ported as-is anyway. + * "This looks wrong to me" is not the same as "this diverges from the + * thing we are porting", and fixing it here would make the two + * implementations disagree while the flag is still switchable. + * + * Worth raising as its own issue, then changing in both at once. + */ + it('re-selects the range when inverted, as Ember does', () => { + const state = run([ + {type: 'selectAll'}, + {type: 'toggle', id: 'c'}, + {type: 'toggle', id: 'd'}, + {type: 'shift', id: 'b', orderedIds: ids} + ]); + + // c and d were taken out; the backward range from the d anchor is + // b, c *and* d, and inverted mode puts each of them back — so the + // shift-click undoes both deselections and everything is selected + // again. + expect(selected(state)).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + }); + + describe('selectAll', () => { + it('inverts rather than enumerating, so unloaded rows are covered', () => { + const state = run([{type: 'selectAll'}]); + + expect(state.inverted).toBe(true); + expect(state.selectedIds.size).toBe(0); + expect(selected(state)).toEqual(ids); + }); + + it('toggles back off when pressed again', () => { + expect(run([{type: 'selectAll'}, {type: 'selectAll'}]).inverted).toBe(false); + }); + + // Ember discards the ids on select-all, so a row deselected before it + // doesn't come back as a row deselected after it. + it('discards the previous selection', () => { + const state = run([{type: 'toggle', id: 'b'}, {type: 'selectAll'}]); + + expect(selected(state)).toEqual(ids); + expect(state.lastSelectedId).toBeNull(); + }); + }); + + describe('clear', () => { + it('resets an ordinary selection', () => { + expect(selected(run([{type: 'toggle', id: 'b'}, {type: 'clear'}]))).toEqual([]); + }); + + it('resets an inverted selection rather than leaving everything selected', () => { + const state = run([{type: 'selectAll'}, {type: 'clear'}]); + + expect(state.inverted).toBe(false); + expect(selected(state)).toEqual([]); + }); + }); +}); + +describe('getPostSelectionCount', () => { + it('counts the selected rows', () => { + expect(getPostSelectionCount(run([{type: 'toggle', id: 'b'}]), 100)).toBe(1); + }); + + // Inverted counts against the server's total, not the rows in memory — + // Cmd+A on a 2,000-post site reads 2,000, not the 30 that are loaded. + it('counts everything on the server when inverted', () => { + expect(getPostSelectionCount(run([{type: 'selectAll'}]), 2000)).toBe(2000); + }); + + it('subtracts deselected rows from the total', () => { + const state = run([{type: 'selectAll'}, {type: 'toggle', id: 'b'}, {type: 'toggle', id: 'c'}]); + + expect(getPostSelectionCount(state, 2000)).toBe(1998); + }); + + // Ember floors this at 1. The total lags behind while pages load, so the + // subtraction can otherwise go negative and read "-3 posts selected". + it('never reads below one while inverted', () => { + const state = run([{type: 'selectAll'}, {type: 'toggle', id: 'a'}, {type: 'toggle', id: 'b'}]); + + expect(getPostSelectionCount(state, 1)).toBe(1); + }); +}); + +describe('isSinglePostSelected', () => { + it('is true for exactly one row', () => { + expect(isSinglePostSelected(run([{type: 'toggle', id: 'b'}]))).toBe(true); + }); + + it('is false for two rows', () => { + expect(isSinglePostSelected(run([{type: 'toggle', id: 'b'}, {type: 'toggle', id: 'c'}]))).toBe(false); + }); + + // One *deselected* row is not one selected row — this decides whether the + // context menu offers its single-post actions in Phase 7. + it('is false when inverted, whatever the id count', () => { + expect(isSinglePostSelected(run([{type: 'selectAll'}, {type: 'toggle', id: 'b'}]))).toBe(false); + }); +}); + +/** + * Right-clicking a row that isn't selected selects just that row — but only for + * as long as the menu is open. Ember does this with a freeze/unfreeze pair on + * the selection list plus a `clearOnNextUnfreeze` flag; Radix owns the menu's + * open state for us, so it collapses to one boolean on the selection itself. + * + * Right-clicking a row that *is* already selected leaves the selection alone — + * that is how you act on many rows at once. + */ +describe('transient selection', () => { + it('replaces the selection when an unselected row is right-clicked', () => { + const state = run([ + {type: 'toggle', id: 'a'}, + {type: 'toggle', id: 'b'}, + {type: 'contextMenu', id: 'd'} + ]); + + expect(selected(state)).toEqual(['d']); + expect(state.transient).toBe(true); + }); + + it('leaves an existing selection alone when one of its rows is right-clicked', () => { + const state = run([ + {type: 'toggle', id: 'a'}, + {type: 'toggle', id: 'b'}, + {type: 'contextMenu', id: 'b'} + ]); + + expect(selected(state)).toEqual(['a', 'b']); + expect(state.transient).toBe(false); + }); + + it('leaves an inverted selection alone, since every row is in it', () => { + const state = run([ + {type: 'selectAll'}, + {type: 'contextMenu', id: 'c'} + ]); + + expect(state.inverted).toBe(true); + expect(state.transient).toBe(false); + }); + + it('drops a transient selection when the menu closes', () => { + const state = run([ + {type: 'contextMenu', id: 'd'}, + {type: 'closeContextMenu'} + ]); + + expect(selected(state)).toEqual([]); + }); + + // The selection the user built by hand has to survive the menu closing, + // or acting on it twice in a row would be impossible. + it('keeps a deliberate selection when the menu closes', () => { + const state = run([ + {type: 'toggle', id: 'a'}, + {type: 'toggle', id: 'b'}, + {type: 'contextMenu', id: 'b'}, + {type: 'closeContextMenu'} + ]); + + expect(selected(state)).toEqual(['a', 'b']); + }); + + // Anchoring on the right-clicked row matches a plain cmd-click, so a + // shift-click straight after the menu closes ranges from where you clicked. + it('anchors on the row that was right-clicked', () => { + expect(run([{type: 'contextMenu', id: 'd'}]).lastSelectedId).toBe('d'); + }); +}); diff --git a/apps/admin/src/posts/list/post-selection-state.ts b/apps/admin/src/posts/list/post-selection-state.ts new file mode 100644 index 00000000000..8ae2a58834a --- /dev/null +++ b/apps/admin/src/posts/list/post-selection-state.ts @@ -0,0 +1,208 @@ +import {computeShiftRange} from '@/posts/list/compute-shift-range'; + +/** + * Selection state for the posts list, ported from `SelectionList` in + * `apps/ember-admin/app/components/posts-list/selection-list.js`. + * + * A plain reducer rather than a hook so the semantics can be tested as data. + * The freeze/unfreeze machinery Ember uses to hold a selection open while its + * context menu is up is not here — that is a Phase 7 concern, and Radix keeps + * the menu's own state, so it becomes a single "transient" flag instead. + */ + +export interface PostSelectionState { + /** + * While `inverted`, this holds the rows taken *out* of the selection, not + * the rows put into it. Every reader has to account for both meanings. + */ + selectedIds: Set; + inverted: boolean; + /** Where the next shift-click ranges from. */ + lastSelectedId: string | null; + /** + * The rows the previous shift-click added, so the next one can undo them. + * Recomputing the range instead would wrongly undo rows the user had + * selected by hand before shift-clicking. + */ + lastShiftGroup: Set; + /** + * Set when the selection exists only because a row was right-clicked, and + * should be dropped again when the menu closes. Ember achieves this with a + * freeze/unfreeze pair plus a `clearOnNextUnfreeze` flag; Radix owns the + * menu's open state here, so one boolean is enough. + */ + transient: boolean; +} + +export type PostSelectionAction = + | {type: 'toggle'; id: string} + | {type: 'shift'; id: string; orderedIds: string[]} + | {type: 'selectAll'} + | {type: 'clear'} + | {type: 'contextMenu'; id: string} + | {type: 'closeContextMenu'} + | {type: 'keepOnly'; ids: Set}; + +export function createPostSelection(): PostSelectionState { + return { + selectedIds: new Set(), + inverted: false, + lastSelectedId: null, + lastShiftGroup: new Set(), + transient: false + }; +} + +export const initialPostSelection: PostSelectionState = createPostSelection(); + +export function isPostSelected(state: PostSelectionState, id: string): boolean { + return state.inverted ? !state.selectedIds.has(id) : state.selectedIds.has(id); +} + +/** Whether exactly one post is selected — an inverted selection never is. */ +export function isSinglePostSelected(state: PostSelectionState): boolean { + return !state.inverted && state.selectedIds.size === 1; +} + +/** + * @param total the server's count for the current filter. Inverted selections + * cover rows that were never loaded, so the count can't come from the array. + */ +export function getPostSelectionCount(state: PostSelectionState, total: number): number { + if (!state.inverted) { + return state.selectedIds.size; + } + + // Floored at 1, as Ember does: `total` lags behind while pages load, so the + // subtraction can otherwise go negative and read "-3 posts selected". + return Math.max(total - state.selectedIds.size, 1); +} + +function toggle(state: PostSelectionState, id: string): PostSelectionState { + const selectedIds = new Set(state.selectedIds); + const wasPresent = selectedIds.delete(id); + + if (!wasPresent) { + selectedIds.add(id); + } + + // Three outcomes, not two. Selecting a row always anchors it. Deselecting + // only clears the anchor when the row *is* the anchor — deselecting some + // other row leaves the anchor where the user put it. Inverted mode anchors + // on every touch, since there "deselect" is the primary gesture. + let lastSelectedId = state.lastSelectedId; + + if (!wasPresent || state.inverted) { + lastSelectedId = id; + } else if (state.lastSelectedId === id) { + lastSelectedId = null; + } + + return { + selectedIds, + inverted: state.inverted, + lastSelectedId, + lastShiftGroup: new Set(), + transient: false + }; +} + +function shift(state: PostSelectionState, id: string, orderedIds: string[]): PostSelectionState { + if (state.lastSelectedId === null) { + return toggle(state, id); + } + + const selectedIds = new Set(state.selectedIds); + + // Undo the previous range first. In inverted mode "selected" means absent + // from the set, so adding and removing swap over throughout. + state.lastShiftGroup.forEach((previous) => { + if (state.inverted) { + selectedIds.add(previous); + } else { + selectedIds.delete(previous); + } + }); + + const range = computeShiftRange(orderedIds, state.lastSelectedId, id); + + range.forEach((rangeId) => { + if (state.inverted) { + selectedIds.delete(rangeId); + } else { + selectedIds.add(rangeId); + } + }); + + return { + selectedIds, + inverted: state.inverted, + // The anchor deliberately does not move, so a second shift-click + // re-ranges from the original click rather than chaining off the last. + lastSelectedId: state.lastSelectedId, + lastShiftGroup: new Set(range), + transient: false + }; +} + +export function postSelectionReducer( + state: PostSelectionState, + action: PostSelectionAction +): PostSelectionState { + switch (action.type) { + case 'toggle': + return toggle(state, action.id); + case 'shift': + return shift(state, action.id, action.orderedIds); + case 'selectAll': + // Inverting rather than enumerating is the whole point: it covers rows + // that were never loaded, so a bulk action on a 2,000-post site sends a + // filter instead of 2,000 ids. + return { + selectedIds: new Set(), + inverted: !state.inverted, + lastSelectedId: null, + lastShiftGroup: new Set(), + transient: false + }; + case 'contextMenu': + // Right-clicking a row already in the selection acts on the whole + // selection — that is how a bulk action is reached. Only an unselected + // row replaces it, and only for as long as the menu is open. + if (isPostSelected(state, action.id)) { + return state; + } + + return { + selectedIds: new Set([action.id]), + inverted: false, + lastSelectedId: action.id, + lastShiftGroup: new Set(), + transient: true + }; + case 'keepOnly': { + // Ember's `clearUnavailableItems`: after a bulk edit, ids that have + // left the list are dropped while the rows still on screen stay + // selected, so a second action can follow the first. + // + // Inverted selections hold *exclusions*, which are not list rows, so + // there is nothing to prune. + if (state.inverted) { + return state; + } + + const selectedIds = new Set([...state.selectedIds].filter(id => action.ids.has(id))); + + return {...state, selectedIds, lastShiftGroup: new Set()}; + } + case 'closeContextMenu': + return state.transient ? createPostSelection() : state; + case 'clear': + // A fresh object rather than the shared constant: handing out the same + // Sets everywhere means one stray in-place mutation would corrupt the + // module-level value for the lifetime of the app. + return createPostSelection(); + default: + return state; + } +} diff --git a/apps/admin/src/posts/list/post-time.test.ts b/apps/admin/src/posts/list/post-time.test.ts new file mode 100644 index 00000000000..b859735993e --- /dev/null +++ b/apps/admin/src/posts/list/post-time.test.ts @@ -0,0 +1,103 @@ +import {describe, expect, it} from 'vitest'; +import {formatPostTime} from './post-time'; + +// Ported from apps/ember-admin/app/helpers/gh-format-post-time.js. The rules +// are order-dependent (the <=12h relative window wins over everything, and +// "yesterday" is checked before "tomorrow"), so each branch is pinned. + +const TZ = 'UTC'; +const NOW = new Date('2026-08-04T12:00:00.000Z'); + +describe('formatPostTime', () => { + describe('within 12 hours either way', () => { + // Relative wins over every absolute format, in both directions. + it('reads as relative for a recent past time', () => { + expect(formatPostTime('2026-08-04T10:00:00.000Z', {timezone: TZ, now: NOW})) + .toBe('2 hours ago'); + }); + + it('reads as relative for a near-future time', () => { + expect(formatPostTime('2026-08-04T14:00:00.000Z', {timezone: TZ, now: NOW})) + .toBe('in 2 hours'); + }); + }); + + describe('same day, more than 12 hours away', () => { + // Reaching this branch needs the reference early in the day: from + // midday, nothing later the same day is more than 12 hours off, so the + // relative window above would win. + const EARLY = new Date('2026-08-04T01:00:00.000Z'); + + it('shows the time and "Today"', () => { + expect(formatPostTime('2026-08-04T23:30:00.000Z', {timezone: TZ, now: EARLY})) + .toBe('23:30 (UTC) Today'); + }); + + it('prefixes "at" when scheduled', () => { + expect(formatPostTime('2026-08-04T23:30:00.000Z', {timezone: TZ, now: EARLY, scheduled: true})) + .toBe('at 23:30 (UTC) Today'); + }); + }); + + describe('yesterday', () => { + it('shows the time and "yesterday"', () => { + expect(formatPostTime('2026-08-03T09:00:00.000Z', {timezone: TZ, now: NOW, absolute: true})) + .toBe('09:00 (UTC) yesterday'); + }); + + it('drops the time in short form', () => { + expect(formatPostTime('2026-08-03T09:00:00.000Z', {timezone: TZ, now: NOW, absolute: true, short: true})) + .toBe('Yesterday'); + }); + }); + + it('shows the time and "tomorrow" when scheduled for tomorrow', () => { + expect(formatPostTime('2026-08-05T09:00:00.000Z', {timezone: TZ, now: NOW, scheduled: true})) + .toBe('at 09:00 (UTC) tomorrow'); + }); + + describe('further away', () => { + it('shows just the date in short form', () => { + expect(formatPostTime('2026-07-01T09:00:00.000Z', {timezone: TZ, now: NOW, absolute: true, short: true})) + .toBe('01 Jul 2026'); + }); + + it('shows time and date in long form', () => { + expect(formatPostTime('2026-07-01T09:00:00.000Z', {timezone: TZ, now: NOW, absolute: true})) + .toBe('09:00 (UTC) 01 Jul 2026'); + }); + + it('reads as a sentence when scheduled', () => { + expect(formatPostTime('2026-09-01T09:00:00.000Z', {timezone: TZ, now: NOW, scheduled: true})) + .toBe('at 09:00 (UTC) on 01 Sep 2026'); + }); + }); + + describe('timezone offsets', () => { + it('writes a bare (UTC) with no offset', () => { + expect(formatPostTime('2026-07-01T09:00:00.000Z', {timezone: 'UTC', now: NOW, absolute: true})) + .toContain('(UTC)'); + }); + + // The helper trims a leading zero and the :00 minutes: +02:00 -> +2. + it('trims a whole-hour offset', () => { + expect(formatPostTime('2026-07-01T09:00:00.000Z', {timezone: 'Europe/Berlin', now: NOW, absolute: true})) + .toContain('(UTC+2)'); + }); + + it('keeps the minutes on a half-hour offset', () => { + expect(formatPostTime('2026-07-01T09:00:00.000Z', {timezone: 'Asia/Kolkata', now: NOW, absolute: true})) + .toContain('(UTC+5:30)'); + }); + + it('renders the time in the given zone, not UTC', () => { + expect(formatPostTime('2026-07-01T09:00:00.000Z', {timezone: 'Europe/Berlin', now: NOW, absolute: true})) + .toBe('11:00 (UTC+2) 01 Jul 2026'); + }); + }); + + it('returns empty for a missing time rather than "Invalid date"', () => { + expect(formatPostTime(null, {timezone: TZ, now: NOW})).toBe(''); + expect(formatPostTime(undefined, {timezone: TZ, now: NOW})).toBe(''); + }); +}); diff --git a/apps/admin/src/posts/list/post-time.ts b/apps/admin/src/posts/list/post-time.ts new file mode 100644 index 00000000000..856bc5826bb --- /dev/null +++ b/apps/admin/src/posts/list/post-time.ts @@ -0,0 +1,79 @@ +import moment from 'moment-timezone'; + +/** + * How a post's date reads in the list. + * + * Ported from `apps/ember-admin/app/helpers/gh-format-post-time.js`. The + * branches are order-dependent and the order is load-bearing: + * + * - anything within 12 hours either way reads as relative ("2 hours ago", + * "in 2 hours"), which beats every absolute format; + * - "yesterday" is checked before "tomorrow", because published posts vastly + * outnumber scheduled ones. + * + * `now` is injectable so the branches can be tested without freezing time. + */ +export interface FormatPostTimeOptions { + timezone?: string; + /** Renders as a sentence fragment: "at 09:00 (UTC) on 01 Sep 2026". */ + scheduled?: boolean; + /** Enables the "yesterday" branch. */ + absolute?: boolean; + /** Date only, no time. */ + short?: boolean; + now?: Date; +} + +function utcOffsetLabel(time: moment.Moment): string { + if (time.utcOffset() === 0) { + return '(UTC)'; + } + + // +02:00 -> +2, but +05:30 keeps its minutes. + const offset = time.format('Z').replace(/([+-])0/, '$1').replace(/:00/, ''); + + return `(UTC${offset})`; +} + +export function formatPostTime( + time: string | null | undefined, + {timezone = 'etc/UTC', scheduled, absolute, short, now}: FormatPostTimeOptions = {} +): string { + if (!time) { + return ''; + } + + const target = moment.tz(time, timezone); + const reference = moment.tz(now ?? moment.utc(), timezone); + + if (!target.isValid()) { + return ''; + } + + const offset = utcOffsetLabel(target); + + // A draft edited, a post published, or a post scheduled within 12 hours. + if (Math.abs(reference.diff(target, 'hours')) <= 12) { + return target.from(reference); + } + + if (target.isSame(reference, 'day')) { + const formatted = target.format(`HH:mm [${offset}] [Today]`); + return scheduled ? `at ${formatted}` : formatted; + } + + // Before the scheduled/tomorrow branch on purpose - see the note above. + if (absolute && target.isSame(reference.clone().subtract(1, 'days').startOf('day'), 'day')) { + return short ? target.format('[Yesterday]') : target.format(`HH:mm [${offset}] [yesterday]`); + } + + if (scheduled && target.isSame(reference.clone().add(1, 'days').startOf('day'), 'day')) { + return target.format(`[at] HH:mm [${offset}] [tomorrow]`); + } + + if (scheduled) { + return target.format(`[at] HH:mm [${offset}] [on] DD MMM YYYY`); + } + + return short ? target.format('DD MMM YYYY') : target.format(`HH:mm [${offset}] DD MMM YYYY`); +} diff --git a/apps/admin/src/posts/list/post-view-params.ts b/apps/admin/src/posts/list/post-view-params.ts new file mode 100644 index 00000000000..820e376b7d1 --- /dev/null +++ b/apps/admin/src/posts/list/post-view-params.ts @@ -0,0 +1,12 @@ +/** + * The params that make up a posts/pages URL, and therefore a saved view's + * identity. `order` is included: Ember's `reset-query-params` covers all five, + * and its `activeView` compares all five, so two views differing only by sort + * are different views. + * + * Kept separate from `post-filter-query.ts` (which owns the four *filter* + * params) so the sidebar can import it without pulling in the chip model. + */ +export const POST_VIEW_PARAMS = ['type', 'visibility', 'author', 'tag', 'order'] as const; + +export type PostViewParam = (typeof POST_VIEW_PARAMS)[number]; diff --git a/apps/admin/src/posts/list/post-views-storage.test.ts b/apps/admin/src/posts/list/post-views-storage.test.ts new file mode 100644 index 00000000000..68017cd90a1 --- /dev/null +++ b/apps/admin/src/posts/list/post-views-storage.test.ts @@ -0,0 +1,114 @@ +import {describe, expect, it} from 'vitest'; +import {applyPostViewDelete, applyPostViewSave} from './post-views-storage'; +import type {SharedView} from '@/members/shared-views'; + +/** + * Views for members, posts and pages all live in ONE `shared_views` setting, + * so a post-view save has to round-trip everything else untouched. + * + * The obvious implementation — parse, validate, re-serialize — silently + * deletes any entry that fails validation, including ones written by a future + * Ghost version. These tests exist because that is data loss the user would + * never be warned about. + */ + +const postsView = (name: string, filter: Record): SharedView => ({ + name, route: 'posts', color: 'blue', filter +}); + +const parse = (json: string) => JSON.parse(json) as unknown[]; + +describe('applyPostViewSave', () => { + it('appends a new view', () => { + const result = parse(applyPostViewSave('[]', 'News', {tag: 'news'}, 'blue')); + + expect(result).toEqual([{name: 'News', route: 'posts', color: 'blue', filter: {tag: 'news'}}]); + }); + + it('leaves other screens\' views exactly as they were', () => { + const existing = JSON.stringify([{name: 'VIPs', route: 'members', filter: {filter: 'label:vip'}}]); + const result = parse(applyPostViewSave(existing, 'News', {tag: 'news'}, 'blue')); + + expect(result[0]).toEqual({name: 'VIPs', route: 'members', filter: {filter: 'label:vip'}}); + }); + + // The whole point: an entry this build can't validate must survive. + it('preserves an entry that fails validation', () => { + const existing = JSON.stringify([ + {name: 'Broken', route: 'members'}, + {totally: 'unrecognised'} + ]); + const result = parse(applyPostViewSave(existing, 'News', {tag: 'news'}, 'blue')); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({name: 'Broken', route: 'members'}); + expect(result[1]).toEqual({totally: 'unrecognised'}); + }); + + // A future version may add fields this build doesn't know about. + it('preserves unknown fields on entries it does not touch', () => { + const existing = JSON.stringify([ + {name: 'Future', route: 'members', filter: {filter: 'x'}, icon: 'star', somethingNew: 42} + ]); + const result = parse(applyPostViewSave(existing, 'News', {tag: 'news'}, 'blue')); + + expect(result[0]).toMatchObject({icon: 'star', somethingNew: 42}); + }); + + it('replaces the original when editing, in place', () => { + const original = postsView('News', {tag: 'news'}); + const existing = JSON.stringify([{name: 'VIPs', route: 'members', filter: {filter: 'x'}}, original]); + const result = parse(applyPostViewSave(existing, 'Renamed', {tag: 'other'}, 'blue', original)); + + expect(result).toHaveLength(2); + expect(result[0]).toMatchObject({route: 'members'}); + expect(result[1]).toMatchObject({name: 'Renamed', filter: {tag: 'other'}}); + }); + + it('rejects a duplicate name on the same route', () => { + const existing = JSON.stringify([postsView('News', {tag: 'news'})]); + + expect(() => applyPostViewSave(existing, 'News', {tag: 'other'}, 'blue')) + .toThrow(/already exists/i); + }); + + // Rather than treating it as an empty list and wiping the lot. + it('refuses to write when the stored value is not an array', () => { + expect(() => applyPostViewSave('{"not":"an array"}', 'News', {tag: 'news'}, 'blue')) + .toThrow(/could not be read/i); + }); + + it('refuses to write when the stored value is unparseable', () => { + expect(() => applyPostViewSave('not json at all', 'News', {tag: 'news'}, 'blue')) + .toThrow(/could not be read/i); + }); +}); + +describe('applyPostViewDelete', () => { + it('removes only the target', () => { + const target = postsView('News', {tag: 'news'}); + const existing = JSON.stringify([{name: 'VIPs', route: 'members', filter: {filter: 'x'}}, target]); + const result = parse(applyPostViewDelete(existing, target)); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({route: 'members'}); + }); + + it('preserves unvalidatable entries', () => { + const target = postsView('News', {tag: 'news'}); + const existing = JSON.stringify([{totally: 'unrecognised'}, target]); + const result = parse(applyPostViewDelete(existing, target)); + + expect(result).toEqual([{totally: 'unrecognised'}]); + }); + + it('throws when the view is already gone', () => { + expect(() => applyPostViewDelete('[]', postsView('News', {tag: 'news'}))) + .toThrow(/could not be found/i); + }); + + it('refuses to write when the stored value is unreadable', () => { + expect(() => applyPostViewDelete('nonsense', postsView('News', {tag: 'news'}))) + .toThrow(/could not be read/i); + }); +}); diff --git a/apps/admin/src/posts/list/post-views-storage.ts b/apps/admin/src/posts/list/post-views-storage.ts new file mode 100644 index 00000000000..3ef78f9ee24 --- /dev/null +++ b/apps/admin/src/posts/list/post-views-storage.ts @@ -0,0 +1,96 @@ +import {buildPostView, type PostViewColor} from '@/posts/list/post-views'; +import {normalizeSharedViewName} from '@/members/shared-views'; +import type {PostListParams} from '@/posts/list/post-query-params'; +import type {SharedView} from '@/members/shared-views'; + +/** + * Reading and writing the posts entries of the shared `shared_views` setting, + * without disturbing anything else in it. + * + * Members, posts and pages all share this one setting. The obvious approach — + * parse, validate, re-serialize — silently deletes any entry the current build + * can't validate, including views written by a future version. That is data + * loss the user is never warned about, so this works on the **raw** array and + * re-serializes only the entry it actually changes. + * + * It also refuses to write at all when the stored value can't be read, rather + * than treating it as an empty list and replacing the lot. + */ + +const UNREADABLE_ERROR = 'Saved views could not be read, so nothing was changed'; +const VIEW_EXISTS_ERROR = 'A view with this name already exists'; +const VIEW_NOT_FOUND_ERROR = 'Saved view could not be found'; + +type RawEntry = Record; + +function readRawViews(json: string): RawEntry[] { + let parsed: unknown; + + try { + parsed = JSON.parse(json); + } catch { + throw new Error(UNREADABLE_ERROR); + } + + if (!Array.isArray(parsed)) { + throw new Error(UNREADABLE_ERROR); + } + + return parsed as RawEntry[]; +} + +/** Loose match on the raw shape — the entry may not validate. */ +function isSamePostsView(entry: RawEntry, view: SharedView): boolean { + return entry.route === 'posts' + && typeof entry.name === 'string' + && normalizeSharedViewName(entry.name) === normalizeSharedViewName(view.name); +} + +function hasNameConflict(entries: RawEntry[], name: string, excludedIndex: number): boolean { + const normalized = normalizeSharedViewName(name); + + return entries.some((entry, index) => index !== excludedIndex + && entry.route === 'posts' + && typeof entry.name === 'string' + && normalizeSharedViewName(entry.name) === normalized); +} + +export function applyPostViewSave( + json: string, + name: string, + params: PostListParams, + color: PostViewColor, + originalView?: SharedView +): string { + const entries = readRawViews(json); + const nextView = buildPostView(name, params, color); + + const targetIndex = originalView + ? entries.findIndex(entry => isSamePostsView(entry, originalView)) + : -1; + + if (originalView && targetIndex === -1) { + throw new Error(VIEW_NOT_FOUND_ERROR); + } + + if (hasNameConflict(entries, nextView.name, targetIndex)) { + throw new Error(VIEW_EXISTS_ERROR); + } + + const updated = targetIndex === -1 + ? [...entries, nextView as unknown as RawEntry] + : entries.map((entry, index) => (index === targetIndex ? nextView as unknown as RawEntry : entry)); + + return JSON.stringify(updated); +} + +export function applyPostViewDelete(json: string, view: SharedView): string { + const entries = readRawViews(json); + const targetIndex = entries.findIndex(entry => isSamePostsView(entry, view)); + + if (targetIndex === -1) { + throw new Error(VIEW_NOT_FOUND_ERROR); + } + + return JSON.stringify(entries.filter((_, index) => index !== targetIndex)); +} diff --git a/apps/admin/src/posts/list/post-views.test.ts b/apps/admin/src/posts/list/post-views.test.ts new file mode 100644 index 00000000000..488e6eb0b24 --- /dev/null +++ b/apps/admin/src/posts/list/post-views.test.ts @@ -0,0 +1,169 @@ +import {describe, expect, it} from 'vitest'; +import { + POST_VIEW_COLORS, + buildPostView, + buildPostViewsForDelete, + buildPostViewsForSave, + canSavePostView, + findActivePostView +} from './post-views'; +import {hasAdminAccess} from '@tryghost/admin-x-framework/api/users'; +import type {SharedView} from '@/members/shared-views'; + +const view = (name: string, filter: Record): SharedView => ({ + name, route: 'posts', filter, color: 'blue' +}); + +describe('canSavePostView', () => { + // Ember gates the save button on: admin, on the posts screen, not already + // on a default view, and at least one param set. + it('allows an admin with a filter set', () => { + expect(canSavePostView({ + isAdmin: true, resource: 'posts', params: {type: 'draft'}, isDefaultView: false + })).toBe(true); + }); + + it('refuses a non-admin', () => { + expect(canSavePostView({ + isAdmin: false, resource: 'posts', params: {type: 'draft'}, isDefaultView: false + })).toBe(false); + }); + + // Ember's `isAdmin` is `or(isOwnerOnly, isAdminOnly)`, so the Owner counts. + // The framework's `isAdminUser` is Administrator *only*, and using it here + // hid the button from the site owner — the person most likely to be saving + // views. Asserted through the same call the screen makes, so swapping the + // helper back would fail this rather than quietly pass. + it.each([ + {role: 'Owner', expected: true}, + {role: 'Administrator', expected: true}, + {role: 'Editor', expected: false}, + {role: 'Author', expected: false} + ])('lets a $role save a view: $expected', ({role, expected}) => { + const user = {roles: [{name: role as 'Owner'}]}; + + expect(canSavePostView({ + isAdmin: hasAdminAccess(user), + resource: 'posts', + params: {type: 'draft'}, + isDefaultView: false + })).toBe(expected); + }); + + // The button is hardcoded to `currentRouteName === 'posts'` in Ember, so + // pages never offer it. + it('refuses on the pages screen', () => { + expect(canSavePostView({ + isAdmin: true, resource: 'pages', params: {type: 'draft'}, isDefaultView: false + })).toBe(false); + }); + + it('refuses with nothing filtered', () => { + expect(canSavePostView({ + isAdmin: true, resource: 'posts', params: {}, isDefaultView: false + })).toBe(false); + }); + + // Sorting counts here, unlike the empty state's "showingAll". + it('allows a sort on its own', () => { + expect(canSavePostView({ + isAdmin: true, resource: 'posts', params: {order: 'published_at asc'}, isDefaultView: false + })).toBe(true); + }); + + it('refuses while a default view is active', () => { + expect(canSavePostView({ + isAdmin: true, resource: 'posts', params: {type: 'draft'}, isDefaultView: true + })).toBe(false); + }); +}); + +describe('buildPostView', () => { + it('stores the params verbatim, so Ember reads the same view', () => { + expect(buildPostView('News', {type: 'draft', tag: 'news'}, 'blue')).toEqual({ + name: 'News', + route: 'posts', + color: 'blue', + filter: {type: 'draft', tag: 'news'} + }); + }); + + it('trims the name', () => { + expect(buildPostView(' News ', {type: 'draft'}, 'blue').name).toBe('News'); + }); + + it('drops empty params so the filter compares equal to a clean URL', () => { + expect(buildPostView('News', {type: 'draft', tag: null, order: ''}, 'blue').filter) + .toEqual({type: 'draft'}); + }); + + it('only uses colours Ember knows', () => { + POST_VIEW_COLORS.forEach((color) => { + expect(buildPostView('X', {type: 'draft'}, color).color).toBe(color); + }); + }); +}); + +describe('buildPostViewsForSave', () => { + it('appends a new view', () => { + const result = buildPostViewsForSave([], 'News', {type: 'draft'}, 'blue'); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('News'); + }); + + it('rejects a duplicate name on the same route', () => { + expect(() => buildPostViewsForSave( + [view('News', {type: 'draft'})], 'News', {tag: 'other'}, 'blue' + )).toThrow(/already exists/i); + }); + + // Views are per-route, so members and pages names don't collide. + it('allows the same name on another route', () => { + const existing: SharedView = {name: 'News', route: 'members', filter: {filter: 'x'}}; + + expect(buildPostViewsForSave([existing], 'News', {type: 'draft'}, 'blue')).toHaveLength(2); + }); + + it('replaces the original when editing', () => { + const original = view('News', {type: 'draft'}); + const result = buildPostViewsForSave([original], 'Renamed', {type: 'published'}, 'red', original); + + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({name: 'Renamed', filter: {type: 'published'}}); + }); + + it('leaves other views untouched when editing', () => { + const original = view('News', {type: 'draft'}); + const other = view('Other', {tag: 'x'}); + const result = buildPostViewsForSave([other, original], 'Renamed', {type: 'published'}, 'red', original); + + expect(result[0]).toEqual(other); + }); +}); + +describe('buildPostViewsForDelete', () => { + it('removes the view', () => { + const target = view('News', {type: 'draft'}); + + expect(buildPostViewsForDelete([target, view('Other', {tag: 'x'})], target)) + .toEqual([view('Other', {tag: 'x'})]); + }); + + it('throws when the view is gone', () => { + expect(() => buildPostViewsForDelete([], view('News', {type: 'draft'}))) + .toThrow(/could not be found/i); + }); +}); + +describe('findActivePostView', () => { + it('finds the view whose filter matches the URL exactly', () => { + const views = [view('News', {type: 'draft', tag: 'news'}), view('Drafts', {type: 'draft'})]; + + expect(findActivePostView(views, {type: 'draft'})?.name).toBe('Drafts'); + }); + + it('finds nothing when no view matches', () => { + expect(findActivePostView([view('Drafts', {type: 'draft'})], {tag: 'news'})).toBeUndefined(); + }); +}); diff --git a/apps/admin/src/posts/list/post-views.ts b/apps/admin/src/posts/list/post-views.ts new file mode 100644 index 00000000000..8d9aa247bc9 --- /dev/null +++ b/apps/admin/src/posts/list/post-views.ts @@ -0,0 +1,137 @@ +import {POST_VIEW_PARAMS} from '@/posts/list/post-view-params'; +import { + type SharedView, + findMatchingSharedViewIndexes, + hasSharedViewNameConflict +} from '@/members/shared-views'; +import type {PostListParams} from '@/posts/list/post-query-params'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * Saved views for the posts list. + * + * Records are written in exactly Ember's shape — `{name, route, color, + * filter}` where `filter` is the five URL params — so a view saved here shows + * up correctly in the Ember sidebar and vice versa while both exist. + * + * The generic save/delete plumbing is shared with members via + * `@/members/shared-views`; only the filter shape differs. + */ + +/** Ember picks one of these at random for a new view. */ +export const POST_VIEW_COLORS = [ + 'midgrey', 'blue', 'green', 'red', 'teal', 'purple', 'yellow', 'orange', 'pink' +] as const; + +export type PostViewColor = (typeof POST_VIEW_COLORS)[number]; + +const VIEW_EXISTS_ERROR = 'A view with this name already exists'; +const VIEW_UPDATE_NOT_FOUND_ERROR = 'Saved view could not be found for update'; +const VIEW_DELETE_NOT_FOUND_ERROR = 'Saved view could not be found for delete'; + +export function pickPostViewColor(): PostViewColor { + return POST_VIEW_COLORS[Math.floor(Math.random() * POST_VIEW_COLORS.length)]; +} + +/** Only the five params, blanks dropped — so it compares equal to a clean URL. */ +function toViewFilter(params: PostListParams): Record { + const filter: Record = {}; + + POST_VIEW_PARAMS.forEach((param) => { + const value = params[param]; + + if (value !== null && value !== undefined && value !== '') { + filter[param] = value; + } + }); + + return filter; +} + +export function buildPostView( + name: string, + params: PostListParams, + color: PostViewColor +): SharedView { + return { + name: name.trim(), + route: 'posts', + color, + filter: toViewFilter(params) + }; +} + +export interface CanSavePostViewOptions { + isAdmin: boolean; + resource: PostResource; + params: PostListParams; + /** Default views (Drafts/Scheduled/Published) can't be edited or re-saved. */ + isDefaultView: boolean; +} + +/** + * Ember's `showCustomViewManagement`: admin, on the posts screen, not on a + * default view, and something actually filtered. Note that a sort alone counts + * here, unlike the empty state's "showing all" check. + */ +export function canSavePostView({ + isAdmin, resource, params, isDefaultView +}: CanSavePostViewOptions): boolean { + if (!isAdmin || resource !== 'posts' || isDefaultView) { + return false; + } + + return POST_VIEW_PARAMS.some(param => Boolean(params[param])); +} + +export function buildPostViewsForSave( + allViews: SharedView[], + name: string, + params: PostListParams, + color: PostViewColor, + originalView?: SharedView +): SharedView[] { + const nextView = buildPostView(name, params, color); + + if (originalView) { + const [targetIndex] = findMatchingSharedViewIndexes(allViews, originalView); + + if (targetIndex === undefined) { + throw new Error(VIEW_UPDATE_NOT_FOUND_ERROR); + } + + if (hasSharedViewNameConflict(allViews, nextView, targetIndex)) { + throw new Error(VIEW_EXISTS_ERROR); + } + + return allViews.map((view, index) => (index === targetIndex ? nextView : view)); + } + + if (hasSharedViewNameConflict(allViews, nextView)) { + throw new Error(VIEW_EXISTS_ERROR); + } + + return [...allViews, nextView]; +} + +export function buildPostViewsForDelete(allViews: SharedView[], view: SharedView): SharedView[] { + const [targetIndex] = findMatchingSharedViewIndexes(allViews, view); + + if (targetIndex === undefined) { + throw new Error(VIEW_DELETE_NOT_FOUND_ERROR); + } + + return allViews.filter((_, index) => index !== targetIndex); +} + +/** The saved view matching the current params exactly, if any. */ +export function findActivePostView( + views: SharedView[], + params: PostListParams +): SharedView | undefined { + const current = toViewFilter(params); + + return views.find(view => POST_VIEW_PARAMS.every( + param => (view.filter[param] ?? null) === (current[param] ?? null) + )); +} diff --git a/apps/admin/src/posts/list/posts-list-bulk-actions.acceptance.test.tsx b/apps/admin/src/posts/list/posts-list-bulk-actions.acceptance.test.tsx new file mode 100644 index 00000000000..a06c6599901 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-bulk-actions.acceptance.test.tsx @@ -0,0 +1,373 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { fakeAdminEndpoint, fakePosts, fakePostsListScreen, fakeTags, fakeTiers, post, renderAdminApp, tag } from "@test-utils/acceptance"; +import { postsListScreen } from "./posts-list.screen"; + +const FLAG_ON = { labs: { postsListReact: true } }; + +/** + * Bulk actions send an NQL *filter*, never a list of ids — that is what lets + * Cmd+A cover posts that were never loaded. These tests assert the outgoing + * request, because the filter string is the thing with the blast radius: it is + * appended straight to `DELETE /posts/?filter=`. + */ +describe("Posts list bulk actions", () => { + beforeEach(() => { + fakePostsListScreen(); + }); + + it("deletes one post by id, not by the list filter", async () => { + const target = post({ title: "Doomed", status: "published" }); + fakePosts([target]); + const deletion = fakeAdminEndpoint("DELETE", /^\/posts\//, {}); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Delete").click(); + await postsListScreen.confirmButton("Delete").click(); + + await expect.poll(() => deletion.requests.length).toBe(1); + expect(new URL(deletion.requests[0].url).searchParams.get("filter")) + .toBe(`id:['${target.id}']`); + }); + + /** + * The reason selection is inverted rather than enumerated. On a site with + * thousands of posts, Cmd+A then Delete must send the *filter* — enumerating + * ids would silently spare every post that had not been scrolled into view, + * and would build a URL long enough to be rejected outright. + */ + it("deletes everything matching the filter after Cmd+A, not the loaded ids", async () => { + fakePosts([ + post({ title: "First", status: "published" }), + post({ title: "Second", status: "published" }) + ]); + const deletion = fakeAdminEndpoint("DELETE", /^\/posts\//, {}); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().nth(1)).toBeVisible(); + + await userEvent.keyboard("{Meta>}a{/Meta}"); + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Delete").click(); + await postsListScreen.confirmButton("Delete").click(); + + await expect.poll(() => deletion.requests.length).toBe(1); + const filter = new URL(deletion.requests[0].url).searchParams.get("filter"); + expect(filter).toBe("status:published"); + expect(filter).not.toContain("id:"); + }); + + // Cmd+A minus a few is the shape that most easily goes wrong: it has to + // subtract the deselected ids from the list filter, parenthesised. + it("subtracts deselected posts from the filter", async () => { + const spared = post({ title: "Spared", status: "published" }); + fakePosts([post({ title: "First", status: "published" }), spared]); + const deletion = fakeAdminEndpoint("DELETE", /^\/posts\//, {}); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().nth(1)).toBeVisible(); + + await userEvent.keyboard("{Meta>}a{/Meta}"); + await postsListScreen.listItems().nth(1).click({ modifiers: ["Meta"] }); + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Delete").click(); + await postsListScreen.confirmButton("Delete").click(); + + await expect.poll(() => deletion.requests.length).toBe(1); + expect(new URL(deletion.requests[0].url).searchParams.get("filter")) + .toBe(`(status:published)+id:-['${spared.id}']`); + }); + + /** + * The client-side prune. Unpublishing a post while viewing `?type=published` + * takes it out of the list straight away — no refetch, no lost scroll + * position — because NQL is re-run in the browser against the list's own + * filter. See prune-non-matching-posts.ts. + * + * `type=published` rather than `type=featured` deliberately: featured fans + * out across all four status buckets, and the fakes don't implement NQL, so + * every bucket would serve the same rows and the counts would be fiction. + */ + describe("pruning after an edit", () => { + it("removes an unpublished post from a published-only view", async () => { + fakePosts([ + post({ title: "Live one", status: "published" }), + post({ title: "Live two", status: "published" }) + ]); + fakeAdminEndpoint("PUT", /^\/posts\/bulk/, {}); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().nth(1)).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Unpublish").click(); + await postsListScreen.confirmButton("Unpublish").click(); + + await expect(postsListScreen.listItems()).toHaveCount(1); + await expect.element(postsListScreen.listItems().first()).toHaveTextContent("Live two"); + }); + + /** + * The unfiltered list shows every status, so the *list-wide* filter + * still matches an unpublished row — it is the published *bucket's* + * filter the row no longer satisfies. Pruning against the wrong one + * left the row sitting in the published section labelled Draft. + */ + it("moves an unpublished post out of the published bucket on the unfiltered list", async () => { + fakePosts(({ filter }) => { + if (filter?.includes("status:draft")) { + return [post({ title: "Existing draft", status: "draft", featured: false })]; + } + if (filter?.includes("status:scheduled")) { + return []; + } + return [post({ title: "Was published", status: "published", featured: false })]; + }); + fakeAdminEndpoint("PUT", /^\/posts\/bulk/, {}); + await renderAdminApp("/posts", FLAG_ON); + await expect.element(postsListScreen.listItems().nth(1)).toBeVisible(); + + await postsListScreen.listItems().nth(1).click({ button: "right" }); + await postsListScreen.contextMenuItem("Unpublish").click(); + await postsListScreen.confirmButton("Unpublish").click(); + + await expect(postsListScreen.listItems()).toHaveCount(1); + await expect.element(postsListScreen.listItems().first()).toHaveTextContent("Existing draft"); + }); + + // The rule with the worst failure mode: an edit must never remove rows + // it did not touch, however the filter reads. + it("leaves the posts it did not edit alone", async () => { + fakePosts([ + post({ title: "Edited", status: "published" }), + // Already a draft, so it does not match `status:published` + // either — but the action never touched it, so it stays. + post({ title: "Untouched", status: "draft" }) + ]); + fakeAdminEndpoint("PUT", /^\/posts\/bulk/, {}); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().nth(1)).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Unpublish").click(); + await postsListScreen.confirmButton("Unpublish").click(); + + await expect(postsListScreen.listItems()).toHaveCount(1); + await expect.element(postsListScreen.listItems().first()).toHaveTextContent("Untouched"); + }); + }); + + /** + * The edit path had no request assertions at all, so a typo in the action + * verb — `feature` for `unfeature`, say — would pass every other test in + * this file, because the prune tests are satisfied entirely by the local + * edit and never look at what was sent. + */ + describe("the outgoing bulk edit", () => { + it.each([ + {label: "Unpublish", verb: "unpublish", type: "published", confirm: true}, + {label: "Unschedule", verb: "unschedule", type: "scheduled", confirm: true}, + {label: "Feature", verb: "feature", type: "draft", confirm: false} + ] as const)("sends $verb", async ({label, verb, type, confirm}) => { + // `featured` is randomised by the builder, and it decides whether + // the menu offers Feature or Unfeature — pin it, or this test is + // flaky by construction. + fakePosts([post({ title: "Target", status: type, featured: false })]); + const edit = fakeAdminEndpoint("PUT", /^\/posts\/bulk/, {}); + await renderAdminApp(`/posts?type=${type}`, FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem(label).click(); + + if (confirm) { + await postsListScreen.confirmButton(label).click(); + } + + await expect.poll(() => edit.requests.length).toBe(1); + expect(edit.requests[0].body).toEqual({bulk: {action: verb, meta: {}}}); + }); + + it("sends the chosen tags for Add a tag", async () => { + fakePosts([post({ title: "Target", status: "draft" })]); + fakeTags([tag({ id: "t1", name: "News", slug: "news" })]); + const edit = fakeAdminEndpoint("PUT", /^\/posts\/bulk/, {}); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Add a tag").click(); + await postsListScreen.tagPickerField().click(); + await postsListScreen.tagOption("News").click(); + // The list floats over the dialog's footer, so it has to be + // dismissed before the confirm button can be reached — clicking + // outside it, as a user would. + await postsListScreen.dialogHeading("Add tags").click(); + await postsListScreen.dialogButton("Add").click(); + + await expect.poll(() => edit.requests.length).toBe(1); + expect(edit.requests[0].body).toEqual({ + bulk: {action: "addTag", meta: {tags: [{id: "t1", name: "News", slug: "news"}]}} + }); + }); + + /** + * The dialog can only add, so it shows only what you are adding. An + * earlier version listed the post's existing tags ticked and disabled, + * which read as a set you could edit while offering no way to untick + * one. + */ + it("does not offer the tags a post already has", async () => { + fakePosts([post({ + title: "Target", + status: "draft", + tags: [tag({id: "t1", name: "News", slug: "news"})] + })]); + fakeTags([tag({ id: "t1", name: "News", slug: "news" })]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Add a tag").click(); + await postsListScreen.tagPickerField().click(); + + // Present to pick, but nothing is pre-selected: no chip, and the + // confirm button stays disabled until you choose something. + await expect.element(postsListScreen.tagOption("News")).toBeVisible(); + await expect.element(postsListScreen.dialogButton("Add")).toBeDisabled(); + }); + + // Ember allows creating a tag inline; the server creates one from a + // name it does not recognise, so no id is sent. + it("sends a typed tag that does not exist yet", async () => { + fakePosts([post({ title: "Target", status: "draft" })]); + fakeTags([]); + const edit = fakeAdminEndpoint("PUT", /^\/posts\/bulk/, {}); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Add a tag").click(); + await postsListScreen.tagSearchInput().fill("Fresh tag"); + await postsListScreen.tagOption(/Create/).click(); + await postsListScreen.dialogHeading("Add tags").click(); + await postsListScreen.dialogButton("Add").click(); + + await expect.poll(() => edit.requests.length).toBe(1); + expect(edit.requests[0].body).toEqual({ + bulk: {action: "addTag", meta: {tags: [{name: "Fresh tag"}]}} + }); + }); + + /** + * A tag typed here is created server-side as a side effect of the post + * save, so nothing in the tag cache knows it exists. Without this the + * new tag is missing from the filter and from this dialog until the + * browser is refreshed. + */ + it("refetches tags after creating one", async () => { + fakePosts([post({ title: "Target", status: "draft" })]); + const tags = fakeTags([]); + fakeAdminEndpoint("PUT", /^\/posts\/bulk/, {}); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Add a tag").click(); + const before = tags.requests.length; + await postsListScreen.tagSearchInput().fill("Fresh tag"); + await postsListScreen.tagOption(/Create/).click(); + await postsListScreen.dialogHeading("Add tags").click(); + await postsListScreen.dialogButton("Add").click(); + + await expect.poll(() => tags.requests.length).toBeGreaterThan(before); + }); + + /** + * Names are not unique — a site can carry two tags called "broaf" that + * differ only by slug. Selection used to compare names, so clicking one + * ticked both and sent a tag the user had not chosen. + */ + it("selects only the tag clicked when two share a name", async () => { + fakePosts([post({ title: "Target", status: "draft" })]); + fakeTags([ + tag({ id: "t1", name: "broaf", slug: "broaf" }), + tag({ id: "t2", name: "broaf", slug: "broaf-2" }) + ]); + const edit = fakeAdminEndpoint("PUT", /^\/posts\/bulk/, {}); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Add a tag").click(); + await postsListScreen.tagPickerField().click(); + await postsListScreen.tagOption(/broaf-2/).click(); + await postsListScreen.dialogHeading("Add tags").click(); + await postsListScreen.dialogButton("Add").click(); + + await expect.poll(() => edit.requests.length).toBe(1); + expect(edit.requests[0].body).toEqual({ + bulk: {action: "addTag", meta: {tags: [{id: "t2", name: "broaf", slug: "broaf-2"}]}} + }); + }); + + /** + * Radix reads Escape as "dismiss the dialog", so the key you press to + * back out of the open list was also the key that discarded every tag + * picked so far. It reaches the dialog only when there is nothing to + * lose. + */ + it("keeps the dialog open on Escape once a tag is picked", async () => { + fakePosts([post({ title: "Target", status: "draft" })]); + fakeTags([tag({ id: "t1", name: "News", slug: "news" })]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Add a tag").click(); + await postsListScreen.tagPickerField().click(); + await postsListScreen.tagOption("News").click(); + + await userEvent.keyboard("{Escape}"); + await expect.element(postsListScreen.dialogButton("Add")).toBeVisible(); + + await userEvent.keyboard("{Escape}"); + await expect.element(postsListScreen.dialogButton("Add")).toBeVisible(); + }); + + it("closes on Escape while the field is still empty", async () => { + fakePosts([post({ title: "Target", status: "draft" })]); + fakeTags([tag({ id: "t1", name: "News", slug: "news" })]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Add a tag").click(); + await postsListScreen.tagPickerField().click(); + + // First closes the list, second reaches the dialog. + await userEvent.keyboard("{Escape}"); + await userEvent.keyboard("{Escape}"); + + await expect.element(postsListScreen.dialogButton("Add")).not.toBeInTheDocument(); + }); + + it("sends the chosen visibility for Change access", async () => { + fakePosts([post({ title: "Target", status: "draft" })]); + // The modal offers a tier picker once "Specific tier(s)" is chosen. + fakeTiers([]); + const edit = fakeAdminEndpoint("PUT", /^\/posts\/bulk/, {}); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Change access").click(); + await postsListScreen.dialogButton("Save").click(); + + await expect.poll(() => edit.requests.length).toBe(1); + expect(edit.requests[0].body).toEqual({ + bulk: {action: "access", meta: {visibility: "public", tiers: []}} + }); + }); + }); +}); diff --git a/apps/admin/src/posts/list/posts-list-celebration.acceptance.test.tsx b/apps/admin/src/posts/list/posts-list-celebration.acceptance.test.tsx new file mode 100644 index 00000000000..2c898941244 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-celebration.acceptance.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { fakePages, fakePosts, fakePostsListScreen, post, renderAdminApp } from "@test-utils/acceptance"; +import { postsListScreen } from "./posts-list.screen"; + +const FLAG_ON = { labs: { postsListReact: true } }; + +/** + * The post-publish celebration. The Ember editor writes a localStorage key and + * navigates to the list, which reads it on mount — the editor stays Ember on + * both sides of the flag, so only the reader moved. + */ +describe("Posts list publish celebration", () => { + beforeEach(() => { + fakePostsListScreen(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("celebrates a post the editor just published", async () => { + const published = post({ title: "Just published", status: "published" }); + fakePosts([published]); + localStorage.setItem("ghost-last-published-post", JSON.stringify({ id: published.id, type: "post" })); + + await renderAdminApp("/posts?type=published", FLAG_ON); + + await expect.element(postsListScreen.celebrationModal()).toBeVisible(); + await expect.element(postsListScreen.celebrationModal()).toHaveTextContent("Just published"); + }); + + /** + * Ember browses whichever resource the editor named — `store.query(post.type, …)` + * where type is 'post' or 'page'. Reading a page back off the posts + * endpoint 404s, so publishing a page would never celebrate at all. + */ + it("celebrates a page the editor just published", async () => { + const page = post({ title: "A published page", status: "published" }); + fakePages([page]); + fakePosts([]); + localStorage.setItem("ghost-last-published-post", JSON.stringify({ id: page.id, type: "page" })); + + await renderAdminApp("/pages?type=published", FLAG_ON); + + await expect.element(postsListScreen.celebrationModal()).toBeVisible(); + await expect.element(postsListScreen.celebrationModal()).toHaveTextContent("A published page"); + }); + + it("says All set! for a scheduled post rather than celebrating a publish", async () => { + const scheduled = post({ title: "Going out later", status: "scheduled" }); + fakePosts([scheduled]); + localStorage.setItem("ghost-last-scheduled-post", JSON.stringify({ id: scheduled.id, type: "post" })); + + await renderAdminApp("/posts?type=scheduled", FLAG_ON); + + await expect.element(postsListScreen.celebrationModal()).toHaveTextContent("All set!"); + }); + + /** + * The key is cleared as it is read, before anything is fetched. Ember + * clears it after the modal opens, so a failed request leaves it in place + * and the celebration re-fires on every visit until one happens to succeed. + */ + it("consumes the key so the same post is not celebrated twice", async () => { + const published = post({ title: "Just published", status: "published" }); + fakePosts([published]); + localStorage.setItem("ghost-last-published-post", JSON.stringify({ id: published.id, type: "post" })); + + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.celebrationModal()).toBeVisible(); + + expect(localStorage.getItem("ghost-last-published-post")).toBeNull(); + }); + + it("shows nothing when the editor left no key", async () => { + fakePosts([post({ title: "An ordinary post", status: "published" })]); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await expect(postsListScreen.celebrationModal()).toHaveCount(0); + }); + + // Malformed JSON must not throw on every mount for the rest of the session. + it("survives a key it cannot parse, and clears it", async () => { + fakePosts([post({ title: "An ordinary post", status: "published" })]); + localStorage.setItem("ghost-last-published-post", "not json"); + + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await expect(postsListScreen.celebrationModal()).toHaveCount(0); + expect(localStorage.getItem("ghost-last-published-post")).toBeNull(); + }); +}); diff --git a/apps/admin/src/posts/list/posts-list-context-menu.acceptance.test.tsx b/apps/admin/src/posts/list/posts-list-context-menu.acceptance.test.tsx new file mode 100644 index 00000000000..5818df10d03 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-context-menu.acceptance.test.tsx @@ -0,0 +1,322 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { fakeAdminEndpoint, fakePosts, fakePostsListScreen, post, renderAdminApp } from "@test-utils/acceptance"; +import { metaMouseDown, postsListScreen } from "./posts-list.screen"; + +const FLAG_ON = { labs: { postsListReact: true } }; + +// Captured before any test stubs it, so afterEach can put it back. Normally +// undefined: `clipboard` lives on the prototype, not as an own property. +const originalClipboard = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + +/** + * Records what the app hands to the clipboard. Recorded rather than read + * back: clipboard *read* permission is denied in this harness, and the value + * passed to `writeText` is the behaviour under test. + */ +function recordClipboard(): string[] { + const copied: string[] = []; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: (text: string) => { copied.push(text); return Promise.resolve(); } } + }); + return copied; +} + +/** + * The right-click menu. Which items it offers for a given selection is unit + * tested in post-context-menu-items.test.ts; what these cover is the wiring — + * that a right-click opens it at all, that it describes the selection rather + * than the row under the cursor, and that the actions do what they say. + */ +describe("Posts list context menu", () => { + beforeEach(() => { + fakePostsListScreen(); + }); + + afterEach(() => { + if (originalClipboard) { + Object.defineProperty(navigator, "clipboard", originalClipboard); + } else { + delete (navigator as { clipboard?: unknown }).clipboard; + } + }); + + it("opens on right-click", async () => { + fakePosts([post({ title: "A draft", status: "draft" })]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + + await expect.element(postsListScreen.contextMenu()).toBeVisible(); + }); + + /** + * Regression: `separated` is decided from the complete item list, but the + * gift link is filtered out at render — so a selection where the gift link + * would have been present but is dropped left Unpublish first in the menu + * still carrying its separator, and Radix drew a rule across the top of the + * menu with nothing above it. + */ + it("draws no separator above the first item", async () => { + // One bucket only: the fakes serve the same rows to every status + // bucket, and duplicate rows make selection counts unreadable. + fakePosts([ + post({ title: "Gated post", status: "published", visibility: "paid", featured: false }), + post({ title: "Also published", status: "published", visibility: "public", featured: false }) + ]); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().nth(1)).toBeVisible(); + + // Two rows selected, so the gift link — a single-post action — is + // filtered out even though the published row qualifies for it. + const rows = postsListScreen.listItems(); + metaMouseDown(rows.nth(0).element()); + metaMouseDown(rows.nth(1).element()); + // Guards the arrange step: with a broken selection the right-click + // falls back to a transient single row, the gift link renders, and + // the assertion below goes green without exercising the bug. + await expect.poll(() => postsListScreen.selectedTitles()).toHaveLength(2); + + await rows.nth(0).click({ button: "right" }); + await expect.element(postsListScreen.contextMenu()).toBeVisible(); + + const menu = postsListScreen.contextMenu().element(); + expect(menu.firstElementChild?.getAttribute("role")).not.toBe("separator"); + }); + + // The rule belongs to the gift link, not to Unpublish: a single published + // *public* post offers no gift link, and Ember draws no rule there. + it("separates Unpublish from the gift link only when the gift link is shown", async () => { + fakePosts([post({ title: "Public post", status: "published", visibility: "public" })]); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await expect.element(postsListScreen.contextMenuItem("Unpublish")).toBeVisible(); + + const unpublish = postsListScreen.contextMenuItem("Unpublish").element(); + expect(unpublish.previousElementSibling?.getAttribute("role")).not.toBe("separator"); + }); + + it("separates Unpublish from the gift link when it is shown", async () => { + fakePosts([post({ title: "Gated post", status: "published", visibility: "paid" })]); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await expect.element(postsListScreen.contextMenuItem("Share as a gift")).toBeVisible(); + + const unpublish = postsListScreen.contextMenuItem("Unpublish").element(); + expect(unpublish.previousElementSibling?.getAttribute("role")).toBe("separator"); + }); + + // The whole point of the transient selection: right-clicking a row nothing + // has selected acts on that row alone. + it("offers actions for the right-clicked row when nothing is selected", async () => { + fakePosts([post({ title: "A draft", status: "draft" })]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + + await expect.element(postsListScreen.contextMenuItem("Copy preview link")).toBeVisible(); + await expect.element(postsListScreen.contextMenuItem("Duplicate")).toBeVisible(); + }); + + /** + * Ember's "Copy preview link" copies `post.url` — the public permalink, + * which for a draft points at a page that does not exist yet. It is the + * same string its "Copy link to post" action copies, so the two menu items + * are indistinguishable. Fixed here rather than ported; flagged for Ember + * separately. + */ + it("copies the preview link, not the public permalink", async () => { + const draft = post({ title: "A draft", status: "draft", url: "https://example.com/a-draft/" }); + fakePosts([draft]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + const copied = recordClipboard(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Copy preview link").click(); + + await expect.poll(() => copied).toEqual([`http://test.com/p/${draft.uuid}/`]); + expect(copied[0]).not.toBe(draft.url); + }); + + it("copies the public permalink for a published post", async () => { + const published = post({ title: "Live", status: "published", url: "https://example.com/live/" }); + fakePosts([published]); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + const copied = recordClipboard(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Copy link to post").click(); + + await expect.poll(() => copied).toEqual(["https://example.com/live/"]); + }); + + /** + * Duplicating always produces a draft, whatever the source was. The point + * of the test is that the copy shows up on its own — the user shouldn't + * have to reload to see what they just made, which is a bug we already hit + * once on this screen when creating a page. + * + * The fake starts serving the copy once the copy endpoint has been called, + * which is what the real server does. + */ + it("duplicates a post and shows the copy straight away", async () => { + const original = post({ title: "Original", status: "published" }); + const duplicate = post({ title: "Original (Copy)", status: "draft" }); + let duplicated = false; + + fakePosts(() => (duplicated ? [duplicate, original] : [original])); + fakeAdminEndpoint("POST", new RegExp(`/posts/${original.id}/copy`), () => { + duplicated = true; + return { posts: [duplicate] }; + }); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Duplicate").click(); + + await expect(postsListScreen.listItems()).toHaveCount(2); + await expect.element(postsListScreen.listItems().first()).toHaveTextContent("Original (Copy)"); + // Also pins the key-to-message mapping: swapping two `notify` calls in + // the actions hook would otherwise go unnoticed. + await expect.element(postsListScreen.toastWithText("Post duplicated")).toBeVisible(); + }); + + /** + * A gift link shares a gated post with someone who isn't a member. Ember's + * menu hands off to this same React modal over the state bridge; the React + * list opens it directly, so there is one modal and one set of eligibility + * rules (`@/shared/gift-link`) behind both. + */ + describe("share as a gift", () => { + const gated = post({ title: "Members only", status: "published", visibility: "paid" }); + + it("offers it for a gated published post", async () => { + fakePosts([gated]); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + + await expect.element(postsListScreen.contextMenuItem("Share as a gift")).toBeVisible(); + }); + + // Nothing to gift — anyone can already read it. + it("does not offer it for a public post", async () => { + fakePosts([post({ title: "Open to all", status: "published", visibility: "public" })]); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + + await expect.element(postsListScreen.contextMenu()).toBeVisible(); + await expect(postsListScreen.contextMenuItem("Share as a gift")).toHaveCount(0); + }); + + it("opens the gift-link modal", async () => { + fakePosts([gated]); + // The modal ensures a gift link for the post as soon as it opens. + fakeAdminEndpoint("PUT", new RegExp(`/posts/${gated.id}/gift_links`), { + gift_links: [{ id: "g1", url: `https://example.com/members/gift/${gated.id}` }] + }); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Share as a gift").click(); + + await expect.element(postsListScreen.giftLinkModal()).toBeVisible(); + }); + }); + + /** + * The menu describes the whole selection. Right-clicking a row that is + * already part of one must not collapse it down to that row — that is how + * every bulk action is reached. + */ + it("keeps a multi-row selection and drops the single-post actions", async () => { + fakePosts([ + post({ title: "First", status: "published" }), + post({ title: "Second", status: "published" }) + ]); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().nth(1)).toBeVisible(); + + await postsListScreen.listItems().nth(0).click({ modifiers: ["Meta"] }); + await postsListScreen.listItems().nth(1).click({ modifiers: ["Meta"] }); + await postsListScreen.listItems().nth(1).click({ button: "right" }); + + await expect.element(postsListScreen.contextMenu()).toBeVisible(); + // Both rows are still selected, so the per-post actions are gone... + await expect(postsListScreen.contextMenuItem("Copy link to post")).toHaveCount(0); + await expect(postsListScreen.contextMenuItem("Duplicate")).toHaveCount(0); + // ...while the ones that work on many rows remain. + await expect.element(postsListScreen.contextMenuItem("Add a tag")).toBeVisible(); + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(2); + }); + + /** + * Every item the menu shows must do something. The menu renders whatever + * `getPostContextMenuItems` returns and disables anything absent from + * `IMPLEMENTED_POST_ACTIONS`, so adding an item without wiring it would + * show up here as a disabled entry rather than as a silent no-op. + */ + it("offers no item that does nothing", async () => { + fakePosts([post({ title: "A draft", status: "draft" })]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await expect.element(postsListScreen.contextMenu()).toBeVisible(); + + const disabled = postsListScreen.contextMenu() + .elements() + .flatMap(menu => [...menu.querySelectorAll('[role="menuitem"]')]) + .filter(item => item.getAttribute("data-disabled") !== null) + .map(item => item.textContent); + + expect(disabled).toEqual([]); + }); + + /** + * Ember surfaces API failures through `notifications.showAPIError`. Here the + * framework's global handler does it, so the message is generic rather than + * action-specific — but the failure is visible, which is the contract that + * matters. `usePostActions` also wraps the switch in a try/catch, which + * covers the paths the framework doesn't see at all: `clipboard.writeText` + * rejects whenever the document isn't focused. + */ + it("reports a failed duplicate instead of failing silently", async () => { + const original = post({ title: "Original", status: "published" }); + fakePosts([original]); + fakeAdminEndpoint( + "POST", + new RegExp(`/posts/${original.id}/copy`), + { errors: [{ message: "Could not duplicate this post." }] }, + { status: 500 } + ); + await renderAdminApp("/posts?type=published", FLAG_ON); + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + await postsListScreen.listItems().first().click({ button: "right" }); + await postsListScreen.contextMenuItem("Duplicate").click(); + + // The framework's own error handling surfaces this, with its standard + // wording rather than anything this screen chooses. What matters is + // that the user is told at all... + await expect.element(postsListScreen.toastWithText(/something went wrong/i)).toBeVisible(); + // ...and that success is not also claimed. + await expect(postsListScreen.toastWithText(/duplicated/i)).toHaveCount(0); + }); +}); diff --git a/apps/admin/src/posts/list/posts-list-data.acceptance.test.tsx b/apps/admin/src/posts/list/posts-list-data.acceptance.test.tsx new file mode 100644 index 00000000000..a01493792b7 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-data.acceptance.test.tsx @@ -0,0 +1,196 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { currentUserResponse, fakePages, fakePosts, fakePostsListScreen, post, renderAdminApp, staffRole } from "@test-utils/acceptance"; +import { postsListScreen } from "./posts-list.screen"; +import type { StaffRoleName } from "@tryghost/test-data"; + +const FLAG_ON = { labs: { postsListReact: true } }; + +function asRole(name: StaffRoleName, slug: string) { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name })]; + me.users[0].slug = slug; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; +} + +/** + * The list is three queries, not one — scheduled, then drafts, then + * published/sent — each with its own default sort, drained in order. This + * covers the whole data path: URL params in, three correctly-filtered requests + * out, rows rendered in bucket order. + * + * The fakes don't implement NQL (see THE RULE in test-utils), so each bucket is + * served by declaring a response as a function of the outgoing filter. + */ + +const SCHEDULED = post({ title: "Scheduled one", status: "scheduled" }); +const DRAFT = post({ title: "Draft one", status: "draft" }); +const PUBLISHED = post({ title: "Published one", status: "published" }); + +function byBucket(filter: string | undefined) { + if (filter?.includes("status:scheduled")) { + return [SCHEDULED]; + } + if (filter?.includes("status:draft")) { + return [DRAFT]; + } + return [PUBLISHED]; +} + +describe("Posts list data", () => { + // The filter bar mounts with the screen and probes these to resolve any + // author/tag slug in the URL into a name. + beforeEach(() => { + fakePostsListScreen(); + }); + + it("runs one query per status bucket", async () => { + const postsApi = fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts", FLAG_ON); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + const filters = postsApi.requests.map(request => request.filter); + expect(filters).toContain("status:scheduled"); + expect(filters).toContain("status:draft"); + expect(filters).toContain("status:[published,sent]"); + }); + + it("renders buckets in order: scheduled, then drafts, then published", async () => { + fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts", FLAG_ON); + + await expect.element(postsListScreen.listItems().nth(2)).toBeVisible(); + await expect(postsListScreen.listItems()).toHaveCount(3); + + await expect.element(postsListScreen.listItems().nth(0)).toHaveTextContent("Scheduled one"); + await expect.element(postsListScreen.listItems().nth(1)).toHaveTextContent("Draft one"); + await expect.element(postsListScreen.listItems().nth(2)).toHaveTextContent("Published one"); + }); + + it("sorts drafts by recently updated and the rest by publish date", async () => { + const postsApi = fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts", FLAG_ON); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + const orderFor = (status: string) => postsApi.requests + .find(request => request.filter?.includes(status)) + ?.order; + + // Drafts have no published_at, so they sort by when they were touched. + expect(orderFor("status:draft")).toBe("updated_at desc"); + expect(orderFor("status:scheduled")).toBe("published_at desc"); + expect(orderFor("status:[published,sent]")).toBe("published_at desc"); + }); + + it("runs a single query when the type filter picks one status", async () => { + const postsApi = fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts?type=draft", FLAG_ON); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + expect(postsApi.requests.map(request => request.filter)).toEqual(["status:draft"]); + }); + + it("carries the other filter params into every bucket", async () => { + const postsApi = fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts?tag=news&visibility=public", FLAG_ON); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + postsApi.requests.forEach((request) => { + expect(request.filter).toContain("tag:news"); + expect(request.filter).toContain("visibility:public"); + }); + }); + + // `featured` is not a status — it means every status, and featured. + it("treats type=featured as every bucket plus featured:true", async () => { + const postsApi = fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts?type=featured", FLAG_ON); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + expect(postsApi.requests).toHaveLength(3); + postsApi.requests.forEach((request) => { + expect(request.filter).toContain("featured:true"); + }); + }); + + it("lets an explicit sort override every bucket", async () => { + const postsApi = fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts?order=published_at%20asc", FLAG_ON); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + postsApi.requests.forEach((request) => { + expect(request.order).toBe("published_at asc"); + }); + }); + + // A posts URL is a saved view's identity; rewriting it would corrupt the + // view and desync from the Ember screen, which reads the same params. + it("leaves the URL exactly as it was given", async () => { + fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts?type=draft&tag=news&order=updated_at+desc", FLAG_ON); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + expect(window.location.hash).toContain("type=draft"); + expect(window.location.hash).toContain("tag=news"); + expect(window.location.hash).toContain("order=updated_at"); + }); + + // Ember forces authors and contributors onto their own posts regardless of + // the author param. Without this they'd see everyone's. + describe.each([ + { role: "Author" as const }, + { role: "Contributor" as const } + ])("as $role", ({ role }) => { + it("scopes every bucket to the signed-in user's own posts", async () => { + const postsApi = fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts", asRole(role, "just-me")); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + postsApi.requests.forEach((request) => { + expect(request.filter).toContain("authors:just-me"); + }); + }); + + it("ignores an author param pointing at someone else", async () => { + const postsApi = fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts?author=someone-else", asRole(role, "just-me")); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + postsApi.requests.forEach((request) => { + expect(request.filter).toContain("authors:just-me"); + expect(request.filter).not.toContain("someone-else"); + }); + }); + }); + + it("honours the author param for roles that see everything", async () => { + const postsApi = fakePosts(query => byBucket(query.filter)); + await renderAdminApp("/posts?author=someone-else", asRole("Administrator", "admin-user")); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + postsApi.requests.forEach((request) => { + expect(request.filter).toContain("authors:someone-else"); + }); + }); + + it("queries the pages endpoint for the pages screen", async () => { + const pagesApi = fakePages(query => byBucket(query.filter)); + const postsApi = fakePosts([]); + await renderAdminApp("/pages", FLAG_ON); + + await expect.element(postsListScreen.listItems().first()).toBeVisible(); + + expect(pagesApi.requests.length).toBeGreaterThan(0); + expect(postsApi.requests).toHaveLength(0); + }); +}); diff --git a/apps/admin/src/posts/list/posts-list-filters.acceptance.test.tsx b/apps/admin/src/posts/list/posts-list-filters.acceptance.test.tsx new file mode 100644 index 00000000000..6316b115688 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-filters.acceptance.test.tsx @@ -0,0 +1,192 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + currentRoute, + currentUserResponse, + fakeAdminEndpoint, + fakePosts, + fakePostsListScreen, + post, + renderAdminApp, + staffRole, + staffUser, + tag +} from "@test-utils/acceptance"; +import { postsListScreen } from "./posts-list.screen"; +import type { StaffRoleName } from "@tryghost/test-data"; + +const FLAG_ON = { labs: { postsListReact: true } }; + +function asRole(name: StaffRoleName) { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name })]; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; +} + +/** + * The filter bar. What matters most is that chips and the URL stay in lockstep: + * the five params are what sidebar saved views persist, and the Ember screen + * reads the same ones while both implementations exist. + */ +describe("Posts list filters", () => { + // The author and tag fields hydrate their selected values as soon as the + // bar mounts, so both endpoints are probed on every render here. + beforeEach(() => { + fakePostsListScreen(); + }); + + it("hydrates a chip from the URL", async () => { + fakePosts([post({ title: "A draft", status: "draft" })]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + + await expect.element(postsListScreen.filterBar()).toBeVisible(); + await expect.element(postsListScreen.filterBar()).toHaveTextContent("Draft posts"); + }); + + // The slug-to-name lookup is its own request, and `fakeTags` only declares + // semantics for `visibility` filters — so declare this one explicitly + // rather than teaching a fake to run NQL. + it("resolves a tag slug in the URL to its name", async () => { + fakePosts([post({ title: "Tagged", status: "published" })]); + fakeAdminEndpoint("GET", /^\/tags\/\?.*slug/, { + tags: [tag({ name: "Engineering", slug: "engineering" })] + }); + await renderAdminApp("/posts?tag=engineering", FLAG_ON); + + // The URL carries a slug; the chip has to read as the tag's name. + await expect.element(postsListScreen.filterBar()).toHaveTextContent("Engineering"); + }); + + // Isolated behind a slug-matching endpoint like the tag case: `fakeUsers` + // is passthrough, so serving Ada from the plain browse too would let this + // pass with hydration removed entirely. + it("resolves an author slug in the URL to their name", async () => { + fakePosts([post({ title: "Authored", status: "published" })]); + fakeAdminEndpoint("GET", /^\/users\/\?.*slug/, { + users: [staffUser({ name: "Ada Lovelace", slug: "ada" })] + }); + await renderAdminApp("/posts?author=ada", FLAG_ON); + + await expect.element(postsListScreen.filterBar()).toHaveTextContent("Ada Lovelace"); + }); + + // A saved view can point at a tag that was later renamed or deleted. + // The chip has to say *something* — without a fallback option Shade shows + // "Select…", so the filter vanishes from the UI while staying in the URL + // and the list looks empty for no visible reason. + it("shows an unknown-value chip rather than an empty one", async () => { + fakePosts([]); + fakeAdminEndpoint("GET", /^\/tags\/\?.*slug/, { tags: [] }); + await renderAdminApp("/posts?tag=deleted-tag", FLAG_ON); + + await expect.element(postsListScreen.filterBar()).toHaveTextContent("Unknown tag"); + await expect.element(postsListScreen.filterBar()).not.toHaveTextContent("Select"); + await expect.poll(currentRoute).toBe("/posts?tag=deleted-tag"); + }); + + it("shows an unknown-value chip for an unrecognised type", async () => { + fakePosts([]); + await renderAdminApp("/posts?type=bogus", FLAG_ON); + + await expect.element(postsListScreen.filterBar()).toHaveTextContent("Unknown type"); + await expect.poll(currentRoute).toBe("/posts?type=bogus"); + }); + + // Each field maps to one URL param, which holds one value. Shade defaults + // to allowing several chips per field, and the serializer keeps the last — + // so without allowMultiple={false} a user could sit looking at two "Post + // type" chips while only one of them was in the URL or a saved view. + it("does not offer a field that already has a chip", async () => { + fakePosts([]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + + await postsListScreen.addFilterButton().click(); + + await expect.element(postsListScreen.filterFieldOption("Tag")).toBeVisible(); + await expect(postsListScreen.filterFieldOption("Post type")).toHaveCount(0); + }); + + describe("the sort control", () => { + it("shows the default when no order is set", async () => { + fakePosts([post({ title: "One", status: "published" })]); + await renderAdminApp("/posts", FLAG_ON); + + await expect.element(postsListScreen.sortButton()).toHaveTextContent("Newest first"); + }); + + it("names the order from the URL", async () => { + fakePosts([post({ title: "One", status: "published" })]); + await renderAdminApp("/posts?order=updated_at+desc", FLAG_ON); + + await expect.element(postsListScreen.sortButton()).toHaveTextContent("Recently updated"); + }); + + it("writes the chosen order to the URL", async () => { + fakePosts([post({ title: "One", status: "published" })]); + await renderAdminApp("/posts", FLAG_ON); + + await postsListScreen.sortButton().click(); + await postsListScreen.sortOption("Oldest first").click(); + + await expect.poll(currentRoute).toBe("/posts?order=published_at+asc"); + }); + + // "Newest first" is the absence of the param, not a value. + it("drops the param when returning to the default", async () => { + fakePosts([post({ title: "One", status: "published" })]); + await renderAdminApp("/posts?order=published_at+asc", FLAG_ON); + + await postsListScreen.sortButton().click(); + await postsListScreen.sortOption("Newest first").click(); + + await expect.poll(currentRoute).toBe("/posts"); + }); + + it("leaves the filters alone when the sort changes", async () => { + fakePosts([post({ title: "One", status: "draft" })]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + + await postsListScreen.sortButton().click(); + await postsListScreen.sortOption("Oldest first").click(); + + await expect.poll(currentRoute).toBe("/posts?type=draft&order=published_at+asc"); + }); + }); + + // Ember hides these for roles that can only see their own posts. + describe("role restrictions", () => { + it("offers only the type filter to a contributor", async () => { + fakePosts([]); + await renderAdminApp("/posts", asRole("Contributor")); + + await postsListScreen.addFilterButton().click(); + + await expect.element(postsListScreen.filterFieldOption("Post type")).toBeVisible(); + await expect(postsListScreen.filterFieldOption("Author")).toHaveCount(0); + await expect(postsListScreen.filterFieldOption("Access")).toHaveCount(0); + await expect(postsListScreen.filterFieldOption("Tag")).toHaveCount(0); + }); + + it("hides the author filter from an author", async () => { + fakePosts([]); + await renderAdminApp("/posts", asRole("Author")); + + await postsListScreen.addFilterButton().click(); + + await expect.element(postsListScreen.filterFieldOption("Tag")).toBeVisible(); + await expect(postsListScreen.filterFieldOption("Author")).toHaveCount(0); + }); + + it("offers all four to an administrator", async () => { + fakePosts([]); + await renderAdminApp("/posts", asRole("Administrator")); + + await postsListScreen.addFilterButton().click(); + + await expect.element(postsListScreen.filterFieldOption("Post type")).toBeVisible(); + await expect.element(postsListScreen.filterFieldOption("Access")).toBeVisible(); + await expect.element(postsListScreen.filterFieldOption("Author")).toBeVisible(); + await expect.element(postsListScreen.filterFieldOption("Tag")).toBeVisible(); + }); + }); +}); diff --git a/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx b/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx new file mode 100644 index 00000000000..83e5ac5f225 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx @@ -0,0 +1,302 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { currentRoute, currentUserResponse, fakeAdminEndpoint, fakePages, fakePosts, fakePostsListScreen, post, renderAdminApp, staffRole, tag } from "@test-utils/acceptance"; +import { postsListScreen } from "./posts-list.screen"; + +const FLAG_ON = { labs: { postsListReact: true } }; + +/** + * What a row says, and the two empty states — the parity-critical surface of + * the list. The strings themselves are unit-tested in post-row-copy.test.ts; + * these check they reach the screen and that the states switch correctly. + */ +describe("Posts list rows", () => { + // The filter bar mounts with the screen and probes these to resolve any + // author/tag slug in the URL into a name. + beforeEach(() => { + fakePostsListScreen(); + }); + + it("shows the title, byline, primary tag and status", async () => { + fakePosts([post({ + title: "A published post", + status: "published", + authors: [{ id: "a1", name: "Ada Lovelace" }], + primary_tag: tag({ name: "Engineering" }) + })]); + await renderAdminApp("/posts", FLAG_ON); + + const row = postsListScreen.listItems().first(); + await expect.element(row).toBeVisible(); + await expect.element(row).toHaveTextContent("A published post"); + await expect.element(row).toHaveTextContent("By Ada Lovelace"); + await expect.element(row).toHaveTextContent("Engineering"); + await expect.element(row).toHaveTextContent("Published"); + }); + + // Scoped to one bucket: the fake doesn't implement NQL, so an unfiltered + // render would serve these same posts to all three status queries. + it("marks a featured post", async () => { + fakePosts([ + post({ title: "Featured one", status: "published", featured: true }), + post({ title: "Ordinary one", status: "published", featured: false }) + ]); + await renderAdminApp("/posts?type=published", FLAG_ON); + + await expect(postsListScreen.listItems()).toHaveCount(2); + await expect(postsListScreen.featuredMarkers()).toHaveCount(1); + }); + + // The wording that would not survive a visual check. + it("says a published post's newsletter failed", async () => { + fakePosts([post({ + title: "Failed send", + status: "published", + email: { status: "failed", email_count: 10, opened_count: 0 } + })]); + await renderAdminApp("/posts", FLAG_ON); + + await expect.element(postsListScreen.listItems().first()) + .toHaveTextContent("Published but failed to send newsletter"); + }); + + it("does not say 'Sent' for an email-only post that failed", async () => { + fakePosts([post({ + title: "Failed email", + status: "sent", + email: { status: "failed", email_count: 10, opened_count: 0 } + })]); + await renderAdminApp("/posts?type=sent", FLAG_ON); + + const row = postsListScreen.listItems().first(); + await expect.element(row).toHaveTextContent("Failed to send newsletter"); + // A substring check alone would pass against "Sent - Failed to ...". + await expect.element(row).not.toHaveTextContent(/(^|[^-])\bSent\b/); + }); + + it("links a row to the editor", async () => { + const target = post({ title: "Editable", status: "draft" }); + fakePosts([target]); + await renderAdminApp("/posts", FLAG_ON); + + await expect.element(postsListScreen.rowLink().first()) + .toHaveAttribute("href", `#/editor/post/${target.id}`); + }); + + describe("as a Contributor", () => { + const asContributor = () => { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name: "Contributor" })]; + me.users[0].slug = "contrib"; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; + }; + + it("links a published post out to the site instead of the editor", async () => { + const target = post({ + title: "Live post", + status: "published", + url: "https://example.com/live-post/" + }); + fakePosts([target]); + await renderAdminApp("/posts?type=published", asContributor()); + + const link = postsListScreen.rowLink().first(); + await expect.element(link).toHaveAttribute("href", "https://example.com/live-post/"); + await expect.element(link).toHaveAttribute("target", "_blank"); + }); + + // Ember's isPublished is strictly status === 'published', so an + // email-only post still opens in the editor. + it("still links an email-only post to the editor", async () => { + const target = post({ title: "Email only", status: "sent" }); + fakePosts([target]); + await renderAdminApp("/posts?type=sent", asContributor()); + + await expect.element(postsListScreen.rowLink().first()) + .toHaveAttribute("href", `#/editor/post/${target.id}`); + }); + + it("links a draft to the editor", async () => { + const target = post({ title: "My draft", status: "draft" }); + fakePosts([target]); + await renderAdminApp("/posts?type=draft", asContributor()); + + await expect.element(postsListScreen.rowLink().first()) + .toHaveAttribute("href", `#/editor/post/${target.id}`); + }); + }); + + it("links a page row to the page editor", async () => { + const target = post({ title: "A page", status: "draft" }); + fakePages([target]); + await renderAdminApp("/pages", FLAG_ON); + + await expect.element(postsListScreen.rowLink().first()) + .toHaveAttribute("href", `#/editor/page/${target.id}`); + }); +}); + +describe("Posts list empty states", () => { + // The filter bar mounts with the screen and probes these to resolve any + // author/tag slug in the URL into a name. + beforeEach(() => { + fakePostsListScreen(); + }); + + it("invites you to write when there is nothing at all", async () => { + fakePosts([]); + await renderAdminApp("/posts", FLAG_ON); + + await expect.element(postsListScreen.emptyCold()).toBeVisible(); + await expect.element(postsListScreen.emptyCold()).toHaveTextContent("Start creating content"); + }); + + it("uses the page wording on the pages screen", async () => { + fakePages([]); + await renderAdminApp("/pages", FLAG_ON); + + await expect.element(postsListScreen.emptyCold()).toHaveTextContent("Tell the world about yourself"); + }); + + it("offers a way back when a filter matched nothing", async () => { + fakePosts([]); + await renderAdminApp("/posts?type=draft", FLAG_ON); + + await expect.element(postsListScreen.emptyFiltered()).toBeVisible(); + await expect.element(postsListScreen.emptyFiltered()) + .toHaveTextContent("No posts match the current filter"); + }); + + // Ember's "Show all posts" resets the filters but deliberately not the + // sort, so a chosen order survives. + it("clears the filters but keeps the sort when taking that way back", async () => { + fakePosts([]); + await renderAdminApp("/posts?type=draft&tag=news&order=published_at+asc", FLAG_ON); + + await postsListScreen.showAllButton("posts").click(); + + await expect.poll(currentRoute).toBe("/posts?order=published_at+asc"); + }); + + // Sorting is not filtering: Ember excludes `order` from this check, so + // re-sorting an empty list still offers "write your first post". + it("treats a sort-only URL as unfiltered", async () => { + fakePosts([]); + await renderAdminApp("/posts?order=published_at+asc", FLAG_ON); + + await expect.element(postsListScreen.emptyCold()).toBeVisible(); + }); + + /** + * The trailing button. Which of the three it is depends on the post *and* + * the signed-in role, and getting it wrong sends people somewhere they + * can't act — a contributor into an editor they have no rights to, or an + * author to an analytics screen they can't open. + */ +}); + +describe("Posts list trailing action button", () => { + beforeEach(() => { + fakePostsListScreen(); + }); + + const settingsWithTracking = { + settings: [ + { key: "email_track_opens", value: true }, + { key: "members_signup_access", value: "all" } + ] + }; + + const emailedPost = post({ + title: "A sent post", + status: "published", + email: { opened_count: 5, email_count: 10, track_opens: true, track_clicks: false } + }); + + function asRole(name: "Administrator" | "Author" | "Contributor", slug: string) { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name })]; + me.users[0].slug = slug; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; + } + + it("goes to analytics for an admin on a post with newsletter engagement", async () => { + fakeAdminEndpoint("GET", /^\/settings\//, settingsWithTracking); + fakePosts([emailedPost]); + await renderAdminApp("/posts?type=published", asRole("Administrator", "admin-user")); + + const action = postsListScreen.rowAction().first(); + await expect.element(action).toHaveAccessibleName("Go to Analytics"); + await expect.element(action).toHaveAttribute("href", `#/posts/analytics/${emailedPost.id}`); + }); + + // Same post, lesser role: Ember gates the analytics screen on isAdmin. + it("falls back to the editor for an author", async () => { + fakeAdminEndpoint("GET", /^\/settings\//, settingsWithTracking); + fakePosts([emailedPost]); + await renderAdminApp("/posts?type=published", asRole("Author", "an-author")); + + const action = postsListScreen.rowAction().first(); + await expect.element(action).toHaveAccessibleName("Go to Editor"); + await expect.element(action).toHaveAttribute("href", `#/editor/post/${emailedPost.id}`); + }); + + it("links a contributor out to the live post", async () => { + const published = post({ title: "Live one", status: "published", url: "https://example.com/live/" }); + fakePosts([published]); + await renderAdminApp("/posts?type=published", asRole("Contributor", "a-contributor")); + + const action = postsListScreen.rowAction().first(); + await expect.element(action).toHaveAccessibleName("View post"); + await expect.element(action).toHaveAttribute("href", "https://example.com/live/"); + await expect.element(action).toHaveAttribute("target", "_blank"); + }); + + it("goes to the editor on a page, which has no analytics screen", async () => { + const page = post({ title: "About", status: "published" }); + fakePages([page]); + await renderAdminApp("/pages?type=published", asRole("Administrator", "admin-user")); + + const action = postsListScreen.rowAction().first(); + await expect.element(action).toHaveAccessibleName("Go to Editor"); + await expect.element(action).toHaveAttribute("href", `#/editor/page/${page.id}`); + }); +}); + +/** + * The hover panel is the whole visible half of the metrics feature, and until + * now nothing rendered it: the contents were asserted against the pure + * `getPostMetricTooltip`, so the trigger could have stopped cloning onto the + * anchor, or the portal could have broken, with every test still green. + */ +describe("Posts list metric hover panels", () => { + beforeEach(() => { + fakePostsListScreen(); + }); + + it("opens the newsletter breakdown on hovering a metric", async () => { + fakeAdminEndpoint("GET", /^\/settings\//, { + settings: [ + { key: "email_track_opens", value: true }, + { key: "members_signup_access", value: "all" } + ] + }); + fakePosts([post({ + title: "A sent post", + status: "published", + email: { opened_count: 60, email_count: 200, track_opens: true, track_clicks: false } + })]); + await renderAdminApp("/posts?type=published", FLAG_ON); + + // The column shows the rate; the panel underneath shows raw counts. + await expect.element(postsListScreen.metricCell("Opens")).toHaveTextContent("30%"); + await postsListScreen.metricCell("Opens").hover(); + + const panel = postsListScreen.metricPanel(); + await expect.element(panel).toBeVisible(); + await expect.element(panel).toHaveTextContent("Newsletter performance"); + await expect.element(panel).toHaveTextContent("Sent"); + await expect.element(panel).toHaveTextContent("200"); + await expect.element(panel).toHaveTextContent("60"); + }); +}); diff --git a/apps/admin/src/posts/list/posts-list-screen.tsx b/apps/admin/src/posts/list/posts-list-screen.tsx new file mode 100644 index 00000000000..1d2dfba75ff --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-screen.tsx @@ -0,0 +1,443 @@ +import {Box, Container, Stack, Text} from '@tryghost/shade/primitives'; +import {Button, LoadingIndicator} from '@tryghost/shade/components'; +import {ListPage} from '@tryghost/shade/page-templates'; +import {LoadMoreButton} from '@/shared/virtual-list'; +import {cn, LucideIcon} from '@tryghost/shade/utils'; +import {FilterBar, PageHeader} from '@tryghost/shade/patterns'; +import {PostListRow} from './components/post-list-row'; +import {PostsEmptyState} from './components/posts-empty-state'; +import {PostsFilters} from './components/posts-filters'; +import {ManagePostViewPopover} from './components/manage-post-view-popover'; +import {POST_DEFAULT_VIEWS} from '@/layout/app-sidebar/post-sidebar-views'; +import {PostsSortMenu} from './components/posts-sort-menu'; +import {buildAllFilter, buildBucketFilter, getActiveBuckets} from './post-query-params'; +import {canSavePostView, findActivePostView} from './post-views'; + +import {usePostViews} from './hooks/use-post-views'; +import {getSettingValue, useBrowseSettings} from '@tryghost/admin-x-framework/api/settings'; +import {hasAdminAccess, isAuthorOrContributor, isContributorUser} from '@tryghost/admin-x-framework/api/users'; +import {usePostActions} from './hooks/use-post-actions'; +import {usePostSelection} from './hooks/use-post-selection'; +import {canCopyGiftLink} from '@/shared/gift-link'; +import {PostCelebrationModal} from './components/post-celebration-modal'; +import {useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {usePostPublishCelebration} from './hooks/use-post-publish-celebration'; +import {AddTagModal} from './components/modals/add-tag-modal'; +import {ChangeAccessModal} from './components/modals/change-access-modal'; +import {ConfirmBulkActionModal} from './components/modals/confirm-bulk-action-modal'; +import {usePostBulkActions, type BulkActionSnapshot} from './hooks/use-post-bulk-actions'; +import type {BulkConfirmKey} from './post-bulk-modal-copy'; +import type {PostContextMenuKey} from './post-context-menu-items'; +import {getPostContextMenuItems} from './post-context-menu-items'; +import {getPostSelectionCount, isPostSelected, isSinglePostSelected} from './post-selection-state'; +import {type PostResource, getPostResourceCopy} from './post-resource'; +import {useCurrentUser} from '@tryghost/admin-x-framework/api/current-user'; +import {usePostsFilterState} from './hooks/use-posts-filter-state'; +import {rememberStickyPostFilters} from './posts-sticky-filters'; +import {lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useLocation} from '@tryghost/admin-x-framework'; +import {usePostAnalyticsCounts} from './hooks/use-post-analytics-counts'; +import {usePostsList} from './hooks/use-posts-list'; + +/** + * The React posts and pages list screens, served behind the `postsListReact` + * Labs flag. One implementation, two resources — see `post-resource.ts`. + */ +/** The three that ask before acting. Feature and unfeature do not. */ +const CONFIRMABLE_ACTIONS: PostContextMenuKey[] = ['delete', 'unpublish', 'unschedule']; + +// Only needed once someone opens it, and it pulls in the gift-link API layer. +const GiftLinkModal = lazy(() => import('@/posts/analytics/modals/gift-link-modal')); + +export function PostsListScreen({resource}: {resource: PostResource}) { + const copy = getPostResourceCopy(resource); + const {params, filters, order, setFilters, setOrder, hasFilters, clearFilters} = usePostsFilterState(); + const {data: currentUser} = useCurrentUser(); + const {data: settingsData} = useBrowseSettings(); + + // Report the current filters so the sidebar's Posts link can return here. + const location = useLocation(); + + useEffect(() => { + rememberStickyPostFilters(resource, location.search); + }, [resource, location.search]); + + // Scheduled times read in the site's timezone, not the browser's. + const timezone = getSettingValue(settingsData?.settings, 'timezone') ?? undefined; + const isContributor = Boolean(currentUser && isContributorUser(currentUser)); + // Ember's `isAdmin` — Owner or Administrator. Decides whether a row's + // trailing button offers Analytics, and whether views can be saved. + const isAdmin = Boolean(currentUser && hasAdminAccess(currentUser)); + + const settings = settingsData?.settings ?? null; + // Memoised because it is a prop of every row, and the rows are memoised: + // rebuilding this object each render would defeat that and re-render the + // whole list on every modifier keypress. + const metricsSettings = useMemo(() => ({ + webAnalyticsEnabled: getSettingValue(settings, 'web_analytics_enabled') === true, + membersTrackSources: getSettingValue(settings, 'members_track_sources') === true, + emailTrackOpens: getSettingValue(settings, 'email_track_opens') === true, + emailTrackClicks: getSettingValue(settings, 'email_track_clicks') === true, + membersSignupAccess: getSettingValue(settings, 'members_signup_access') ?? 'all', + isMembersInviteOnly: getSettingValue(settings, 'members_signup_access') === 'invite', + isContributor + }), [settings, isContributor]); + const paidMembersEnabled = getSettingValue(settings, 'paid_members_enabled') === true; + + // The save/edit-view affordance: admins only, posts only, not while a + // default view is active, and only with something actually filtered. + const savedViews = usePostViews(); + // Posts only: the saved views are posts views, and matching is filter-only, + // so on /pages this could otherwise resolve to a posts view. + const activeView = resource === 'posts' ? findActivePostView(savedViews, params) : undefined; + const isOnDefaultView = POST_DEFAULT_VIEWS.some(view => findActivePostView([{ + ...view, route: 'posts' + }], params)); + const canManageView = canSavePostView({ + isAdmin, + resource, + params, + isDefaultView: isOnDefaultView + }); + + // A sort alone makes the view saveable without opening the chip bar, so + // the save-view control falls back to the top row when the bar is hidden. + const showFilterBar = hasFilters; + const showViewActionsInHeader = canManageView && !showFilterBar; + + // Authors and contributors only ever see their own posts, whatever the + // `author` param says — matching PostsRoute#model in the Ember app. + const isRestrictedAuthor = Boolean(currentUser && isAuthorOrContributor(currentUser)); + const ownAuthorSlug = currentUser && isRestrictedAuthor ? currentUser.slug : null; + + const { + items, + isLoading, + isError, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + totalItems + } = usePostsList({resource, params, context: {ownAuthorSlug}}); + + // Selection is a bulk-edit affordance, and authors and contributors have no + // bulk actions — Ember disables the whole SelectionList for them. + const selection = usePostSelection({ + orderedIds: items.map(item => item.id), + // Bounds an inverted selection: after Cmd+A a bulk action sends this + // filter rather than every id, so it covers rows never loaded. + allFilter: buildAllFilter(params, {ownAuthorSlug}), + enabled: Boolean(currentUser) && !isRestrictedAuthor + }); + + // The menu describes the selection, not the row under the cursor. Ember's + // `availableModels` — the selected rows that are actually loaded. + // + // Depends on `selection.state` rather than `selection`, which is a fresh + // object literal every render and would make this memo a no-op. + // The buckets on screen, so a bulk edit only patches the lists it is about. + const bucketFilters = useMemo( + () => getActiveBuckets(params).map(bucket => buildBucketFilter(bucket, params, {ownAuthorSlug})), + [params, ownAuthorSlug] + ); + + const selectionState = selection.state; + const menuPosts = useMemo( + () => items.filter(item => isPostSelected(selectionState, item.id)), + [items, selectionState] + ); + const membersEnabled = getSettingValue(settings, 'members_signup_access') !== 'none'; + + // Changes on every selection change; rows read it through a stable ref + // below so their memo holds. + const menuItems = useMemo(() => getPostContextMenuItems({ + posts: menuPosts, + resource, + isAdmin, + membersEnabled, + // The gift link is filtered back in per row below, since it is the one + // item that depends on *which* post rather than on the selection. + canCopyGiftLink: true + }), [menuPosts, resource, isAdmin, membersEnabled]); + + // Ember gates this on `isSingle` — one *selected* post — not on "one loaded + // post". After Cmd+A on a view with a single loaded row, everything is + // selected, and offering to gift-link it would be wrong. + const giftLinkPost = isSinglePostSelected(selectionState) ? menuPosts[0] : undefined; + const menuGiftLinkPostId = giftLinkPost && canCopyGiftLink({user: currentUser, post: giftLinkPost}) + ? giftLinkPost.id + : null; + // Opened from the context menu. Ember reaches the same React modal over the + // state bridge; here the list owns it directly, so there is one modal and + // one set of eligibility rules behind both implementations. + const [giftLinkPostId, setGiftLinkPostId] = useState(null); + + // The Ember editor writes a localStorage key on publish and navigates here; + // this reads it. The editor stays Ember on both sides of the flag. + const celebration = usePostPublishCelebration(); + const {data: siteData} = useBrowseSite(); + + // Snapshotted when the menu item is picked: Radix closes the menu at once, + // which clears a transient selection before the modal could read it. + const [pendingBulkAction, setPendingBulkAction] = useState< + {key: PostContextMenuKey; snapshot: BulkActionSnapshot} | null + >(null); + + const bulkActions = usePostBulkActions({ + resource, + onDeleted: () => { + setPendingBulkAction(null); + selection.clear(); + }, + onEdited: (remainingIds) => { + setPendingBulkAction(null); + // Not a full clear: the rows still on screen stay selected, so a + // second action can follow the first. Ember's clearUnavailableItems. + selection.keepOnly(remainingIds); + } + }); + const menuItemsRef = useRef(menuItems); + const runPostActionRef = useRef<(key: PostContextMenuKey) => void | Promise>(() => {}); + + menuItemsRef.current = menuItems; + + const getMenuItems = useCallback(() => menuItemsRef.current, []); + const stableRunPostAction = useCallback( + (key: PostContextMenuKey) => runPostActionRef.current(key), + [] + ); + + const runPostAction = usePostActions({ + resource, + posts: menuPosts, + onShareAsGift: setGiftLinkPostId, + onBulkAction: (key, snapshot) => { + // Feature and unfeature apply straight away in Ember — no + // confirmation, because they are trivially reversible. + if (key === 'feature' || key === 'unfeature') { + void bulkActions.run(key, snapshot); + return; + } + + setPendingBulkAction({key, snapshot}); + }, + selectionFilter: selection.filter, + bucketFilters, + isSingle: isSinglePostSelected(selectionState), + inverted: selectionState.inverted, + // The selection count, not the loaded-row count: after Cmd+A on a + // 2,000-post site the toast has to say 2,000, not the 30 in memory. + count: getPostSelectionCount(selectionState, totalItems) + }); + + runPostActionRef.current = runPostAction; + + const {visitorCounts, memberCounts} = usePostAnalyticsCounts({ + items, + webAnalyticsEnabled: metricsSettings.webAnalyticsEnabled, + membersTrackSources: metricsSettings.membersTrackSources + }); + + return ( + + + + + + + {copy.title} + + {/* Sort and the primary button live in the top row, + as on the members list. The filter trigger joins + them only while there is nothing to show — once + there is, it moves down into a full-width row, + because chips need the width and would otherwise + crowd the title. */} + + + {!showFilterBar && ( + + )} + + {showViewActionsInHeader && ( + + )} + + + + + {showFilterBar && ( + + + )} + onFiltersChange={setFilters} + /> + + )} + + + {isLoading ? ( + + + + ) : isError ? ( + + Error loading {copy.title.toLowerCase()} + + ) : items.length === 0 ? ( + + + + ) : ( + // Same testids as the Ember list, deliberately — + // shared e2e page objects. They can never collide: + // the Ember route aborts when this screen renders. + +
      + {items.map(item => ( + + ))} +
    + {hasNextPage && ( + + )} +
    + )} +
    +
    + {pendingBulkAction && CONFIRMABLE_ACTIONS.includes(pendingBulkAction.key) && ( + { + setPendingBulkAction(null); + }} + onConfirm={() => { + void bulkActions.run(pendingBulkAction.key, pendingBulkAction.snapshot); + }} + /> + )} + {pendingBulkAction?.key === 'add-tag' && ( + { + setPendingBulkAction(null); + }} + onConfirm={(tags) => { + void bulkActions.runWithPayload('add-tag', pendingBulkAction.snapshot, {tags}); + }} + /> + )} + {pendingBulkAction?.key === 'change-access' && ( + { + setPendingBulkAction(null); + }} + onConfirm={(access) => { + void bulkActions.runWithPayload('change-access', pendingBulkAction.snapshot, access); + }} + /> + )} + {celebration.celebration && celebration.post && ( + + )} + {giftLinkPostId && ( + + { + if (!open) { + setGiftLinkPostId(null); + } + }} + /> + + )} +
    +
    + ); +} diff --git a/apps/admin/src/posts/list/posts-list-selection.acceptance.test.tsx b/apps/admin/src/posts/list/posts-list-selection.acceptance.test.tsx new file mode 100644 index 00000000000..1aa003bf1db --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-selection.acceptance.test.tsx @@ -0,0 +1,263 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { currentUserResponse, fakePosts, fakePostsListScreen, post, renderAdminApp, staffRole } from "@test-utils/acceptance"; +import { metaMouseDown, postsListScreen } from "./posts-list.screen"; +import type { StaffRoleName } from "@tryghost/test-data"; + +const FLAG_ON = { labs: { postsListReact: true } }; + +/** + * Selection has no checkboxes — it is entirely modifier-clicks, Cmd+A, Escape + * and window-level handlers. None of that can be covered by the reducer tests: + * the semantics live in `post-selection-state.test.ts`, and what these check is + * the wiring — that a cmd-click reaches the reducer at all, that it doesn't + * navigate, and that the window handler doesn't clear what was just selected. + */ + +const POSTS = [ + post({ title: "First post", status: "published" }), + post({ title: "Second post", status: "published" }), + post({ title: "Third post", status: "published" }), + post({ title: "Fourth post", status: "published" }) +]; + +function asRole(name: StaffRoleName) { + const me = currentUserResponse(); + me.users[0].roles = [staffRole({ name })]; + return { ...FLAG_ON, boot: { browseMe: { response: me } } }; +} + +async function renderList(options: object = FLAG_ON) { + fakePosts(POSTS); + await renderAdminApp("/posts?type=published", options); + await expect.element(postsListScreen.listItems().nth(3)).toBeVisible(); +} + +describe("Posts list selection", () => { + beforeEach(() => { + fakePostsListScreen(); + }); + + it("selects a row on cmd-click without following its link", async () => { + await renderList(); + + await postsListScreen.listItems().nth(1).click({ modifiers: ["Meta"] }); + + await expect.poll(() => postsListScreen.selectedTitles()).toEqual(["Second post"]); + // The row is a link; a modifier-click must not navigate to the editor. + expect(window.location.hash).toContain("/posts"); + }); + + it("adds a second row rather than replacing the first", async () => { + await renderList(); + + await postsListScreen.listItems().nth(0).click({ modifiers: ["Meta"] }); + await postsListScreen.listItems().nth(2).click({ modifiers: ["Meta"] }); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(2); + }); + + it("deselects a row on a second cmd-click", async () => { + await renderList(); + + await postsListScreen.listItems().nth(1).click({ modifiers: ["Meta"] }); + await postsListScreen.listItems().nth(1).click({ modifiers: ["Meta"] }); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(0); + }); + + // Titles, not a count: the anchor-inclusion bugs this guards against all + // preserve the count while shifting *which* rows are in the range. + it("selects a range on shift-click", async () => { + await renderList(); + + await postsListScreen.listItems().nth(0).click({ modifiers: ["Meta"] }); + await postsListScreen.listItems().nth(2).click({ modifiers: ["Shift"] }); + + await expect.poll(() => postsListScreen.selectedTitles()) + .toEqual(["First post", "Second post", "Third post"]); + }); + + // Backwards takes the anchor with it, where forwards excludes it — the + // asymmetry is Ember's, and it is invisible to a count-only assertion. + it("selects a backward range including the anchor", async () => { + await renderList(); + + await postsListScreen.listItems().nth(2).click({ modifiers: ["Meta"] }); + await postsListScreen.listItems().nth(0).click({ modifiers: ["Shift"] }); + + await expect.poll(() => postsListScreen.selectedTitles()) + .toEqual(["First post", "Second post", "Third post"]); + }); + + /** + * Asserted against the server's total, not the loaded rows: an + * implementation that simply enumerated the four ids on screen would pass a + * count-of-four check, and that is precisely the bug that makes a bulk + * delete silently miss every unloaded page. + */ + it("selects everything on Cmd+A, including rows never loaded", async () => { + await renderList(); + + await userEvent.keyboard("{Meta>}a{/Meta}"); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(4); + // The rows on screen are all selected, but the selection is inverted — + // it is bounded by the filter, not by the four ids in memory. + await expect.poll(() => postsListScreen.listRoot().getAttribute("data-selection")) + .toBe("inverted"); + }); + + // The inverted selection has to survive a deselect, since that is what + // produces the `(filter)+id:-[…]` shape bulk actions send. + it("lets a row be taken back out after Cmd+A", async () => { + await renderList(); + + await userEvent.keyboard("{Meta>}a{/Meta}"); + await postsListScreen.listItems().nth(1).click({ modifiers: ["Meta"] }); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(3); + }); + + it("clears the selection on Escape", async () => { + await renderList(); + + await postsListScreen.listItems().nth(1).click({ modifiers: ["Meta"] }); + await userEvent.keyboard("{Escape}"); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(0); + }); + + // The window-level click handler. Its counterpart — *not* clearing on the + // modifier click that just selected the row — is covered by every test + // above, which would all read zero if the handler fired too eagerly. + it("clears the selection on an unmodified click elsewhere", async () => { + await renderList(); + + await postsListScreen.listItems().nth(1).click({ modifiers: ["Meta"] }); + await postsListScreen.title("posts", "Posts").click(); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(0); + }); + + // `data-ignore-select` on the metric links and the trailing button: a + // cmd-click there follows the link into a new tab, as on any link, rather + // than selecting the row underneath it. + it("does not select when the trailing action button is cmd-clicked", async () => { + await renderList(); + + metaMouseDown(postsListScreen.rowAction().nth(1).element()); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(0); + }); + + describe.each([ + { role: "Author" as const }, + { role: "Contributor" as const } + ])("as $role", ({ role }) => { + // Ember disables the whole SelectionList for these roles — they have no + // bulk actions, so a selection would be an affordance leading nowhere. + it("cannot select a row at all", async () => { + await renderList(asRole(role)); + + metaMouseDown(postsListScreen.listItems().nth(1).element()); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(0); + }); + + it("cannot select everything with Cmd+A", async () => { + await renderList(asRole(role)); + + await userEvent.keyboard("{Meta>}a{/Meta}"); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(0); + }); + }); + + /** + * Ember clears the selection on every model refresh (`clearSelection()` in + * PostsRoute#setupController), and all five query params are + * `refreshModel: true` — so changing a filter always drops the selection. + * + * Keeping it would be worse than untidy. After Cmd+A the selection is + * *inverted* and bounded by the list's filter, and that filter is rebuilt + * from the URL. Select all drafts, then clear the type filter, and the same + * selection now means "every post on the site" — which is what a bulk + * delete would be handed. + */ + describe("across a filter change", () => { + // Navigated through history rather than by clicking a filter control: + // every click path to changing a filter is itself an unmodified click, + // which clears the selection on its own. Going through the URL is the + // only way to prove the filter change is what did it. + function navigate(to: string) { + window.history.pushState({}, "", to); + window.dispatchEvent(new PopStateEvent("popstate")); + } + + it("drops the selection when the filter changes", async () => { + await renderList(); + + await postsListScreen.listItems().nth(1).click({ modifiers: ["Meta"] }); + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(1); + + navigate("#/posts?type=published&visibility=public"); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(0); + }); + + it("drops an inverted selection rather than re-bounding it to the new filter", async () => { + await renderList(); + + await userEvent.keyboard("{Meta>}a{/Meta}"); + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(4); + + navigate("#/posts?type=published&visibility=public"); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(0); + }); + }); + + /** + * Ember's `[data-ctrl]` mode. Holding a modifier takes the list out of + * "links" and into "selectable rows" — the cursor changes and children stop + * taking pointer events, so a click lands on the row. Without it the list + * still *behaves* correctly (the handlers preventDefault) but gives no sign + * that a click is about to select rather than navigate. + */ + describe("while a modifier is held", () => { + it("puts the list into select mode and takes it back out", async () => { + await renderList(); + + await userEvent.keyboard("{Meta>}"); + await expect.poll(() => postsListScreen.listRoot().getAttribute("data-ctrl")).toBe("true"); + + await userEvent.keyboard("{/Meta}"); + await expect.poll(() => postsListScreen.listRoot().getAttribute("data-ctrl")).toBeNull(); + }); + + // The keyup for a held modifier never arrives if the window loses focus + // mid-chord, which would strand the list with nothing clickable. + it("leaves select mode when the window loses focus", async () => { + await renderList(); + + await userEvent.keyboard("{Meta>}"); + await expect.poll(() => postsListScreen.listRoot().getAttribute("data-ctrl")).toBe("true"); + + window.dispatchEvent(new Event("blur")); + + await expect.poll(() => postsListScreen.listRoot().getAttribute("data-ctrl")).toBeNull(); + await userEvent.keyboard("{/Meta}"); + }); + + it("stays out of select mode for roles that cannot select", async () => { + await renderList(asRole("Contributor")); + + await userEvent.keyboard("{Meta>}"); + + await expect.poll(() => postsListScreen.listRoot().getAttribute("data-ctrl")).toBeNull(); + await userEvent.keyboard("{/Meta}"); + }); + }); +}); diff --git a/apps/admin/src/posts/list/posts-list.acceptance.test.tsx b/apps/admin/src/posts/list/posts-list.acceptance.test.tsx new file mode 100644 index 00000000000..83d13c366be --- /dev/null +++ b/apps/admin/src/posts/list/posts-list.acceptance.test.tsx @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { fakePages, fakePosts, fakePostsListScreen, renderAdminApp } from "@test-utils/acceptance"; +import { postsListScreen } from "./posts-list.screen"; + +const FLAG_ON = { labs: { postsListReact: true } }; +const FLAG_OFF = { labs: { postsListReact: false } }; + +/** + * Proves the `postsListReact` flag swap end-to-end in the real admin app: the + * React screen appears only when the flag is on, and the Ember side of the URL + * is delegated to otherwise. + * + * There is no Ember app in this harness, so "Ember serves it" shows up as the + * React screen being absent rather than as an Ember list being present — the + * Ember half of the handshake (PostsRoute aborting its transition) is covered + * in apps/ember-admin/tests/acceptance/posts-list-react-flag-test.js. + */ +describe("Posts and pages list flag", () => { + // The screen queries once per status bucket as soon as it mounts; the + // content is irrelevant here, this file is only about which implementation + // serves the route. + beforeEach(() => { + fakePostsListScreen(); + fakePosts([]); + fakePages([]); + }); + + describe.each([ + { resource: "posts", route: "/posts", title: "Posts", newLabel: "New post" }, + { resource: "pages", route: "/pages", title: "Pages", newLabel: "New page" } + ] as const)("$route", ({ resource, route, title, newLabel }) => { + it("renders the React screen when the flag is on", async () => { + await renderAdminApp(route, FLAG_ON); + + await expect.element(postsListScreen.page(resource)).toBeVisible(); + await expect.element(postsListScreen.title(resource, title)).toBeVisible(); + }); + + it("offers the primary create action", async () => { + await renderAdminApp(route, FLAG_ON); + + await expect.element(postsListScreen.newLink(resource, newLabel)).toBeVisible(); + }); + + // That the *Ember* list isn't mounted alongside this one is asserted in + // apps/ember-admin/tests/acceptance/posts-list-react-flag-test.js — + // there is no Ember app in this harness, so it can't be checked here. + + it("defers to Ember when the flag is off", async () => { + await renderAdminApp(route, FLAG_OFF); + + await expect(postsListScreen.page(resource)).toHaveCount(0); + }); + + it("defers to Ember when the flag is absent entirely", async () => { + await renderAdminApp(route); + + await expect(postsListScreen.page(resource)).toHaveCount(0); + }); + }); + + // The two routes share one gate implementation, so a copy-paste slip would + // silently serve the wrong screen. + it("serves each route its own resource", async () => { + await renderAdminApp("/pages", FLAG_ON); + + await expect.element(postsListScreen.page("pages")).toBeVisible(); + await expect(postsListScreen.page("posts")).toHaveCount(0); + }); +}); diff --git a/apps/admin/src/posts/list/posts-list.screen.ts b/apps/admin/src/posts/list/posts-list.screen.ts new file mode 100644 index 00000000000..84725799de3 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list.screen.ts @@ -0,0 +1,110 @@ +import { page } from "vitest/browser"; +import { + listPage, + postFeaturedMarker, + postListItemAction, + postListItemLink, + postMetricPanel, + postsEmptyCold, + postsEmptyFiltered, + postsFilters, + postsList, + postsListItem, + postsSort, +} from "@tryghost/test-data/selectors/posts"; + +/** + * Locator vocabulary for the React posts and pages list screens. The testid + * strings live in `@tryghost/test-data/selectors/posts`, shared with the e2e + * page objects. + * + * Page-scoped locators go through `page(resource)`: the admin sidebar carries + * its own "Create new post" link, so an unscoped role query matches twice. + * Which implementation is serving a route is asserted via `page(resource)`, + * which only the React screen renders. + */ +/** + * A cmd-click on a row is a cmd-click on a *link*, which opens a new browser + * tab — dispatching the mousedown directly exercises the selection path + * without asking the browser to open tabs mid-suite. Ember behaves the same. + */ +export function metaMouseDown(element: Element): void { + element.dispatchEvent(new MouseEvent("mousedown", { + bubbles: true, cancelable: true, metaKey: true + })); +} + +export const postsListScreen = { + page: (resource: "posts" | "pages") => page.getByTestId(listPage(resource)), + title: (resource: "posts" | "pages", name: string) => + page.getByTestId(listPage(resource)).getByRole("heading", { name }), + // `exact` matters: the cold empty state also offers "Write a new post", + // which a substring match on "New post" would pick up too. + newLink: (resource: "posts" | "pages", name: string) => + page.getByTestId(listPage(resource)).getByRole("link", { name, exact: true }), + listItems: () => page.getByTestId(postsListItem), + /** The
      . Carries `data-selection` so an inverted selection is observable. */ + listRoot: () => page.getByTestId(postsList).element(), + /** + * Titles of the currently selected rows, keyed off the same `data-selected` + * attribute Ember sets. Read as elements rather than as a locator because + * Vitest's locators have no attribute selector; pair it with `expect.poll` + * so it still retries while React settles. + */ + selectedTitles: () => postsListScreen.listItems().elements() + .filter(element => element.getAttribute("data-selected") === "true") + .map(element => element.querySelector("h3")?.textContent ?? ""), + /** The row's main link — the image and title region. */ + rowLink: () => page.getByTestId(postListItemLink), + /** A metric column, found by its label ("Opens", "Members", …). */ + metricCell: (label: string) => page.getByTestId(postsListItem).getByRole("link", { name: new RegExp(label) }).first(), + /** The hover breakdown, which Radix portals out of the row. */ + metricPanel: () => page.getByTestId(postMetricPanel), + /** The right-click menu, which Radix portals out of the list. */ + contextMenu: () => page.getByRole("menu"), + contextMenuItem: (label: string) => page.getByRole("menuitem", { name: label, exact: true }), + /** + * Toasts render into Shade's Sonner portal, outside the list. Matched by + * text: these strings appear nowhere else on the screen. + */ + toastWithText: (text: string | RegExp) => page.getByText(text), + /** A button inside a non-destructive modal (Add a tag, Change access). */ + dialogButton: (label: string) => page.getByRole("dialog").getByRole("button", { name: label, exact: true }), + /** A row in the tag picker's list — a `cmdk` item, so `option`. */ + tagOption: (name: string | RegExp) => page.getByRole("dialog").getByRole("option", { name }), + tagSearchInput: () => page.getByRole("dialog").getByLabelText("Search tags"), + /** The chip field. Click it to open the list, as the chevron invites. */ + tagPickerField: () => page.getByTestId("tag-picker"), + /** + * The dialog's own heading, used to dismiss the tag list: it floats over + * the footer, so the confirm button cannot be reached until something + * outside the list is clicked — which is what a user does too. + */ + dialogHeading: (name: string) => page.getByRole("dialog").getByRole("heading", { name }), + /** The confirm button inside a bulk-action modal. */ + confirmButton: (label: string) => page.getByRole("alertdialog").getByRole("button", { name: label, exact: true }), + bulkModal: () => page.getByRole("alertdialog"), + /** + * The post-publish celebration, handed over from the Ember editor. + * + * Located by role, not testid: `PostShareModal` spreads its extra props + * onto Radix's `Dialog.Root`, which renders no DOM node at all, so a + * testid passed to it has nowhere to land. + */ + celebrationModal: () => page.getByRole("dialog").filter({ hasText: /published|All set/ }), + /** The gift-link modal, opened from the context menu. */ + giftLinkModal: () => page.getByRole("dialog", { name: /gift/i }), + /** The trailing button at a row's end — Analytics, View, or Editor. */ + rowAction: () => page.getByTestId(postListItemAction), + featuredMarkers: () => page.getByTestId(postFeaturedMarker), + emptyCold: () => page.getByTestId(postsEmptyCold), + emptyFiltered: () => page.getByTestId(postsEmptyFiltered), + showAllButton: (plural: string) => page.getByRole("button", { name: `Show all ${plural}` }), + filterBar: () => page.getByTestId(postsFilters), + addFilterButton: () => page.getByTestId(postsFilters).getByRole("button", { name: "Filter" }), + /** A field in the add-filter popover, which renders into a portal. */ + filterFieldOption: (label: string) => page.getByRole("option", { name: label, exact: true }), + sortButton: () => page.getByTestId(postsSort), + /** Radio items, so the active sort is announced and visibly checked. */ + sortOption: (label: string) => page.getByRole("menuitemradio", { name: label, exact: true }) +}; diff --git a/apps/admin/src/posts/list/posts-route.tsx b/apps/admin/src/posts/list/posts-route.tsx new file mode 100644 index 00000000000..52de92e56b2 --- /dev/null +++ b/apps/admin/src/posts/list/posts-route.tsx @@ -0,0 +1,9 @@ +import {PostsListScreen} from './posts-list-screen'; + +/** + * Route entry for `/posts`. Exists as its own module so the gate can lazy-load + * the posts and pages variants independently of each other. + */ +export default function PostsRoute() { + return ; +} diff --git a/apps/admin/src/posts/list/posts-sticky-filters.test.ts b/apps/admin/src/posts/list/posts-sticky-filters.test.ts new file mode 100644 index 00000000000..4408c1f7255 --- /dev/null +++ b/apps/admin/src/posts/list/posts-sticky-filters.test.ts @@ -0,0 +1,79 @@ +import {beforeEach, describe, expect, it} from 'vitest'; +import { + clearStickyPostFilters, + getStickyPostFilterUrl, + rememberStickyPostFilters +} from './posts-sticky-filters'; + +// Ported from state-bridge.js `getRouteUrl`. Three rules, in order: +// 1. already on the route -> bare URL ("click again to go home") +// 2. otherwise reuse the last params seen for that route +// 3. unless those match a saved view, or clicking "Posts" would silently +// drop you into whichever view you had open last + +const NO_VIEWS: Array> = []; + +describe('sticky post filters', () => { + beforeEach(() => { + clearStickyPostFilters(); + }); + + it('links to the bare route when nothing has been remembered', () => { + expect(getStickyPostFilterUrl('posts', '/members', NO_VIEWS)).toBe('posts'); + }); + + it('reuses the last params seen for the route', () => { + rememberStickyPostFilters('posts', '?tag=news'); + + expect(getStickyPostFilterUrl('posts', '/members', NO_VIEWS)).toBe('posts?tag=news'); + }); + + // The "click one more time to go home" behaviour. + it('links to the bare route while already on it', () => { + rememberStickyPostFilters('posts', '?tag=news'); + + expect(getStickyPostFilterUrl('posts', '/posts', NO_VIEWS)).toBe('posts'); + }); + + // Otherwise clicking "Posts" would silently take you into "Drafts". + it('links to the bare route when the remembered params are a saved view', () => { + rememberStickyPostFilters('posts', '?type=draft'); + + expect(getStickyPostFilterUrl('posts', '/members', [{type: 'draft'}])).toBe('posts'); + }); + + it('still reuses params that only partly overlap a view', () => { + rememberStickyPostFilters('posts', '?type=draft&tag=news'); + + expect(getStickyPostFilterUrl('posts', '/members', [{type: 'draft'}])) + .toBe('posts?type=draft&tag=news'); + }); + + it('keeps posts and pages apart', () => { + rememberStickyPostFilters('posts', '?tag=news'); + + expect(getStickyPostFilterUrl('pages', '/members', NO_VIEWS)).toBe('pages'); + }); + + // Every default view is `route: 'posts'` in Ember, so they must not + // suppress a pages filter. Passing the posts defaults in here broke sticky + // filters on Pages for the three commonest filters. + it('does not let posts views suppress a pages filter', () => { + rememberStickyPostFilters('pages', '?type=draft'); + + expect(getStickyPostFilterUrl('pages', '/members', NO_VIEWS)).toBe('pages?type=draft'); + }); + + it('ignores params that are not part of a view', () => { + rememberStickyPostFilters('posts', '?tag=news&somethingElse=x'); + + expect(getStickyPostFilterUrl('posts', '/members', NO_VIEWS)).toBe('posts?tag=news'); + }); + + it('forgets a route once its params are cleared', () => { + rememberStickyPostFilters('posts', '?tag=news'); + rememberStickyPostFilters('posts', ''); + + expect(getStickyPostFilterUrl('posts', '/members', NO_VIEWS)).toBe('posts'); + }); +}); diff --git a/apps/admin/src/posts/list/posts-sticky-filters.ts b/apps/admin/src/posts/list/posts-sticky-filters.ts new file mode 100644 index 00000000000..72b6f09700b --- /dev/null +++ b/apps/admin/src/posts/list/posts-sticky-filters.ts @@ -0,0 +1,101 @@ +import {POST_VIEW_PARAMS} from '@/posts/list/post-view-params'; +import type {PostResource} from '@/posts/list/post-resource'; + +/** + * "Sticky filters": clicking Posts in the sidebar returns you to the filters + * you last had, rather than a bare list. + * + * Ported from `state-bridge.js` `getRouteUrl`, which reads Ember's live + * controller query params. React has no equivalent long-lived controller, so + * the list screen reports its params here as they change. + * + * Module scope, deliberately not `sessionStorage`: Ember's is in-memory and + * per-tab, so persisting it would be a behaviour change rather than a port. + */ + +type ViewFilter = Record; + +const lastSeen = new Map(); + +/** Called by the list screen whenever its params change. */ +export function rememberStickyPostFilters(resource: PostResource, search: string): void { + const params = toViewParams(search); + + if (Object.keys(params).length === 0) { + lastSeen.delete(resource); + return; + } + + lastSeen.set(resource, buildQuery(params)); +} + +export function clearStickyPostFilters(): void { + lastSeen.clear(); +} + +/** Only the five params a view is made of; anything else isn't sticky. */ +function toViewParams(search: string): Record { + const source = new URLSearchParams(search); + const params: Record = {}; + + POST_VIEW_PARAMS.forEach((param) => { + const value = source.get(param); + + if (value !== null && value !== '') { + params[param] = value; + } + }); + + return params; +} + +function buildQuery(params: Record): string { + const search = new URLSearchParams(); + + POST_VIEW_PARAMS.forEach((param) => { + if (params[param] !== undefined) { + search.set(param, params[param]); + } + }); + + return search.toString(); +} + +function matchesView(params: Record, view: ViewFilter): boolean { + return POST_VIEW_PARAMS.every((param) => { + const expected = view[param] ?? null; + + return expected === (params[param] ?? null); + }); +} + +/** + * Where the sidebar's top-level item should link. + * + * @param currentPathname so being *on* the route yields a bare URL + * @param views the saved and default views, so remembered params that are just + * a view don't make "Posts" a shortcut back into that view + */ +export function getStickyPostFilterUrl( + resource: PostResource, + currentPathname: string, + views: ViewFilter[] +): string { + if (currentPathname === `/${resource}`) { + return resource; + } + + const remembered = lastSeen.get(resource); + + if (!remembered) { + return resource; + } + + const params = toViewParams(`?${remembered}`); + + if (views.some(view => matchesView(params, view))) { + return resource; + } + + return `${resource}?${remembered}`; +} diff --git a/apps/admin/src/posts/list/prune-non-matching-posts.test.ts b/apps/admin/src/posts/list/prune-non-matching-posts.test.ts new file mode 100644 index 00000000000..d90906ee6c8 --- /dev/null +++ b/apps/admin/src/posts/list/prune-non-matching-posts.test.ts @@ -0,0 +1,128 @@ +import {describe, expect, it} from 'vitest'; +import {pruneNonMatchingPosts} from './prune-non-matching-posts'; +import type {PostListItem} from './hooks/use-posts-list'; + +/** + * After a bulk edit, Ember re-runs NQL *in the browser* against the list's own + * filter and drops the rows that no longer match — that is why unfeaturing a + * post while viewing `?type=featured` makes it disappear immediately, without a + * refetch and without losing scroll position. + * + * Ported from `updateFilteredPosts` in + * `apps/ember-admin/app/components/posts-list/context-menu.js`. + */ + +const post = (overrides: Partial = {}): PostListItem => ({ + id: 'p1', uuid: 'u1', url: 'u', slug: 'p', title: 'A post', status: 'draft', ...overrides +}); + +describe('pruneNonMatchingPosts', () => { + // The first rule, and the one with the worst failure mode: a row the user + // never touched must survive, whatever the filter says about it. Otherwise + // a bulk edit silently empties rows the action had nothing to do with. + it('never removes a post that was not edited', () => { + const untouched = post({id: 'other', featured: false}); + + const remaining = pruneNonMatchingPosts({ + posts: [untouched], + editedIds: new Set(['p1']), + filter: 'featured:true' + }); + + expect(remaining).toEqual([untouched]); + }); + + // The behaviour the whole module exists for: unfeature a post while viewing + // `?type=featured` and it leaves the list immediately. + it('removes an edited post that no longer matches the filter', () => { + const remaining = pruneNonMatchingPosts({ + posts: [post({id: 'p1', featured: false})], + editedIds: new Set(['p1']), + filter: 'featured:true' + }); + + expect(remaining).toEqual([]); + }); + + it('keeps an edited post that still matches', () => { + const stillFeatured = post({id: 'p1', featured: true}); + + const remaining = pruneNonMatchingPosts({ + posts: [stillFeatured], + editedIds: new Set(['p1']), + filter: 'featured:true' + }); + + expect(remaining).toEqual([stillFeatured]); + }); + + /** + * The expansions are not optional. Without them `tag:news` is looked up as + * a literal `tag` property, which posts do not have — so every edited post + * fails to match and the whole selection vanishes from the list. + */ + describe('the expansions', () => { + const tagged = (slug: string) => post({id: 'p1', tags: [{slug, name: slug}]}); + + it('matches a tag filter against the post tags', () => { + expect(pruneNonMatchingPosts({ + posts: [tagged('news')], + editedIds: new Set(['p1']), + filter: 'tag:news' + })).toHaveLength(1); + }); + + it('removes a post whose tags no longer include the filtered one', () => { + expect(pruneNonMatchingPosts({ + posts: [tagged('sport')], + editedIds: new Set(['p1']), + filter: 'tag:news' + })).toHaveLength(0); + }); + + it('matches an author filter against the post authors', () => { + const authored = post({id: 'p1', authors: [{slug: 'ada', name: 'Ada'}]}); + + expect(pruneNonMatchingPosts({ + posts: [authored], + editedIds: new Set(['p1']), + filter: 'authors:ada' + })).toHaveLength(1); + }); + }); + + // An unfiltered list bounds nothing, so nothing can fall out of it. NQL + // would throw on an empty string, which would take the whole action down + // after the server had already applied it. + it('keeps everything when the list has no filter', () => { + const posts = [post({id: 'p1'}), post({id: 'p2'})]; + + expect(pruneNonMatchingPosts({ + posts, + editedIds: new Set(['p1', 'p2']), + filter: '' + })).toEqual(posts); + }); + + // The analytics endpoints don't return relations, so a row can reach the + // pruner without `tags` at all. Throwing here would lose the list. + it('treats a post with no tags as not matching a tag filter', () => { + expect(pruneNonMatchingPosts({ + posts: [post({id: 'p1'})], + editedIds: new Set(['p1']), + filter: 'tag:news' + })).toHaveLength(0); + }); + + // A filter NQL cannot parse must not take the list with it: the edit has + // already happened server-side by this point. + it('keeps everything when the filter cannot be parsed', () => { + const posts = [post({id: 'p1'})]; + + expect(pruneNonMatchingPosts({ + posts, + editedIds: new Set(['p1']), + filter: 'status:' + })).toEqual(posts); + }); +}); diff --git a/apps/admin/src/posts/list/prune-non-matching-posts.ts b/apps/admin/src/posts/list/prune-non-matching-posts.ts new file mode 100644 index 00000000000..2a85333abad --- /dev/null +++ b/apps/admin/src/posts/list/prune-non-matching-posts.ts @@ -0,0 +1,66 @@ +import nql from '@tryghost/nql'; +import type {PostListItem} from '@/posts/list/hooks/use-posts-list'; + +/** + * Copied verbatim from Ember's `updateFilteredPosts`. Not optional: without + * them `tag:news` is looked up as a literal `tag` property, which a post does + * not have, so every edited post fails to match and the entire selection + * disappears from the list. + */ +const EXPANSIONS = [ + { + key: 'primary_tag', + replacement: 'tags.slug', + expansion: 'posts_tags.sort_order:0+tags.visibility:public' + }, + { + key: 'primary_author', + replacement: 'authors.slug', + expansion: 'posts_authors.sort_order:0+authors.visibility:public' + }, + {key: 'authors', replacement: 'authors.slug'}, + {key: 'author', replacement: 'authors.slug'}, + {key: 'tag', replacement: 'tags.slug'}, + {key: 'tags', replacement: 'tags.slug'} +]; + +interface PruneOptions { + posts: PostListItem[]; + /** Ids the bulk action actually edited — Ember's `availableModels`. */ + editedIds: Set; + /** The bucket's own filter, which the edited posts must still match. */ + filter: string; +} + +/** + * Drops the rows a bulk edit has pushed out of the given filter. + * + * Adapted from `updateFilteredPosts` in Ember's posts-list context menu — + * with one deliberate difference: the caller prunes per *bucket* filter, + * where Ember prunes every model against the list-wide filter and so leaves + * an unpublished row sitting in the published section of the unfiltered list. + */ +export function pruneNonMatchingPosts({posts, editedIds, filter}: PruneOptions): PostListItem[] { + const query = nql(filter, {expansions: EXPANSIONS}); + + try { + // NQL parses lazily, so building the query proves nothing — probe it + // once before trusting it with the list. + query.queryJSON({}); + } catch { + // By the time we prune, the server has already applied the edit. A + // filter NQL can't parse is not a reason to empty the list — leave the + // rows alone and let the next refetch sort them out. + return posts; + } + + return posts.filter((post) => { + // Untouched rows are never removed, whatever the filter says about + // them. Only what the action changed may disappear. + if (!editedIds.has(post.id)) { + return true; + } + + return query.queryJSON(post); + }); +} diff --git a/apps/admin/src/posts/list/use-post-filter-fields.test.ts b/apps/admin/src/posts/list/use-post-filter-fields.test.ts new file mode 100644 index 00000000000..b4324b7c2e7 --- /dev/null +++ b/apps/admin/src/posts/list/use-post-filter-fields.test.ts @@ -0,0 +1,74 @@ +import {describe, expect, it} from 'vitest'; +import {buildPostFilterFields} from './use-post-filter-fields'; +import type {ValueSource} from '@tryghost/shade/patterns'; + +const stubSource = {id: 'stub', useOptions: () => ({ + options: [], isInitialLoad: false, isSearching: false, isLoadingMore: false, hasMore: false, loadMore: () => {} +})} as ValueSource; + +function build(overrides: Parameters[0] extends infer T + ? Partial : never = {}) { + return buildPostFilterFields({ + resource: 'posts', + authorValueSource: stubSource, + tagValueSource: stubSource, + ...overrides + }); +} + +const keysOf = (fields: ReturnType) => fields.map(field => field.key); + +describe('buildPostFilterFields', () => { + it('offers the four filterable params, in Ember order', () => { + expect(keysOf(build())).toEqual(['type', 'visibility', 'author', 'tag']); + }); + + // Sorting is not a filter — it has no operator and belongs in its own + // control, so it must never appear as a chip. + it('never offers order as a filter', () => { + expect(keysOf(build())).not.toContain('order'); + }); + + it('labels the type field for the resource', () => { + expect(build().find(field => field.key === 'type')?.label).toBe('Post type'); + expect(build({resource: 'pages'}).find(field => field.key === 'type')?.label).toBe('Page type'); + }); + + it('drops "email only" from the type options on pages', () => { + const values = (fields: ReturnType) => + fields.find(field => field.key === 'type')?.options?.map(option => option.value); + + expect(values(build())).toContain('sent'); + expect(values(build({resource: 'pages'}))).not.toContain('sent'); + }); + + // Ember hides visibility, author and tag for contributors, and author for + // authors too — they only ever see their own posts. + it('hides visibility, author and tag from contributors', () => { + expect(keysOf(build({isContributor: true}))).toEqual(['type']); + }); + + it('hides the author filter from authors', () => { + expect(keysOf(build({isAuthorOrContributor: true}))).toEqual(['type', 'visibility', 'tag']); + }); + + it('uses single-select equality throughout', () => { + build().forEach((field) => { + expect(field.operators?.map(operator => operator.value)).toEqual(['is']); + }); + }); + + it('gives author and tag async value sources rather than fixed options', () => { + const author = build().find(field => field.key === 'author'); + const tag = build().find(field => field.key === 'tag'); + + expect(author?.valueSource).toBe(stubSource); + expect(tag?.valueSource).toBe(stubSource); + expect(author?.options).toBeUndefined(); + }); + + it('carries the paid+tiers visibility value as one opaque option', () => { + expect(build().find(field => field.key === 'visibility')?.options?.map(option => option.value)) + .toEqual(['public', 'members', '[paid,tiers]']); + }); +}); diff --git a/apps/admin/src/posts/list/use-post-filter-fields.tsx b/apps/admin/src/posts/list/use-post-filter-fields.tsx new file mode 100644 index 00000000000..f0b64d6b250 --- /dev/null +++ b/apps/admin/src/posts/list/use-post-filter-fields.tsx @@ -0,0 +1,134 @@ +import {LucideIcon} from '@tryghost/shade/utils'; +import {type PostFilterOption, VISIBILITY_OPTIONS, getTypeOptions} from '@/posts/list/post-filter-fields'; +import {isAuthorOrContributor, isContributorUser} from '@tryghost/admin-x-framework/api/users'; +import {usePostAuthorValueSource} from '@/shared/filter-sources/use-post-author-value-source'; +import {usePostTagValueSource} from '@/shared/filter-sources/use-post-tag-value-source'; +import type {FilterFieldConfig, ValueSource} from '@tryghost/shade/patterns'; +import type {PostResource} from '@/posts/list/post-resource'; +import type {User} from '@tryghost/admin-x-framework/api/users'; + +/** + * The Shade field config for the posts/pages filter bar. + * + * All four fields are single-select equality — Ember offers nothing else, and + * the URL can only hold one value per param, so anything richer would produce + * URLs the Ember screen renders as "Unknown". + * + * `order` is deliberately absent: it is a sort, not a filter, and lives in its + * own control. + */ + +export interface BuildPostFilterFieldsOptions { + resource: PostResource; + authorValueSource: ValueSource; + tagValueSource: ValueSource; + /** Contributors see only their own posts, so only the type filter. */ + isContributor?: boolean; + /** Authors are scoped to themselves, so the author filter is meaningless. */ + isAuthorOrContributor?: boolean; + /** + * The params currently in the URL. A value that isn't a known option gets + * an "Unknown" entry so the chip still shows something — otherwise Shade + * falls back to "Select…" and the filter vanishes from the UI while + * staying in the URL. Ember shows a red "Unknown type" for the same case. + */ + params?: Partial>; +} + +const IS_ONLY = [{value: 'is', label: 'is'}]; + +function withUnknownOption( + options: PostFilterOption[], + value: string | null | undefined, + noun: string +): PostFilterOption[] { + if (!value || options.some(option => option.value === value)) { + return options; + } + + return [...options, {value, label: `Unknown ${noun}`}]; +} + +export function buildPostFilterFields({ + resource, + authorValueSource, + tagValueSource, + isContributor = false, + isAuthorOrContributor: authorScoped = false, + params = {} +}: BuildPostFilterFieldsOptions): FilterFieldConfig[] { + const noun = resource === 'pages' ? 'Page' : 'Post'; + + const typeField: FilterFieldConfig = { + key: 'type', + label: `${noun} type`, + type: 'select', + icon: , + operators: IS_ONLY, + options: withUnknownOption(getTypeOptions(resource), params.type, 'type') + }; + + if (isContributor) { + return [typeField]; + } + + const fields: FilterFieldConfig[] = [ + typeField, + { + key: 'visibility', + label: 'Access', + type: 'select', + icon: , + operators: IS_ONLY, + options: withUnknownOption(VISIBILITY_OPTIONS, params.visibility, 'access') + } + ]; + + if (!authorScoped) { + fields.push({ + key: 'author', + label: 'Author', + type: 'select', + icon: , + operators: IS_ONLY, + searchable: true, + placeholder: 'Search authors', + valueSource: authorValueSource + }); + } + + fields.push({ + key: 'tag', + label: 'Tag', + type: 'select', + icon: , + operators: IS_ONLY, + searchable: true, + placeholder: 'Search tags', + valueSource: tagValueSource, + // Wider than the 200px default: each row carries the tag's name and its + // slug side by side, and the slug is what tells two same-named tags + // apart — it should not be the first thing to truncate. + className: 'w-[320px]' + }); + + return fields; +} + +export function usePostFilterFields( + resource: PostResource, + currentUser?: User, + params?: BuildPostFilterFieldsOptions['params'] +): FilterFieldConfig[] { + const authorValueSource = usePostAuthorValueSource(); + const tagValueSource = usePostTagValueSource(); + + return buildPostFilterFields({ + resource, + authorValueSource, + tagValueSource, + isContributor: Boolean(currentUser && isContributorUser(currentUser)), + isAuthorOrContributor: Boolean(currentUser && isAuthorOrContributor(currentUser)), + params + }); +} diff --git a/apps/admin/src/routes.tsx b/apps/admin/src/routes.tsx index d92719dd621..f6e701d4a6f 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -12,8 +12,8 @@ import MyProfileRedirect from "./my-profile-redirect"; import { EmberFallback, ForceUpgradeGuard } from "./ember-bridge"; import type { RouteHandle } from "./ember-bridge"; import HomeRedirect from "./home-redirect"; -import { EmberListWithGiftLinks } from "./gift-link-modal-host"; import { MemberDetailGate } from "./member-detail-gate"; +import { PagesListGate, PostsListGate } from "./posts-list-gate"; import { TagDetailGate } from "./tag-detail-gate"; import { OnboardingRedirect } from "./onboarding/onboarding-redirect"; import { type AccessRouteHandle, RouteAccessGuard } from "./route-access-guard"; @@ -43,10 +43,28 @@ const EMBER_ROUTES: string[] = [ const emberFallbackHandle = { allowInForceUpgrade: true } satisfies RouteHandle; +/** + * Ember routes that hide the nav sidebar. + * + * The editor is a focused writing surface and has always hidden it. Ember + * arranges that by setting `ui.isFullScreen` when the editor route *activates* — + * but with `postsListReact` on, the posts route aborts its transition, so the + * editor route never deactivates, and a second visit is a model change on an + * already-active route where `activate()` does not run again. The sidebar came + * back from the second post onwards. + * + * Deciding it from the route makes React the authority and removes the + * cross-implementation handshake, which had already caused the mirror-image bug + * (the sidebar going *missing* on returning from the editor). + */ +const EMBER_ROUTES_HIDING_SIDEBAR = new Set(["/editor/*"]); + const emberFallbackRoutes: RouteObject[] = EMBER_ROUTES.map(path => ({ path, Component: EmberFallback, - handle: emberFallbackHandle, + handle: EMBER_ROUTES_HIDING_SIDEBAR.has(path) + ? { ...emberFallbackHandle, hideAdminSidebar: true } satisfies RouteHandle & AdminRouteHandle + : emberFallbackHandle, })); const membersRoute: RouteObject = { @@ -194,8 +212,11 @@ const appRoutes: RouteObject[] = [ requiresAccess: canAccessSettingsRoute } satisfies RouteHandle & AdminRouteHandle & AccessRouteHandle, }, - {path: "/posts", Component: EmberListWithGiftLinks, handle: emberFallbackHandle}, - {path: "/pages", Component: EmberListWithGiftLinks, handle: emberFallbackHandle}, + // Served by React or Ember depending on the `postsListReact` Labs flag. + // The handle stays emberFallbackHandle so force-upgrade behaves the same + // on both sides of the flag. + {path: "/posts", Component: PostsListGate, handle: emberFallbackHandle}, + {path: "/pages", Component: PagesListGate, handle: emberFallbackHandle}, // Ember-handled routes ...emberFallbackRoutes, { diff --git a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx index db5abefecac..c0f01d4cd8e 100644 --- a/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/app/components/settings/advanced/labs/private-features.tsx @@ -87,6 +87,10 @@ const features: Feature[] = [{ title: 'Gift subscription customization', description: 'Enables fixed-duration gift subscription purchases before publisher configuration is available', flag: 'giftSubCustomization' +}, { + title: 'React posts & pages lists', + description: 'Renders the posts (/posts) and pages (/pages) list screens from the React app instead of the Ember screens. Gates the migration behind a runtime toggle so we can compare both implementations.', + flag: 'postsListReact' }]; const AlphaFeatures: React.FC = () => { diff --git a/apps/admin/src/analytics/views/stats/components/feature-image-placeholder.tsx b/apps/admin/src/shared/feature-image-placeholder.tsx similarity index 100% rename from apps/admin/src/analytics/views/stats/components/feature-image-placeholder.tsx rename to apps/admin/src/shared/feature-image-placeholder.tsx diff --git a/apps/admin/src/shared/filter-sources/use-post-author-value-source.ts b/apps/admin/src/shared/filter-sources/use-post-author-value-source.ts new file mode 100644 index 00000000000..3eda4794cdc --- /dev/null +++ b/apps/admin/src/shared/filter-sources/use-post-author-value-source.ts @@ -0,0 +1,59 @@ +import {type User, type UsersResponseType, useBrowseUsers} from '@tryghost/admin-x-framework/api/users'; +import {type ValueSource} from '@tryghost/shade/patterns'; +import {buildQuotedListFilter} from './utils'; +import {createGhostBrowseValueSource} from './create-ghost-browse-value-source'; +import {escapeNqlString} from '@tryghost/nql-string'; +import {keepPreviousData} from '@tanstack/react-query'; + +/** + * Staff users for the posts/pages author filter. + * + * Value is the **slug**, matching the `?author=` URL param and saved views. + * Falls back to the email for staff who were invited but never set a name, as + * Ember's `post-author-names` helper does. + * + * Search matches Ember's `name:~` (`controllers/posts.js:146`). + */ +const AUTHOR_PAGE_LIMIT = '100'; + +function toAuthorOption(user: User) { + return { + value: user.slug, + label: user.name || user.email, + metadata: {id: user.id} + }; +} + +const usePostAuthorBrowseValueSource = createGhostBrowseValueSource({ + id: 'posts.authors', + buildBrowseSearchParams: query => ({ + limit: AUTHOR_PAGE_LIMIT, + order: 'name asc', + ...(query ? {filter: `name:~${escapeNqlString(query)}`} : {}) + }), + buildHydrateFilter: selectedValues => buildQuotedListFilter('slug', selectedValues), + buildHydrateSearchParams: selectedFilter => ({ + filter: selectedFilter, + order: 'name asc' + }), + // See the tag source: without this the chip reads "Select…" and the value + // disappears from the UI. Ember shows "Unknown author". + getMissingSelectedOption: value => ({ + value, + label: 'Unknown author' + }), + selectItems: data => data?.users, + useQuery: ({enabled, searchParams}) => { + return useBrowseUsers({ + enabled, + placeholderData: keepPreviousData, + searchParams + }); + }, + toOption: toAuthorOption, + debounceMs: 250 +}); + +export function usePostAuthorValueSource(): ValueSource { + return usePostAuthorBrowseValueSource(); +} diff --git a/apps/admin/src/shared/filter-sources/use-post-tag-value-source.ts b/apps/admin/src/shared/filter-sources/use-post-tag-value-source.ts new file mode 100644 index 00000000000..5b8754ac133 --- /dev/null +++ b/apps/admin/src/shared/filter-sources/use-post-tag-value-source.ts @@ -0,0 +1,73 @@ +import {type Tag, type TagsResponseType, useBrowseTags} from '@tryghost/admin-x-framework/api/tags'; +import {type ValueSource} from '@tryghost/shade/patterns'; +import {buildQuotedListFilter} from './utils'; +import {createGhostBrowseValueSource} from './create-ghost-browse-value-source'; +import {escapeNqlString} from '@tryghost/nql-string'; +import {keepPreviousData} from '@tanstack/react-query'; + +/** + * Tags for the posts/pages tag filter. + * + * The value is the **slug**, because that is what the `?tag=` URL param and + * sidebar saved views carry — but the chip has to read as the tag's name. The + * hydrate step covers the case that makes this necessary: opening a saved view + * whose tag isn't in the first page of results, where the chip would otherwise + * show a bare slug. + * + * Search matches Ember's `tags.name:~` (`controllers/posts.js:131`). + */ +const TAG_PAGE_LIMIT = '100'; + +function toTagOption(tag: Tag) { + return { + value: tag.slug, + label: tag.name, + // The slug, and it does more than inform. Names are not unique — a site + // can carry two tags called "broaf" — and Shade builds each row's + // `cmdk` value from `label` plus `detail`. Without a detail the two + // share one value, so `cmdk` treats them as a single row and highlights + // both at once. The slug is also the thing that tells them apart, so it + // is shown rather than only carried. + detail: tag.slug, + metadata: {id: tag.id} + }; +} + +const usePostTagBrowseValueSource = createGhostBrowseValueSource({ + id: 'posts.tags', + buildBrowseSearchParams: query => ({ + limit: TAG_PAGE_LIMIT, + order: 'name asc', + ...(query ? {filter: `tags.name:~${escapeNqlString(query)}`} : {}) + }), + buildHydrateFilter: selectedValues => buildQuotedListFilter('slug', selectedValues), + buildHydrateSearchParams: selectedFilter => ({ + filter: selectedFilter, + order: 'name asc' + }), + // Without this, a slug that resolves to nothing leaves the chip reading + // "Select…" — the value vanishes from the UI while staying in the URL, so + // the list looks empty for no visible reason. Ember shows "Unknown tag". + getMissingSelectedOption: value => ({ + value, + label: 'Unknown tag' + }), + selectItems: data => data?.tags, + useQuery: ({enabled, searchParams}) => { + // `useBrowseTags` builds its own comma-joined filter from the `filter` + // object; passing an empty one and overriding via searchParams keeps + // the NQL we want. + return useBrowseTags({ + filter: {}, + enabled, + placeholderData: keepPreviousData, + searchParams + }); + }, + toOption: toTagOption, + debounceMs: 250 +}); + +export function usePostTagValueSource(): ValueSource { + return usePostTagBrowseValueSource(); +} diff --git a/apps/admin/src/shared/gift-link.test.ts b/apps/admin/src/shared/gift-link.test.ts new file mode 100644 index 00000000000..bc8890eb9eb --- /dev/null +++ b/apps/admin/src/shared/gift-link.test.ts @@ -0,0 +1,74 @@ +import {canCopyGiftLink} from './gift-link'; +import type {UserRoleType} from '@tryghost/admin-x-framework/api/roles'; +import {describe, expect, it} from 'vitest'; + +/** + * Ported from `apps/ember-admin/app/utils/gift-link.js`, which the Ember + * context menu imports. Both implementations now read the same rules, so the + * entry point can't appear on one side and not the other. + * + * A gift link shares a *gated* post with someone who isn't a member, so the + * two halves of the rule are: a user senior enough to hand out access, and a + * post that actually withholds it. + */ + +const user = (roles: UserRoleType[]) => ({roles: roles.map(name => ({name}))}); + +const post = (overrides: Record = {}) => ({ + id: 'p1', status: 'published', visibility: 'paid', ...overrides +}); + +describe('canCopyGiftLink', () => { + describe('who may share one', () => { + it.each([ + 'Owner', + 'Administrator', + 'Editor', + 'Super Editor' + ] as const)('allows an %s', (role) => { + expect(canCopyGiftLink({user: user([role]), post: post()})).toBe(true); + }); + + it.each([ + 'Author', + 'Contributor' + ] as const)('refuses a %s', (role) => { + expect(canCopyGiftLink({user: user([role]), post: post()})).toBe(false); + }); + }); + + describe('what may be shared', () => { + it('allows a published post behind a paywall', () => { + expect(canCopyGiftLink({user: user(['Owner']), post: post({visibility: 'paid'})})).toBe(true); + }); + + it('allows a members-only post', () => { + expect(canCopyGiftLink({user: user(['Owner']), post: post({visibility: 'members'})})).toBe(true); + }); + + // Nothing to gift: anyone can already read it. + it('refuses a public post', () => { + expect(canCopyGiftLink({user: user(['Owner']), post: post({visibility: 'public'})})).toBe(false); + }); + + it('refuses a draft, which has nothing to share yet', () => { + expect(canCopyGiftLink({user: user(['Owner']), post: post({status: 'draft'})})).toBe(false); + }); + + it('refuses a scheduled post', () => { + expect(canCopyGiftLink({user: user(['Owner']), post: post({status: 'scheduled'})})).toBe(false); + }); + + it('refuses a post with no visibility set at all', () => { + expect(canCopyGiftLink({user: user(['Owner']), post: post({visibility: undefined})})).toBe(false); + }); + }); + + it('refuses when there is no post', () => { + expect(canCopyGiftLink({user: user(['Owner']), post: undefined})).toBe(false); + }); + + it('refuses when there is no user', () => { + expect(canCopyGiftLink({user: undefined, post: post()})).toBe(false); + }); +}); diff --git a/apps/admin/src/shared/gift-link.ts b/apps/admin/src/shared/gift-link.ts new file mode 100644 index 00000000000..d2dd8f48ddd --- /dev/null +++ b/apps/admin/src/shared/gift-link.ts @@ -0,0 +1,44 @@ +import {hasAdminAccess, isEditorUser} from '@tryghost/admin-x-framework/api/users'; + +/** + * Whether the current user may share a gift link for this post. + * + * Ported from `apps/ember-admin/app/utils/gift-link.js`, which the Ember + * context menu imports. Both implementations read this rule, so the entry + * point can't appear on one side of the flag and not the other. + * + * Two halves: a user senior enough to hand out access, and a post that + * actually withholds it. A public post has nothing to gift. + * + * Only decides whether to *offer* the action — the URL itself is built by the + * modal. + */ + +/** Whatever the framework's role helpers accept, so the shapes can't drift. */ +type GiftLinkUser = Parameters[0]; + +interface GiftLinkPost { + status?: string; + visibility?: string; +} + +export function canCopyGiftLink({user, post}: { + user?: GiftLinkUser | null; + post?: GiftLinkPost | null; +}): boolean { + if (!user || !post) { + return false; + } + + // Ember's `isAdmin || isEitherEditor`. Two traps: `isAdmin` there means + // Owner *or* Administrator, which is `hasAdminAccess` here and not + // `isAdminUser`; and `isEditorUser` already covers Super Editor, so + // Ember's `or('isEditor', 'isSuperEditor')` is a single call. + const canManage = hasAdminAccess(user) || isEditorUser(user); + + const isGated = post.status === 'published' + && Boolean(post.visibility) + && post.visibility !== 'public'; + + return canManage && isGated; +} diff --git a/apps/admin/test-utils/acceptance/index.ts b/apps/admin/test-utils/acceptance/index.ts index bd66e997c60..845eccefecb 100644 --- a/apps/admin/test-utils/acceptance/index.ts +++ b/apps/admin/test-utils/acceptance/index.ts @@ -2,7 +2,7 @@ export { fakeAnalyticsOverview } from "./analytics"; export { currentRoute, renderAdminApp } from "./render-admin-app"; export type { RenderAdminAppOptions } from "./render-admin-app"; -export { defineResource, fakeActions, fakeAutomations, fakeComments, fakeEditSettings, fakeIntegrations, fakeInvites, fakeLabels, fakeMembers, fakeNewsletters, fakeOffers, fakePosts, fakeRoles, fakeSettingsScreens, fakeTags, fakeThemes, fakeTiers, fakeUsers } from "./resources"; +export { defineResource, fakeActions, fakeAutomations, fakeComments, fakeEditSettings, fakeIntegrations, fakeInvites, fakeLabels, fakeMembers, fakeNewsletters, fakeOffers, fakePages, fakePosts, fakePostsListScreen, fakeRoles, fakeSettingsScreens, fakeTags, fakeThemes, fakeTiers, fakeUsers } from "./resources"; export type { BrowseQuery, EditSettingsCapture, FakeMembersOptions, ResourceCapture, ResourceOptions, ResourceSemantics, RespondWith } from "./resources"; export { allowUnhandledRequests, fakeAdminEndpoint, fakeEndpoint, fakeSitePreview } from "./worker"; export type { CapturedEndpointRequest, EndpointCapture, FakeAdminEndpointResponse, FakeEndpointOptions, SitePreviewCapture, SitePreviewRequest } from "./worker"; diff --git a/apps/admin/test-utils/acceptance/resources.ts b/apps/admin/test-utils/acceptance/resources.ts index a570dabddaf..70a442fb94b 100644 --- a/apps/admin/test-utils/acceptance/resources.ts +++ b/apps/admin/test-utils/acceptance/resources.ts @@ -21,7 +21,7 @@ import { type Tier, } from "@tryghost/test-data"; -import { record418, registerAdminApiHandler, registerRoute } from "./worker"; +import { fakeAdminEndpoint, record418, registerAdminApiHandler, registerRoute } from "./worker"; export interface BrowseQuery { /** Full request URL, for raw assertions on encoding. */ @@ -219,6 +219,14 @@ const newslettersResource = defineResource({ resource: "newsletters" */ export const fakePosts = defineResource({ resource: "posts", semantics: { kind: "passthrough" } }); +/** + * Pages list fake (passthrough). The pages list screen browses this endpoint + * once per status bucket, exactly as the posts one does — declare the response + * (a function of the query, if a test needs each bucket to differ) and assert + * the outgoing filters. + */ +export const fakePages = defineResource({ resource: "pages", semantics: { kind: "passthrough" } }); + /** Tiers list fake (passthrough): serves the declared tiers and captures every browse request. */ export const fakeTiers = defineResource({ resource: "tiers", semantics: { kind: "passthrough" } }); @@ -320,6 +328,21 @@ export function fakeSettingsScreens(): void { }); } +/** + * Declares the chrome every posts/pages list mount reads: the batched + * analytics counts the metric columns request, and the tag/author worlds the + * filter bar and its slug lookups probe. Screen-specific data a spec asserts + * on is declared in the spec — a fake registered after this one wins. + */ +export function fakePostsListScreen(): void { + fakeAdminEndpoint("POST", "/stats/posts-visitor-counts/", { stats: [{ data: { visitor_counts: {} } }] }); + fakeAdminEndpoint("POST", "/stats/posts-member-counts/", { stats: [{ data: { member_counts: {} } }] }); + fakeTags([]); + fakeUsers([]); + fakeAdminEndpoint("GET", /^\/tags\/\?.*slug/, { tags: [] }); + fakeAdminEndpoint("GET", /^\/users\/\?.*slug/, { users: [] }); +} + type SettingsPutBody = { settings: Array<{ key: string; value: string | boolean | null }> }; export interface EditSettingsCapture { diff --git a/apps/ember-admin/app/routes/posts.js b/apps/ember-admin/app/routes/posts.js index 424acdb925e..19052b2ea73 100644 --- a/apps/ember-admin/app/routes/posts.js +++ b/apps/ember-admin/app/routes/posts.js @@ -41,6 +41,7 @@ export default class PostsRoute extends AuthenticatedRoute { @service feature; @service postAnalytics; @service settings; + @service ui; queryParams = { type: {refreshModel: true}, @@ -70,6 +71,115 @@ export default class PostsRoute extends AuthenticatedRoute { }); } + // React owns /posts and /pages when the flag is on. Aborting keeps the + // Ember subtree unrendered, so `data-testid` attributes exist in only one + // tree and none of the three infinity models below fire for a screen + // nobody sees. Inherited by PagesRoute, so this covers both URLs. + beforeModel(transition) { + super.beforeModel(...arguments); + + // Strictly boolean, matching the tag route: a non-boolean labs value + // must not hand the route to React. + if (this.feature.postsListReact !== true) { + return; + } + + transition.abort(); + + // Aborting means the route we came FROM never deactivates, so any UI + // state its teardown would have cleared stays set. The editor's + // `deactivate` clears full-screen mode, and the React shell reads that + // to decide whether to show the sidebar - so without this, returning + // from the editor leaves you looking at a sidebar-less screen. + this.ui.set('isFullScreen', false); + + // Ember and React share window.location.hash, and an aborted + // transition never reaches updateURL - so a navigation Ember itself + // started would be a silent no-op without writing the URL ourselves. + // + // The transition intent says which case we're in. A URL intent (cold + // load, hash change, React-driven navigation) already has the browser + // URL pointing here, so React renders and there is nothing to do - + // and leaving it alone is what keeps query params like ?type=draft, + // which is how saved views are addressed, intact. A named intent + // (`transitionTo('posts')` from the publish flow, or a + // `` breadcrumb) has no URL yet, so we supply + // one. + if (!transition.intent?.url) { + this._navigateToReactRoute(this._reactRouteUrl(transition)); + } + + this._parkOnReactFallback(); + } + + // Aborting stops Ember rendering this screen, but it also leaves the router + // believing it is still on the route we came from - `currentRouteName` stays + // `lexical-editor.edit` while the browser is showing the React list. That + // desync is only invisible until you navigate back to the very same URL: + // Ember compares it against the route it thinks it is on, finds no + // difference, and runs no transition at all, so the editor never + // re-activates and you get an empty screen. Opening a *different* post + // masks it, because a different id is a real change. + // + // So park on `react-fallback` - the empty catch-all Ember already uses for + // URLs React owns. It makes the router's state honest: the editor + // deactivates properly, and coming back to it is a real transition again. + // + // `replaceWith`, never `transitionTo`: this is a correction to router state + // the user did not ask for, so it must not add a history entry. The URL is + // left alone - React owns it, and rewriting it here would drop the query + // params that address saved views. + // + // The guard compares the parked *path*, not just the route name: without + // it parking loops, and on the name alone it parks only once, pinning the + // router at whatever the admin booted on - which the editor's back button + // reads via `transition.from.params.path`. + _parkOnReactFallback() { + const parkedPath = this.router.currentRouteName === 'react-fallback' + ? this.router.currentRoute?.params?.path + : null; + + if (parkedPath === this.routeName) { + return; + } + + const url = window.location.hash; + const state = window.history.state; + + this.router.replaceWith('react-fallback', this.routeName) + .finally(() => this._restoreUrl(url, state)); + } + + // Parking writes the fallback route's own path, dropping the query string + // that addresses saved views - so the captured URL goes back afterwards. + // `replaceState`: no history entry, and no `hashchange` to re-enter + // routing. The captured history state goes back too, unconditionally: + // react-router keeps `{usr, key, idx}` there, and the parking navigation + // resets it - a `null` state breaks its back/forward index and useBlocker. + _restoreUrl(url, state) { + window.history.replaceState(state, '', url); + } + + // Built by hand rather than with `router.urlFor`, whose output depends on + // the configured location - it returns `/ghost/posts` under the `none` + // location used in tests but `#/posts/` under `trailing-hash` in the app. + // These routes have no dynamic segments, so the path is just the name. + _reactRouteUrl(transition) { + const queryParams = transition.to?.queryParams ?? {}; + const search = Object.entries(queryParams) + .filter(([, value]) => value !== null && value !== undefined && value !== '') + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + + return search ? `/${this.routeName}?${search}` : `/${this.routeName}`; + } + + // Seam so tests can assert the navigation without a real hash location - + // Ember acceptance tests run with `location: 'none'`. + _navigateToReactRoute(url) { + window.location.hash = url; + } + model(params) { // Reset analytics cache every time we load the posts index to ensure fresh data if (this.settings.webAnalyticsEnabled || this.settings.membersTrackSources) { diff --git a/apps/ember-admin/app/services/feature.js b/apps/ember-admin/app/services/feature.js index b9c54cafa05..f5e50def3ae 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -87,6 +87,7 @@ export default class FeatureService extends Service { @feature('paywallImprovements') paywallImprovements; @feature('automations') automations; @feature('csvContentImporter') csvContentImporter; + @feature('postsListReact') postsListReact; _user = null; @computed('settings.labs') diff --git a/apps/ember-admin/tests/acceptance/posts-list-react-flag-test.js b/apps/ember-admin/tests/acceptance/posts-list-react-flag-test.js new file mode 100644 index 00000000000..3d251a41ed1 --- /dev/null +++ b/apps/ember-admin/tests/acceptance/posts-list-react-flag-test.js @@ -0,0 +1,221 @@ +import sinon from 'sinon'; +import {afterEach, beforeEach, describe, it} from 'mocha'; +import {authenticateSession} from 'ember-simple-auth/test-support'; +import {enableLabsFlag} from '../helpers/labs-flag'; +import {expect} from 'chai'; +import {find, settled, visit} from '@ember/test-helpers'; +import {setupApplicationTest} from 'ember-mocha'; +import {setupMirage} from 'ember-cli-mirage/test-support'; + +// The `postsListReact` flag hands /posts and /pages to the React app. Ember's +// side of that handshake is PostsRoute#beforeModel: it aborts so the Ember +// subtree stays unrendered, and drives window.location.hash so navigations +// Ember itself starts still land somewhere (an aborted transition never +// reaches updateURL, and the two apps share the hash). + +// `visit()` rejects with TransitionAborted whenever the route aborts, which is +// the whole point of the flag being on. Swallow only that rejection so the +// assertions below can run; anything else still fails the test. +async function visitExpectingAbort(url) { + try { + await visit(url); + } catch (error) { + if (error?.message !== 'TransitionAborted' && error?.name !== 'TransitionAborted') { + throw error; + } + } + await settled(); +} + +describe('Acceptance: posts/pages React flag', function () { + let hooks = setupApplicationTest(); + setupMirage(hooks); + + beforeEach(async function () { + this.server.loadFixtures('configs'); + this.server.loadFixtures('settings'); + + let role = this.server.create('role', {name: 'Administrator'}); + this.server.create('user', {roles: [role]}); + + return await authenticateSession(); + }); + + afterEach(function () { + sinon.restore(); + }); + + describe('when the flag is off', function () { + it('renders the Ember posts list', async function () { + this.server.createList('post', 2); + + await visit('/posts'); + + expect(find('[data-testid="posts-list"]'), 'Ember posts list').to.exist; + }); + + it('renders the Ember pages list', async function () { + this.server.createList('page', 2); + + await visit('/pages'); + + expect(find('[data-testid="posts-list"]'), 'Ember pages list').to.exist; + }); + }); + + describe('when the flag is on', function () { + beforeEach(function () { + enableLabsFlag(this.server, 'postsListReact'); + }); + + it('does not render the Ember posts list', async function () { + this.server.createList('post', 2); + + await visitExpectingAbort('/posts'); + + expect(find('[data-testid="posts-list"]'), 'Ember posts list').to.not.exist; + expect(find('[data-testid="posts-filters"]'), 'Ember posts filters').to.not.exist; + }); + + it('does not render the Ember pages list', async function () { + this.server.createList('page', 2); + + await visitExpectingAbort('/pages'); + + expect(find('[data-testid="posts-list"]'), 'Ember pages list').to.not.exist; + }); + + it('does not fetch posts for a screen it will not render', async function () { + this.server.createList('post', 2); + + let postRequests = 0; + this.server.pretender.handledRequest = (verb, path) => { + if (verb === 'GET' && path === '/ghost/api/admin/posts/') { + postRequests += 1; + } + }; + + await visitExpectingAbort('/posts'); + + expect(postRequests, 'GET /posts/ requests').to.equal(0); + }); + + // The regression this guards: transitionTo('posts') from the publish + // flow, and the breadcrumbs on the debug and + // member screens, all abort. Without supplying a URL they are silent + // no-ops and the user is stranded on the previous screen. + it('navigates React when Ember initiates a transition into posts', async function () { + const route = this.owner.lookup('route:posts'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/tags'); + this.owner.lookup('service:router').transitionTo('posts'); + await settled(); + + expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; + expect(navigate.firstCall.args[0], 'target url').to.equal('/posts'); + }); + + it('carries query params through an Ember-initiated transition', async function () { + const route = this.owner.lookup('route:posts'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/tags'); + this.owner.lookup('service:router').transitionTo('posts', {queryParams: {type: 'draft'}}); + await settled(); + + expect(navigate.firstCall.args[0], 'target url').to.equal('/posts?type=draft'); + }); + + it('navigates React when Ember initiates a transition into pages', async function () { + const route = this.owner.lookup('route:pages'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/tags'); + this.owner.lookup('service:router').transitionTo('pages'); + await settled(); + + expect(navigate.calledOnce, '_navigateToReactRoute called once').to.be.true; + expect(navigate.firstCall.args[0], 'target url').to.equal('/pages'); + }); + + // Aborting means the route we came FROM never deactivates, so any UI + // state its teardown would have cleared stays set. The editor's + // teardown is the one that shows: it clears full-screen mode, which is + // what the React shell reads to decide whether to show the sidebar. + // Without this, returning from the editor leaves you with no sidebar. + it('leaves full-screen mode when it aborts', async function () { + const ui = this.owner.lookup('service:ui'); + ui.set('isFullScreen', true); + + await visitExpectingAbort('/posts'); + + expect(ui.isFullScreen, 'isFullScreen after aborting into posts').to.be.false; + }); + + it('leaves full-screen mode when it aborts into pages', async function () { + const ui = this.owner.lookup('service:ui'); + ui.set('isFullScreen', true); + + await visitExpectingAbort('/pages'); + + expect(ui.isFullScreen, 'isFullScreen after aborting into pages').to.be.false; + }); + + // Query params are how saved views are addressed, so a URL that already + // points at this route must be left exactly as it is. + it('does not rewrite a URL-initiated navigation', async function () { + const route = this.owner.lookup('route:posts'); + const navigate = sinon.stub(route, '_navigateToReactRoute'); + + await visitExpectingAbort('/posts?type=draft'); + + expect(navigate.called, '_navigateToReactRoute called').to.be.false; + }); + + // Regression: aborting alone left the router still reporting the route + // it came from, so returning to that same URL later was a no-op + // transition that rendered nothing - the editor came back blank. Parking + // on the catch-all keeps the router's own state truthful. + it('parks the router on the React fallback route', async function () { + const router = this.owner.lookup('service:router'); + + await visitExpectingAbort('/posts'); + + expect(router.currentRouteName, 'currentRouteName after aborting').to.equal('react-fallback'); + }); + + // ...and parks with `replaceWith`, not `transitionTo`: this corrects + // router state the user never asked to change, so it must not put an + // extra entry in their way when they press Back. + // + // Only the positive assertion is made. `transitionTo` cannot serve as a + // negative signal here, because Ember's own `replaceWith` is built on + // top of it - spying on it reports a call either way. + it('parks with replace semantics so no history entry is added', async function () { + const route = this.owner.lookup('route:posts'); + const replaceWith = sinon.spy(route.router, 'replaceWith'); + + await visitExpectingAbort('/posts'); + + expect(replaceWith.calledWith('react-fallback', 'posts'), 'replaceWith called').to.be.true; + }); + + // Regression: the guard used to match on the route name alone, so once + // parked anywhere the router never moved again. Arriving from + // /analytics — which the admin boots on — left it pinned there, and the + // editor reads that path to label its back button, offering "Analytics" + // for a post opened from the list. + it('re-parks when already parked at a different path', async function () { + const router = this.owner.lookup('service:router'); + + await visitExpectingAbort('/analytics'); + expect(router.currentRoute?.params?.path, 'parked path after /analytics').to.equal('analytics'); + + await visitExpectingAbort('/posts'); + + expect(router.currentRouteName, 'currentRouteName after /posts').to.equal('react-fallback'); + expect(router.currentRoute?.params?.path, 'parked path after /posts').to.equal('posts'); + }); + }); +}); diff --git a/apps/ember-admin/tests/unit/routes/posts-test.js b/apps/ember-admin/tests/unit/routes/posts-test.js new file mode 100644 index 00000000000..c7d440dfcec --- /dev/null +++ b/apps/ember-admin/tests/unit/routes/posts-test.js @@ -0,0 +1,74 @@ +import Service from '@ember/service'; +import sinon from 'sinon'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {setupTest} from 'ember-mocha'; + +describe('Unit: Route: posts', function () { + setupTest(); + + afterEach(function () { + sinon.restore(); + }); + + // The router and ui services are stubbed per-method on the real instances: + // wholesale service stubs miss the surface other injected services touch + // during the route's own instantiation. + function setupRoute(owner, {flagValue}) { + class SessionStub extends Service { + isAuthenticated = true; + user = {isAuthorOrContributor: false}; + requireAuthentication = sinon.spy(); + } + class FeatureStub extends Service { + postsListReact = flagValue; + } + owner.register('service:session', SessionStub); + owner.register('service:feature', FeatureStub); + + const router = owner.lookup('service:router'); + sinon.stub(router, 'on'); + sinon.stub(router, 'replaceWith').returns({finally: sinon.stub()}); + + const route = owner.lookup('route:posts'); + // Set by the router when it mounts the route; a bare unit lookup has + // no router, so pin the name the parking call passes along. + route.routeName = 'posts'; + const ui = owner.lookup('service:ui'); + sinon.spy(ui, 'set'); + + return {route, router, ui}; + } + + it('aborts the Ember transition when React owns the posts list', function () { + const {route, router, ui} = setupRoute(this.owner, {flagValue: true}); + // A URL intent, so beforeModel does not rewrite the hash itself. + const transition = {abort: sinon.spy(), intent: {url: '/posts'}}; + + route.beforeModel(transition); + + expect(transition.abort.calledOnce, 'transition aborted').to.be.true; + expect(ui.set.calledWith('isFullScreen', false), 'full-screen reset').to.be.true; + expect(router.replaceWith.calledWith('react-fallback', 'posts'), 'parked on react-fallback').to.be.true; + }); + + it('restores the URL together with the history state react-router keeps', function () { + const {route} = setupRoute(this.owner, {flagValue: true}); + const replaceState = sinon.stub(window.history, 'replaceState'); + const state = {usr: null, key: 'abc123', idx: 4}; + + route._restoreUrl('#/posts?type=draft', state); + + expect(replaceState.calledOnceWith(state, '', '#/posts?type=draft'), 'state restored with URL').to.be.true; + }); + + it('keeps Ember ownership when the feature flag is not a boolean', function () { + const {route, router} = setupRoute(this.owner, {flagValue: 'true'}); + const transition = {abort: sinon.spy(), intent: {url: '/posts'}}; + + route.beforeModel(transition); + + expect(transition.abort.called, 'transition not aborted').to.be.false; + expect(router.replaceWith.called, 'no parking').to.be.false; + }); +}); diff --git a/apps/shade/src/components/patterns/filters.tsx b/apps/shade/src/components/patterns/filters.tsx index 187a0ba03f2..589cf1edc46 100644 --- a/apps/shade/src/components/patterns/filters.tsx +++ b/apps/shade/src/components/patterns/filters.tsx @@ -1325,6 +1325,15 @@ interface SelectOptionsListProps { onSelectUnselected: (option: FilterOption) => void; } +/** + * What a row's tooltip says. The detail is not rendered beside the label, so + * this is the only place it surfaces — enough to tell two same-named options + * apart when you need to, without spending row width on it always. + */ +function optionTitle(option: FilterOption): string { + return option.detail ? `${option.label} — ${option.detail}` : option.label; +} + function SelectOptionsList({ contextLabel, selectedOptions, @@ -1358,11 +1367,8 @@ function SelectOptionsList({ onSelect={() => onSelectSelected(option)} > {option.icon && option.icon} -
      - {option.label} - {option.detail && {option.detail}} -
      - + {option.label} + ))} @@ -1376,15 +1382,25 @@ function SelectOptionsList({ onSelectUnselected(option)} > {option.icon && option.icon} -
      - {option.label} - {option.detail && {option.detail}} -
      - + {/* The detail is not drawn — it lives in the + title. Beside the label it crowded out the + name, which is the thing being chosen; a + duplicate name is rare enough not to spend + half the row on. No invisible checkmark + either: the selected rows sit in their own + group above, so an empty column here only + narrowed the names. */} + {option.label}
      ))} diff --git a/apps/shade/tailwind.theme.css b/apps/shade/tailwind.theme.css index 1dac1f0b15c..ad34fd4ca95 100644 --- a/apps/shade/tailwind.theme.css +++ b/apps/shade/tailwind.theme.css @@ -217,6 +217,7 @@ --color-button-hover: var(--button-hover); --color-interactive-hover: var(--interactive-hover); --color-table-row-hover: var(--table-row-hover); + --color-table-row-selected: var(--table-row-selected); --color-control-surface: var(--control-surface); --color-control-readonly-surface: var(--control-readonly-surface); --color-control-disabled-surface: var(--control-disabled-surface); diff --git a/apps/shade/theme-variables.css b/apps/shade/theme-variables.css index b1ceabd79c7..914498d8067 100644 --- a/apps/shade/theme-variables.css +++ b/apps/shade/theme-variables.css @@ -85,6 +85,10 @@ --control-disabled-surface: var(--color-gray-100); --control-border: var(--color-gray-200); --table-row-hover: var(--color-gray-50); + /* Selected rows read as picked-out rather than merely hovered, so this is a + hue change and not another step on the grey ramp - a darker grey would + compete with --table-row-hover instead of contrasting with it. */ + --table-row-selected: var(--color-blue-50); --members-sticky-hover-bg: var(--table-row-hover); --mobile-navbar-height: 64px; } @@ -126,6 +130,10 @@ --control-disabled-surface: var(--color-gray-950); --control-border: color-mix(in oklab, var(--color-gray-900) 70%, transparent); --table-row-hover: var(--color-sidebar-bg); + /* Mixed into the page background rather than a step off the blue ramp: at + dark-mode lightness the raw palette blues are far too saturated for a + whole row. */ + --table-row-selected: color-mix(in oklab, var(--color-blue-700) 25%, var(--background)); --members-sticky-hover-bg: var(--table-row-hover); --background: oklch(0.178 0.003 271); diff --git a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts index 2d25c7daa5b..b940fce04d1 100644 --- a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts +++ b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts @@ -128,6 +128,12 @@ export class PostEditorPage extends AdminPage { readonly publishSaveButton: Locator; readonly updateFlowButton: Locator; readonly revertToDraftButton: Locator; + /** + * The back link. Located by its test attribute rather than by role: its + * accessible name carries the inlined arrow icon's title, so "Posts" is + * really "arrow-left Posts". + */ + readonly backButton: Locator; readonly settingsMenu: SettingsMenu; @@ -147,6 +153,7 @@ export class PostEditorPage extends AdminPage { this.publishSaveButton = page.locator('[data-test-button="publish-save"]').first(); this.updateFlowButton = page.locator('[data-test-button="update-flow"]').first(); this.revertToDraftButton = page.locator('[data-test-button="revert-to-draft"]'); + this.backButton = page.locator('[data-test-breadcrumb]'); this.settingsMenu = new SettingsMenu(page); } diff --git a/e2e/helpers/pages/admin/posts/posts-page.ts b/e2e/helpers/pages/admin/posts/posts-page.ts index 14a5a4dc1b2..d05cf541433 100644 --- a/e2e/helpers/pages/admin/posts/posts-page.ts +++ b/e2e/helpers/pages/admin/posts/posts-page.ts @@ -1,12 +1,19 @@ import {AdminPage} from '@/admin-pages'; import {Locator, Page} from '@playwright/test'; +import {postsFilters, postsList, postsListItem} from '@tryghost/test-data/selectors/posts'; + +/** Which implementation serves the list — decided by the `postsListReact` flag. */ +export type PostsListImplementation = 'ember' | 'react'; export class PostsPage extends AdminPage { + private readonly implementation: PostsListImplementation; + public readonly postsList: Locator; public readonly postsListItem: Locator; public readonly newPostButton: Locator; public readonly postsFilters: Locator; + public readonly addFilterButton: Locator; public readonly typeFilter: Locator; public readonly visibilityFilter: Locator; @@ -19,25 +26,36 @@ export class PostsPage extends AdminPage { public readonly pageTitle: Locator; - constructor(page: Page) { + public readonly emptyState: Locator; + + constructor(page: Page, {implementation = 'ember'}: {implementation?: PostsListImplementation} = {}) { super(page); + this.implementation = implementation; this.pageUrl = '/ghost/#/posts'; - this.postsList = page.getByTestId('posts-list'); - this.postsListItem = this.postsList.getByTestId('posts-list-item'); + this.postsList = page.getByTestId(postsList); + this.postsListItem = this.postsList.getByTestId(postsListItem); this.newPostButton = page.getByRole('link', {name: 'New post', exact: true}); - this.postsFilters = page.getByTestId('posts-filters'); + this.postsFilters = page.getByTestId(postsFilters); + // React's single entry point into the filter popover; absent in Ember, + // which has a dropdown per field. + this.addFilterButton = this.postsFilters.getByRole('button', {name: 'Filter', exact: true}); this.typeFilter = this.postsFilters.getByRole('button', {name: 'Type filter'}); this.visibilityFilter = this.postsFilters.getByRole('button', {name: 'Visibility filter'}); this.authorFilter = this.postsFilters.getByRole('button', {name: 'Author filter'}); this.tagFilter = this.postsFilters.getByRole('button', {name: 'Tag filter'}); this.orderFilter = this.postsFilters.getByRole('button', {name: 'Sort filter'}); - this.saveViewButton = page.getByRole('button', {name: /save as view/i}); - this.editViewButton = page.getByRole('button', {name: /edit current view/i}); + // Ember titles these "Save as view..." / "Edit current view..."; React + // labels them "Save view" / "Edit view", matching the members list. The + // optional middle word covers both without two page objects. + this.saveViewButton = page.getByRole('button', {name: /save (as )?view/i}); + this.editViewButton = page.getByRole('button', {name: /edit (current )?view/i}); this.pageTitle = page.getByRole('heading', {level: 2}); + + this.emptyState = page.getByText(/No posts match the current filter/i); } getPostByTitle(title: string): Locator { @@ -49,31 +67,61 @@ export class PostsPage extends AdminPage { await this.postsList.waitFor({state: 'visible'}); } + /** + * Waits for the list without asserting the URL. `waitForPageToFullyLoad` + * matches the bare `/ghost/#/posts`, so it never settles on a filtered or + * saved-view URL — which is exactly where the query params matter. + */ + async waitForList() { + await this.postsList.waitFor({state: 'visible'}); + } + async refreshData() { await this.page.reload(); } + /** + * Applies a filter. The gesture differs by implementation — Ember renders a + * dropdown per field, React one "Filter" button that asks for the field + * first — so the branch lives here, keyed by the constructor's + * `implementation`, and no test body needs to know which screen it drives. + * Never inferred from the DOM: a visibility probe races re-renders. + */ + private async applyFilter(fieldLabel: string, emberTrigger: Locator, optionName: string): Promise { + if (this.implementation === 'ember') { + await emberTrigger.click(); + } else { + await this.addFilterButton.click(); + await this.page.getByRole('option', {name: fieldLabel, exact: true}).click(); + } + + await this.page.getByRole('option', {name: optionName, exact: true}).click(); + } + async selectType(typeName: string): Promise { - await this.typeFilter.click(); - await this.page.getByRole('option', {name: typeName, exact: true}).click(); + await this.applyFilter('Post type', this.typeFilter, typeName); } async selectVisibility(visibilityName: string): Promise { - await this.visibilityFilter.click(); - await this.page.getByRole('option', {name: visibilityName, exact: true}).click(); + await this.applyFilter('Access', this.visibilityFilter, visibilityName); } async selectAuthor(authorName: string): Promise { - await this.authorFilter.click(); - await this.page.getByRole('option', {name: authorName, exact: true}).click(); + await this.applyFilter('Author', this.authorFilter, authorName); } async selectTag(tagName: string): Promise { - await this.tagFilter.click(); - await this.page.getByRole('option', {name: tagName, exact: true}).click(); + await this.applyFilter('Tag', this.tagFilter, tagName); } async selectOrder(orderName: string): Promise { + if (this.implementation !== 'ember') { + // React's sort is a menu next to the filter button, with its own + // labels — drive it via a dedicated helper when a React-lane test + // first needs it. + throw new Error('selectOrder drives the Ember sort dropdown; the React list sorts via its sort menu'); + } + await this.orderFilter.click(); await this.page.getByRole('option', {name: orderName, exact: true}).click(); } @@ -88,6 +136,99 @@ export class PostsPage extends AdminPage { await this.editViewButton.click(); } + /** + * How many rows are selected. + * + * Read as an attribute rather than located by one: the repo forbids CSS + * selectors in e2e, and `data-selected` is the only marker either + * implementation exposes — there is no role or label for "selected row". + */ + async selectedPostCount(): Promise { + const rows = await this.postsListItem.all(); + const flags = await Promise.all(rows.map(row => row.evaluate((el) => { + // Presence, not a `"true"` value. React writes + // `data-selected="true"`; Ember writes the attribute with an *empty* + // value, because Glimmer renders `data-selected={{true}}` as a bare + // attribute and omits it entirely when false. Matching on `"true"` + // made Ember's selection invisible to this helper, which is what + // kept these tests out of the shared suite. + const marker = el.closest('[data-selected]'); + + return marker !== null && marker.getAttribute('data-selected') !== 'false'; + }))); + + return flags.filter(Boolean).length; + } + + /** + * Modifier-click, which is how both implementations select without + * checkboxes. + * + * Dispatched rather than clicked for real: the whole row is a link in both + * screens, and a genuine cmd-click on a link opens a new browser tab — + * which tears the test context down mid-run. Both implementations listen + * for `mousedown` (in the capture phase, precisely so they can beat the + * link), so this drives the same code path the user does. + */ + async selectPost(title: string): Promise { + await this.getPostByTitle(title).evaluate((row) => { + // Ember listens on the wrapper that carries `data-selected`; React + // listens on the row itself. Aim at whichever is present so one + // helper drives both. + // Ember's handler sits on the wrapper carrying `role="menuitem"`; + // React's on the row. Either way a capture-phase listener sees an + // event dispatched at the row, so aim there and let it travel. + const target = row; + + target.dispatchEvent(new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + metaKey: true + })); + }); + } + + async openContextMenuFor(title: string): Promise { + await this.getPostByTitle(title).click({button: 'right'}); + // Waits on "Add a tag" rather than a container: the two menus share no + // container, and it is the one item both offer unconditionally. + await this.contextMenuItem('Add a tag').waitFor({state: 'visible'}); + } + + /** + * An item in the right-click menu. + * + * The two implementations build the menu from different elements: React + * uses Radix, so items carry `role="menuitem"`, while Ember renders a plain + * list of `