diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef22601d4fc..ae3e4c7c5b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -770,7 +770,7 @@ jobs: if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' services: mysql: - image: ${{ matrix.env.DB == 'mysql8' && 'mysql:8.0' || '' }} + image: mysql:8.0 env: MYSQL_DATABASE: ghost_testing MYSQL_ROOT_PASSWORD: root @@ -794,25 +794,11 @@ jobs: --health-interval=2s --health-timeout=5s --health-retries=60 - strategy: - matrix: - node: ${{ fromJSON(needs.job_setup.outputs.node_test_matrix) }} - env: - - DB: mysql8 - NODE_ENV: testing-mysql - include: - - node: ${{ needs.job_setup.outputs.node_version }} - env: - DB: better-sqlite3 - NODE_ENV: testing env: - DB: ${{ matrix.env.DB }} - NODE_ENV: ${{ matrix.env.NODE_ENV }} - # The `test:ci:*` targets wrap the suites in c8. Only the sqlite leg is - # instrumented (mysql covers the same code), and only on the canonical - # repo — see job_setup's coverage_enabled output. - COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled == 'true' && matrix.env.DB == 'better-sqlite3' }} - name: Acceptance tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }}) + DB: mysql8 + NODE_ENV: testing-mysql + COVERAGE_ENABLED: ${{ needs.job_setup.outputs.coverage_enabled }} + name: Acceptance tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 @@ -820,33 +806,18 @@ jobs: env: FORCE_COLOR: 0 with: - node-version: ${{ matrix.node }} + node-version: ${{ needs.job_setup.outputs.node_version }} cache: pnpm - name: Install dependencies - # better-sqlite3 is an optionalDependency. Without --force, pnpm may skip - # installing/linking it when restoring from a cached store. --force - # ensures all optional deps are installed regardless. The mysql leg - # doesn't need better-sqlite3, so it skips --force and gets a fast cache - # restore (matches the legacy-tests job below). - run: | - if [ "${{ matrix.env.DB }}" = "better-sqlite3" ]; then - pnpm install --frozen-lockfile --force - else - pnpm install --frozen-lockfile - fi + run: pnpm install --frozen-lockfile - name: Set timezone (non-UTC) uses: szenius/set-timezone@1f9716b0f7120e344f0c62bb7b1ee98819aefd42 # v2.0 with: timezoneLinux: 'America/New_York' - - name: Set env vars (SQLite) - if: contains(matrix.env.DB, 'sqlite') - run: echo "database__connection__filename=/dev/shm/ghost-test.db" >> "$GITHUB_ENV" - - name: Set env vars (MySQL) - if: contains(matrix.env.DB, 'mysql') run: | { echo "database__connection__host=127.0.0.1" @@ -899,7 +870,7 @@ jobs: fi - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - if: env.COVERAGE_ENABLED == 'true' && matrix.node == env.NODE_VERSION + if: env.COVERAGE_ENABLED == 'true' with: name: e2e-coverage path: | @@ -919,7 +890,7 @@ jobs: if: needs.job_setup.outputs.is_tag == 'true' || needs.job_setup.outputs.changed_core == 'true' services: mysql: - image: ${{ matrix.env.DB == 'mysql8' && 'mysql:8.0' || '' }} + image: mysql:8.0 env: MYSQL_DATABASE: ghost_testing MYSQL_ROOT_PASSWORD: root @@ -931,21 +902,10 @@ jobs: --health-interval=10s --health-timeout=5s --health-retries=12 - strategy: - matrix: - include: - - node: ${{ needs.job_setup.outputs.node_version }} - env: - DB: mysql8 - NODE_ENV: testing-mysql - - node: ${{ needs.job_setup.outputs.node_version }} - env: - DB: better-sqlite3 - NODE_ENV: testing env: - DB: ${{ matrix.env.DB }} - NODE_ENV: ${{ matrix.env.NODE_ENV }} - name: Legacy tests (Node ${{ matrix.node }}, ${{ matrix.env.DB }}) + DB: mysql8 + NODE_ENV: testing-mysql + name: Legacy tests (Node ${{ needs.job_setup.outputs.node_version }}, mysql8) steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -955,26 +915,13 @@ jobs: env: FORCE_COLOR: 0 with: - node-version: ${{ matrix.node }} + node-version: ${{ needs.job_setup.outputs.node_version }} cache: pnpm - name: Install dependencies - # better-sqlite3 is an optionalDependency. Without --force, pnpm may skip - # installing/linking it when restoring from a cached store. --force - # ensures all optional deps are installed regardless. - run: | - if [ "${{ matrix.env.DB }}" = "better-sqlite3" ]; then - pnpm install --frozen-lockfile --force - else - pnpm install --frozen-lockfile - fi - - - name: Set env vars (SQLite) - if: contains(matrix.env.DB, 'better-sqlite') - run: echo "database__connection__filename=/dev/shm/ghost-test.db" >> $GITHUB_ENV + run: pnpm install --frozen-lockfile - name: Set env vars (MySQL) - if: contains(matrix.env.DB, 'mysql') run: | echo "database__connection__host=127.0.0.1" >> $GITHUB_ENV echo "database__connection__port=${{ job.services.mysql.ports['3306'] }}" >> $GITHUB_ENV diff --git a/apps/admin-x-framework/src/api/automations.ts b/apps/admin-x-framework/src/api/automations.ts index 4ef30576635..3bfee4ef633 100644 --- a/apps/admin-x-framework/src/api/automations.ts +++ b/apps/admin-x-framework/src/api/automations.ts @@ -19,7 +19,7 @@ export type AutomationStats = { }; export type AutomationBrowseItem = Automation & { - stats: AutomationStats; + stats?: AutomationStats; }; export type AutomationWaitAction = { diff --git a/apps/admin-x-framework/src/api/pages.ts b/apps/admin-x-framework/src/api/pages.ts index 294c8174569..33ce11511dc 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 } from '../utils/api/hooks'; +import { Meta, createInfiniteQuery, createMutation, createQuery } 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; @@ -49,3 +62,31 @@ 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 fef1609bc2d..e17bc6442a1 100644 --- a/apps/admin-x-framework/src/api/posts.ts +++ b/apps/admin-x-framework/src/api/posts.ts @@ -11,6 +11,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 = { @@ -36,7 +73,7 @@ export type Post = { email_recipient_filter?: string; send_email_when_published?: boolean; email_stats?: object; -}; +} & PostListFields; export interface PostsResponseType { meta?: Meta; @@ -86,6 +123,51 @@ 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/`, +}); + export interface ImportContentCSVPayload { file: File; mapping: Record; diff --git a/apps/admin-x-framework/src/api/stats.ts b/apps/admin-x-framework/src/api/stats.ts index 4f428ed93f5..b9db9c07907 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 @@ -311,3 +313,80 @@ export const useSubscriberCount = createQuery; + +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 69cb7c2560e..e4af8a53a39 100644 --- a/apps/admin-x-framework/src/api/users.ts +++ b/apps/admin-x-framework/src/api/users.ts @@ -85,10 +85,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 98bb64a23ce..1c726c659e7 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -165,6 +165,7 @@ { "projects": [ "@tryghost/admin-x-framework", + "@tryghost/checkout", "@tryghost/shade" ], "target": "build" 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 ba4b646bf57..4b7e11caeff 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 db350bbcfde..6ed62f1bfb1 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, diff --git a/apps/admin/src/automations/automations.acceptance.test.tsx b/apps/admin/src/automations/automations.acceptance.test.tsx index d49d53aaa4c..80cd034e474 100644 --- a/apps/admin/src/automations/automations.acceptance.test.tsx +++ b/apps/admin/src/automations/automations.acceptance.test.tsx @@ -52,4 +52,21 @@ describe('Automations list', () => { // Stripe is disconnected in the default boot, which hides the paid welcome flow. await expect(automationsScreen.rows()).toHaveCount(1); }); + + it('hides run analytics when the backend does not return stats', async () => { + fakeAutomations([ + automation({ + name: 'Free member welcome flow', + slug: 'member-welcome-email-free', + status: 'active', + stats: undefined, + }), + ]); + await renderAdminApp('/automations', AUTOMATIONS_ENABLED); + + await expect.element(automationsScreen.link('Free member welcome flow')).toBeVisible(); + await expect.element(automationsScreen.columnHeader('Last entry')).not.toBeInTheDocument(); + await expect.element(automationsScreen.columnHeader('Total entries')).not.toBeInTheDocument(); + await expect.element(automationsScreen.columnHeader('In progress')).not.toBeInTheDocument(); + }); }); diff --git a/apps/admin/src/automations/components/automations-list.test.tsx b/apps/admin/src/automations/components/automations-list.test.tsx index d183f0d8100..96ca8c8b95c 100644 --- a/apps/admin/src/automations/components/automations-list.test.tsx +++ b/apps/admin/src/automations/components/automations-list.test.tsx @@ -83,6 +83,19 @@ describe('AutomationsList', () => { expect(screen.getByText('Never')).toBeInTheDocument(); }); + it('hides run analytics when stats are unavailable', () => { + const automationsWithoutStats = automations.map( + ({ stats: _stats, ...automation }) => automation, + ); + + renderWithRouter(); + + expect(screen.queryByRole('columnheader', { name: 'Last entry' })).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'Total entries' })).not.toBeInTheDocument(); + expect(screen.queryByRole('columnheader', { name: 'In progress' })).not.toBeInTheDocument(); + expect(screen.queryByText('1,432')).not.toBeInTheDocument(); + }); + it('links each row to the automation sequence by id', () => { renderWithRouter(); diff --git a/apps/admin/src/automations/components/automations-list.tsx b/apps/admin/src/automations/components/automations-list.tsx index a4ea6862051..772ee00a037 100644 --- a/apps/admin/src/automations/components/automations-list.tsx +++ b/apps/admin/src/automations/components/automations-list.tsx @@ -19,18 +19,25 @@ const AUTOMATION_DESCRIPTIONS: Record = { 'member-welcome-email-paid': 'Welcome new paid members after they start their subscription.', }; +// Widths are scoped to `lg` because below that the stats lay out on the row's +// grid rather than in table cells, where a fixed width would fight the columns. const AUTOMATION_STAT_COLUMNS = [ - { key: 'lastEntry', label: 'Last entry', widthClassName: 'w-40', skeletonWidthClassName: 'w-20' }, + { + key: 'lastEntry', + label: 'Last entry', + widthClassName: 'lg:w-40', + skeletonWidthClassName: 'w-20', + }, { key: 'totalEntries', label: 'Total entries', - widthClassName: 'w-32', + widthClassName: 'lg:w-32', skeletonWidthClassName: 'w-10', }, { key: 'inProgressEntries', label: 'In progress', - widthClassName: 'w-32', + widthClassName: 'lg:w-32', skeletonWidthClassName: 'w-10', }, ] as const; @@ -65,21 +72,24 @@ const AutomationsListSkeleton: React.FC = () => { @@ -97,37 +107,41 @@ const AutomationsList: React.FC = ({ return ; } + const showRunAnalytics = automations.every((automation) => automation.stats !== undefined); + return ( - - - - Name - - {AUTOMATION_STAT_COLUMNS.map((column) => ( - - {column.label} + {showRunAnalytics && ( + + + + Name - ))} - - Status - - - + {AUTOMATION_STAT_COLUMNS.map((column) => ( + + {column.label} + + ))} + + Status + + + + )} {automations.map((automation) => { const description = AUTOMATION_DESCRIPTIONS[automation.slug]; - const lastEntry = automation.stats.last_run_created_at; - const totalEntries = automation.stats.total_run_count; - const inProgressEntries = automation.stats.in_progress_run_count; + const lastEntry = automation.stats?.last_run_created_at; + const totalEntries = automation.stats?.total_run_count ?? 0; + const inProgressEntries = automation.stats?.in_progress_run_count ?? 0; const statCells = { lastEntry: { content: lastEntry ? ( @@ -150,12 +164,12 @@ const AutomationsList: React.FC = ({ return ( = ({ {description && {description}} - {AUTOMATION_STAT_COLUMNS.map((column) => { - const cell = statCells[column.key]; + {showRunAnalytics && + AUTOMATION_STAT_COLUMNS.map((column) => { + const cell = statCells[column.key]; - return ( - - {cell.content} - - ); - })} - + return ( + + {cell.content} + {/* The column headers are hidden below `lg`, so each stat + carries its own label there. */} + + {column.label} + + + ); + })} + diff --git a/apps/admin/src/ember-bridge/ember-bridge.test.tsx b/apps/admin/src/ember-bridge/ember-bridge.test.tsx index 6a10f58e39f..c302973f4fe 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.test.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.test.tsx @@ -171,6 +171,85 @@ 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.setQueryDefaults(['PagesResponseType', '/pages'], { gcTime: Infinity }); + queryClient.setQueryData(['TagsResponseType', '/tags'], { tags: [] }); + queryClient.setQueryData(['PagesResponseType', '/pages'], { pages: [] }); + + 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 queries = queryClient.getQueryCache().getAll(); + const tagQueries = queries.filter((q) => q.queryKey[0] === 'TagsResponseType'); + const pageQueries = queries.filter((q) => q.queryKey[0] === 'PagesResponseType'); + + expect(tagQueries.length).toBeGreaterThan(0); + expect(tagQueries.every((q) => q.state.isInvalidated)).toBe(true); + expect(pageQueries.length).toBeGreaterThan(0); + expect(pageQueries.every((q) => q.state.isInvalidated)).toBe(true); + }); + }); + queryTest( 'invalidates the sidebar member count query for Ember member changes', async ({ queryClient, wrapper }) => { diff --git a/apps/admin/src/ember-bridge/ember-bridge.tsx b/apps/admin/src/ember-bridge/ember-bridge.tsx index d94d8a7d02b..d5d646971b5 100644 --- a/apps/admin/src/ember-bridge/ember-bridge.tsx +++ b/apps/admin/src/ember-bridge/ember-bridge.tsx @@ -98,6 +98,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', tag: 'TagsResponseType', label: 'LabelsResponseType', @@ -177,11 +181,22 @@ 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 aeec38c099a..e2ce9ab4d00 100644 --- a/apps/admin/src/flag-gated-route.test.tsx +++ b/apps/admin/src/flag-gated-route.test.tsx @@ -167,4 +167,70 @@ 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 6a68c24342d..04debbc7a81 100644 --- a/apps/admin/src/flag-gated-route.tsx +++ b/apps/admin/src/flag-gated-route.tsx @@ -1,6 +1,6 @@ import { EmberFallback } from './ember-bridge'; import { useFlagGatedRouteOwner } from './use-flag-gated-route-owner'; -import { Suspense, type ComponentType, type LazyExoticComponent } from 'react'; +import { Suspense, type ComponentType, type LazyExoticComponent, type ReactNode } from 'react'; /** * Chooses which implementation serves a route while a screen migrates from @@ -23,13 +23,20 @@ import { Suspense, type ComponentType, type LazyExoticComponent } from 'react'; * 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, + fallback = , }: { flag: string; component: LazyExoticComponent; + fallback?: ReactNode; }) { const owner = useFlagGatedRouteOwner(flag); @@ -37,7 +44,7 @@ export function FlagGatedRoute({ return null; } if (owner === 'ember') { - return ; + return fallback; } return ( diff --git a/apps/admin/src/layout/app-sidebar/nav-content.tsx b/apps/admin/src/layout/app-sidebar/nav-content.tsx index 553f69f25f1..e4446672206 100644 --- a/apps/admin/src/layout/app-sidebar/nav-content.tsx +++ b/apps/admin/src/layout/app-sidebar/nav-content.tsx @@ -17,10 +17,10 @@ import { } 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'; @@ -80,7 +80,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(); @@ -93,23 +94,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 ( @@ -124,33 +118,12 @@ 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 02b4519f49d..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 21c9075d614..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..6194377857f --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/post-sidebar-views.test.ts @@ -0,0 +1,120 @@ +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..6afc3f6be16 --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/post-sidebar-views.ts @@ -0,0 +1,82 @@ +import { POST_VIEW_PARAMS, type PostResource } from '@/posts/api'; + +/** + * 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 e76f2b6f69e..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..0a298c27b77 --- /dev/null +++ b/apps/admin/src/layout/app-sidebar/use-post-navigation.ts @@ -0,0 +1,133 @@ +import { buildPostViewUrl, getDefaultPostViews, isPostViewActive } from './post-sidebar-views'; +import { getStickyPostFilterUrl, type PostResource } from '@/posts/api'; +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'; + +/** + * 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..8e1bd8a5cba --- /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/members/api.ts b/apps/admin/src/members/api.ts index 4c51991e835..5f5670fc5ba 100644 --- a/apps/admin/src/members/api.ts +++ b/apps/admin/src/members/api.ts @@ -6,4 +6,10 @@ export { membersRouteChildren } from './routes'; export { buildMembersUrl } from './member-route'; export { formatMemberName, getMemberInitials, memberAvatarProps } from './member-format'; -export { type SharedView, parseAllSharedViewsJSON } from './shared-views'; +export { + type SharedView, + findMatchingSharedViewIndexes, + hasSharedViewNameConflict, + normalizeSharedViewName, + parseAllSharedViewsJSON, +} from './shared-views'; diff --git a/apps/admin/src/nql.d.ts b/apps/admin/src/nql.d.ts new file mode 100644 index 00000000000..ba93f8736a0 --- /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..bf64949b013 --- /dev/null +++ b/apps/admin/src/posts-list-gate.test.tsx @@ -0,0 +1,132 @@ +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..3bed9c66d6d --- /dev/null +++ b/apps/admin/src/posts-list-gate.tsx @@ -0,0 +1,37 @@ +import { EmberListWithGiftLinks } from './gift-link-modal-host'; +import { FlagGatedRoute } from './flag-gated-route'; +import { lazyPagesListRoute, lazyPostsListRoute } from './posts/api'; +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(lazyPostsListRoute); +const PagesListReact = lazy(lazyPagesListRoute); + +export function PostsListGate() { + return ( + } + flag="postsListReact" + /> + ); +} + +export function PagesListGate() { + return ( + } + flag="postsListReact" + /> + ); +} 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 729d2f1b231..d844a5b5389 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/api.ts b/apps/admin/src/posts/api.ts index 2654323fc6f..5ba64f42af7 100644 --- a/apps/admin/src/posts/api.ts +++ b/apps/admin/src/posts/api.ts @@ -4,6 +4,14 @@ * in this domain is internal. */ export { lazyPostAnalyticsRoot, postAnalyticsRouteChildren } from './analytics/routes'; +export { POST_VIEW_PARAMS } from './list/post-view-params'; +export { getStickyPostFilterUrl } from './list/posts-sticky-filters'; +export type { PostResource } from './list/post-resource'; + +// Lazy route entries keep the posts and pages list chunks out of the shell +// while still exposing them through the domain boundary. +export const lazyPostsListRoute = () => import('./list/posts-route'); +export const lazyPagesListRoute = () => import('./list/pages-route'); // Lazy entry, not a component re-export: the shell's host loads the modal on // demand, so a static re-export would pull the chunk into the shell bundle. 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..218805c5328 --- /dev/null +++ b/apps/admin/src/posts/list/components/manage-post-view-popover.tsx @@ -0,0 +1,196 @@ +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/api'; + +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..326050cd74e --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/add-tag-modal.tsx @@ -0,0 +1,120 @@ +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'; +import { useDebounce } from 'use-debounce'; +import { escapeNqlString } from '@tryghost/nql-string'; + +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 term = search.trim(); + const [debouncedTerm] = useDebounce(term, 250); + + const { data: tagsData, isFetching } = useBrowseTags({ + searchParams: { + limit: '100', + order: 'name asc', + ...(debouncedTerm ? { filter: `tags.name:~${escapeNqlString(debouncedTerm)}` } : {}), + }, + 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..1d15f863f20 --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/change-access-modal.tsx @@ -0,0 +1,159 @@ +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.test.tsx b/apps/admin/src/posts/list/components/modals/confirm-bulk-action-modal.test.tsx new file mode 100644 index 00000000000..7db0758cea3 --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/confirm-bulk-action-modal.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { ConfirmBulkActionModal } from './confirm-bulk-action-modal'; + +function renderModal(isRunning: boolean, onCancel = vi.fn()) { + render( + , + ); + + return onCancel; +} + +describe('ConfirmBulkActionModal', () => { + it('blocks Escape dismissal while the action is running', () => { + const onCancel = renderModal(true); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onCancel).not.toHaveBeenCalled(); + expect(screen.getByRole('alertdialog')).toBeInTheDocument(); + }); + + it('allows Escape dismissal when the action is idle', () => { + const onCancel = renderModal(false); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(onCancel).toHaveBeenCalledOnce(); + }); +}); 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..ab6ef709d17 --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/confirm-bulk-action-modal.tsx @@ -0,0 +1,87 @@ +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(); + } + }} + > + { + if (isRunning) { + event.preventDefault(); + } + }} + > + + {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..3901a1399b9 --- /dev/null +++ b/apps/admin/src/posts/list/components/modals/tag-picker.tsx @@ -0,0 +1,296 @@ +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[]; + /** False until the server has answered for the current search term. */ + allowCreation: boolean; + 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({ + allowCreation, + 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 = + allowCreation && + term.length > 0 && + !tags.some((tag) => tag.name.toLowerCase() === term.toLowerCase()) && + !selected.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..f08e4fe71b3 --- /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..116abc98d69 --- /dev/null +++ b/apps/admin/src/posts/list/components/post-celebration-modal.tsx @@ -0,0 +1,57 @@ +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..75e6c65e8de --- /dev/null +++ b/apps/admin/src/posts/list/components/post-list-row.tsx @@ -0,0 +1,324 @@ +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..aa502bdf9b5 --- /dev/null +++ b/apps/admin/src/posts/list/components/post-metric-tooltip.tsx @@ -0,0 +1,71 @@ +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..96d3462daa3 --- /dev/null +++ b/apps/admin/src/posts/list/components/post-metrics-cells.tsx @@ -0,0 +1,198 @@ +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); + const content = ( + + + {/* 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} + + + ); + + if (resource === 'pages') { + return ( + + {content} + + ); + } + + return ( + + {content} + + ); + })} + + + ); + })} + + ); +} 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..a1ff04aa1b7 --- /dev/null +++ b/apps/admin/src/posts/list/components/posts-context-menu.tsx @@ -0,0 +1,117 @@ +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..e45cfe9f599 --- /dev/null +++ b/apps/admin/src/posts/list/components/posts-empty-state.tsx @@ -0,0 +1,49 @@ +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..9e8940d0bb8 --- /dev/null +++ b/apps/admin/src/posts/list/components/posts-filters.tsx @@ -0,0 +1,116 @@ +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..e829d9adf3d --- /dev/null +++ b/apps/admin/src/posts/list/components/posts-sort-menu.tsx @@ -0,0 +1,63 @@ +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..f4f5a1dac45 --- /dev/null +++ b/apps/admin/src/posts/list/compose-post-buckets.test.ts @@ -0,0 +1,221 @@ +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..e1538eed804 --- /dev/null +++ b/apps/admin/src/posts/list/compose-post-buckets.ts @@ -0,0 +1,101 @@ +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..96ff2c63aba --- /dev/null +++ b/apps/admin/src/posts/list/compute-shift-range.test.ts @@ -0,0 +1,92 @@ +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..7cf405eb011 --- /dev/null +++ b/apps/admin/src/posts/list/compute-shift-range.ts @@ -0,0 +1,47 @@ +/** + * 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..30dc69ede10 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-actions.ts @@ -0,0 +1,176 @@ +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..377375d0a6d --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-analytics-counts.ts @@ -0,0 +1,40 @@ +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..edc1229b429 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-bulk-actions.test.tsx @@ -0,0 +1,167 @@ +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..aca926a3af4 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-bulk-actions.ts @@ -0,0 +1,460 @@ +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< + Record< + PostContextMenuKey, + { + action: PostBulkAction['type']; + message?: PostActionMessageKey; + } + > +> = { + 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..1cbdc81a9ef --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-publish-celebration.ts @@ -0,0 +1,94 @@ +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..146770ea0ad --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-selection.ts @@ -0,0 +1,288 @@ +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.toLowerCase() === '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..36832a1b4f5 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-post-views.ts @@ -0,0 +1,108 @@ +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/api'; +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 | number | 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..76b7677bfe5 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-posts-filter-state.test.tsx @@ -0,0 +1,196 @@ +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); + }); + + it('does not treat a whitespace-only URL value as a filter', () => { + const { result } = renderState('/posts?tag=%20%20'); + + expect(result.current.filters).toEqual([]); + 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..b023d844ccc --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-posts-filter-state.ts @@ -0,0 +1,125 @@ +import { + POST_FILTER_PARAMS, + parsePostFilters, + serializePostFilters, +} from '@/posts/list/post-filter-query'; +import { useCallback, useMemo } from 'react'; +import { useSearchParams } from '@tryghost/admin-x-framework'; +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 = filters.length > 0; + + 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..97f47742417 --- /dev/null +++ b/apps/admin/src/posts/list/hooks/use-posts-list.ts @@ -0,0 +1,106 @@ +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..e0d23fe10c1 --- /dev/null +++ b/apps/admin/src/posts/list/humanize-recipient-filter.test.ts @@ -0,0 +1,45 @@ +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..989595315da --- /dev/null +++ b/apps/admin/src/posts/list/humanize-recipient-filter.ts @@ -0,0 +1,61 @@ +/** + * 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..731890f24d1 --- /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..795c713293f --- /dev/null +++ b/apps/admin/src/posts/list/post-action-messages.test.ts @@ -0,0 +1,81 @@ +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..080871e14be --- /dev/null +++ b/apps/admin/src/posts/list/post-action-messages.ts @@ -0,0 +1,80 @@ +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..d2a0dd3d6b3 --- /dev/null +++ b/apps/admin/src/posts/list/post-bulk-modal-copy.test.ts @@ -0,0 +1,118 @@ +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('falls back to the resource name when a single post has no title', () => { + expect(getBulkConfirmCopy('delete', { count: 1, resource: 'posts' }).body).toBe( + 'You’re about to delete this post. This is permanent! We warned you, k?', + ); + }); + + 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..80d97da797d --- /dev/null +++ b/apps/admin/src/posts/list/post-bulk-modal-copy.ts @@ -0,0 +1,72 @@ +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 ? `"${title}"` : `this ${noun}`) : `${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..3e0ef68ad7b --- /dev/null +++ b/apps/admin/src/posts/list/post-celebration-copy.test.ts @@ -0,0 +1,68 @@ +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..af36f7c1602 --- /dev/null +++ b/apps/admin/src/posts/list/post-celebration-copy.ts @@ -0,0 +1,49 @@ +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..6f552afb187 --- /dev/null +++ b/apps/admin/src/posts/list/post-context-menu-items.test.ts @@ -0,0 +1,233 @@ +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..24365f2a421 --- /dev/null +++ b/apps/admin/src/posts/list/post-context-menu-items.ts @@ -0,0 +1,147 @@ +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..8c33638ea85 --- /dev/null +++ b/apps/admin/src/posts/list/post-filter-fields.test.ts @@ -0,0 +1,82 @@ +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..e1dcd6c0344 --- /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..8810b2338b6 --- /dev/null +++ b/apps/admin/src/posts/list/post-filter-query.test.ts @@ -0,0 +1,138 @@ +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([]); + }); + + it('ignores whitespace-only strings', () => { + expect(parsePostFilters({ tag: ' ' })).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..46db0a0f596 --- /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.trim() === '') { + 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..816ea34d7be --- /dev/null +++ b/apps/admin/src/posts/list/post-metric-icons.ts @@ -0,0 +1,26 @@ +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..41577bd3185 --- /dev/null +++ b/apps/admin/src/posts/list/post-metric-tooltips.test.ts @@ -0,0 +1,79 @@ +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..2ad6bf7e335 --- /dev/null +++ b/apps/admin/src/posts/list/post-metric-tooltips.ts @@ -0,0 +1,86 @@ +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..c667492fe11 --- /dev/null +++ b/apps/admin/src/posts/list/post-metrics.test.ts @@ -0,0 +1,298 @@ +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..db7122fdc2a --- /dev/null +++ b/apps/admin/src/posts/list/post-metrics.ts @@ -0,0 +1,204 @@ +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..5dbf1e28364 --- /dev/null +++ b/apps/admin/src/posts/list/post-preview-url.test.ts @@ -0,0 +1,31 @@ +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..2c52a8dac91 --- /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..287e0721232 --- /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..d92e4c26bea --- /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..583d56dc1af --- /dev/null +++ b/apps/admin/src/posts/list/post-query-params.test.ts @@ -0,0 +1,185 @@ +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..77312e32c19 --- /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..2fee41b6083 --- /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..49ee41d9f7d --- /dev/null +++ b/apps/admin/src/posts/list/post-row-copy.test.ts @@ -0,0 +1,313 @@ +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.each([ + ['published', 'Published'], + ['sent', 'Sent'], + ] as const)('ignores stale failed-email data on a %s page', (status, label) => { + expect( + getPostStatusLabel( + post({ + status, + email: { status: 'failed', email_count: 10, opened_count: 0 }, + }), + 'pages', + ), + ).toBe(label); + }); + + 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..e37f20453f9 --- /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 (didPostEmailFail(post, resource)) { + return 'Published but failed to send newsletter'; + } + return wasEmailed(post, resource) ? 'Published and sent' : 'Published'; + case 'sent': + return didPostEmailFail(post, resource) ? '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..605cb750b82 --- /dev/null +++ b/apps/admin/src/posts/list/post-selection-filter.test.ts @@ -0,0 +1,85 @@ +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..0549f8cc120 --- /dev/null +++ b/apps/admin/src/posts/list/post-selection-filter.ts @@ -0,0 +1,49 @@ +/** + * 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..9ca14cfbd88 --- /dev/null +++ b/apps/admin/src/posts/list/post-selection-state.test.ts @@ -0,0 +1,325 @@ +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..d31ce86331c --- /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..6ceca81e81a --- /dev/null +++ b/apps/admin/src/posts/list/post-time.test.ts @@ -0,0 +1,145 @@ +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'); + }); + + it('uses the exact hour distance at the relative-time boundary', () => { + expect(formatPostTime('2026-08-04T13:59:00.000Z', { timezone: TZ, now: EARLY })).toBe( + '13:59 (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..55f645255dc --- /dev/null +++ b/apps/admin/src/posts/list/post-time.ts @@ -0,0 +1,82 @@ +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', true)) <= 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..3bc6533b5b5 --- /dev/null +++ b/apps/admin/src/posts/list/post-views-storage.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; +import { applyPostViewDelete, applyPostViewSave } from './post-views-storage'; +import type { SharedView } from '@/members/api'; + +/** + * 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..2af84473e64 --- /dev/null +++ b/apps/admin/src/posts/list/post-views-storage.ts @@ -0,0 +1,104 @@ +import { buildPostView, type PostViewColor } from '@/posts/list/post-views'; +import { normalizeSharedViewName } from '@/members/api'; +import type { PostListParams } from '@/posts/list/post-query-params'; +import type { SharedView } from '@/members/api'; + +/** + * 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..1e1a4b96315 --- /dev/null +++ b/apps/admin/src/posts/list/post-views.test.ts @@ -0,0 +1,221 @@ +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/api'; + +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..24421ccdb00 --- /dev/null +++ b/apps/admin/src/posts/list/post-views.ts @@ -0,0 +1,148 @@ +import { POST_VIEW_PARAMS } from '@/posts/list/post-view-params'; +import { + type SharedView, + findMatchingSharedViewIndexes, + hasSharedViewNameConflict, +} from '@/members/api'; +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/api`; 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..8f337e5f5b9 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-bulk-actions.acceptance.test.tsx @@ -0,0 +1,405 @@ +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' }] } }, + }); + }); + + it('searches tags on the server before offering creation', async () => { + const existing = tag({ id: 't1', name: 'C++', slug: 'c-plus-plus' }); + fakePosts([post({ title: 'Target', status: 'draft' })]); + const tags = fakeTags(({ filter }) => (filter?.includes('tags.name:~') ? [existing] : [])); + 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('C++'); + + await expect.poll(() => tags.lastRequest?.filter).toContain("tags.name:~'C++'"); + await expect.element(postsListScreen.tagOption('C++')).toBeVisible(); + await expect(postsListScreen.tagOption(/Create/)).toHaveCount(0); + }); + + /** + * 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..25829cbb547 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-celebration.acceptance.test.tsx @@ -0,0 +1,113 @@ +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..22e8b5f6ee6 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-context-menu.acceptance.test.tsx @@ -0,0 +1,344 @@ +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..881bdbb11b1 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-data.acceptance.test.tsx @@ -0,0 +1,203 @@ +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..b0e504e06d5 --- /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..32e1af825c5 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx @@ -0,0 +1,350 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + currentRoute, + currentUserResponse, + fakeAdminEndpoint, + fakePages, + fakePosts, + fakePostsListScreen, + post, + renderAdminApp, + settingsResponse, + 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}`); + }); + + it('renders page metrics without linking to post analytics', async () => { + fakePages([post({ title: 'A tracked page', status: 'published' })]); + await renderAdminApp('/pages?type=published', { + ...FLAG_ON, + boot: { + browseSettings: { + response: settingsResponse({ settings: { web_analytics_enabled: true } }), + }, + }, + }); + + const visitors = postsListScreen.metricCell('Visitors'); + await expect.element(visitors).toBeVisible(); + await expect.element(visitors).not.toHaveAttribute('href'); + }); +}); + +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..17b62bf06ff --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-screen.tsx @@ -0,0 +1,477 @@ +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..2bfb18fdb5a --- /dev/null +++ b/apps/admin/src/posts/list/posts-list-selection.acceptance.test.tsx @@ -0,0 +1,322 @@ +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'); + }); + + it('selects everything on Cmd+A when CapsLock reports an uppercase key', async () => { + await renderList(); + + window.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, cancelable: true, key: 'A', metaKey: true }), + ); + + await expect.poll(() => postsListScreen.selectedTitles().length).toBe(4); + }); + + // 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..708d4e0561f --- /dev/null +++ b/apps/admin/src/posts/list/posts-list.acceptance.test.tsx @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + fakePages, + fakePosts, + fakePostsListScreen, + renderAdminApp, + type ResourceCapture, +} 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', () => { + let pagesApi: ResourceCapture; + let postsApi: ResourceCapture; + + // 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(); + postsApi = fakePosts([]); + pagesApi = 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); + await expect.poll(() => pagesApi.requests.length).toBeGreaterThan(0); + expect(postsApi.requests).toHaveLength(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..301cfc72b27 --- /dev/null +++ b/apps/admin/src/posts/list/posts-list.screen.ts @@ -0,0 +1,120 @@ +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).getByLabelText(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..8e9e5524471 --- /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..a402a473a21 --- /dev/null +++ b/apps/admin/src/posts/list/posts-sticky-filters.test.ts @@ -0,0 +1,80 @@ +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..beb30145c47 --- /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..d63cc77aaaa --- /dev/null +++ b/apps/admin/src/posts/list/prune-non-matching-posts.test.ts @@ -0,0 +1,146 @@ +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..f94cea3069e --- /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..768296ea6a1 --- /dev/null +++ b/apps/admin/src/posts/list/use-post-filter-fields.test.ts @@ -0,0 +1,89 @@ +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..4d086b7c45f --- /dev/null +++ b/apps/admin/src/posts/list/use-post-filter-fields.tsx @@ -0,0 +1,138 @@ +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 dd33a78b1cf..2a33a8c0fdc 100644 --- a/apps/admin/src/routes.tsx +++ b/apps/admin/src/routes.tsx @@ -18,6 +18,7 @@ import MyProfileRedirect from './my-profile-redirect'; import { EmberFallback, ForceUpgradeGuard } from './ember-bridge'; import HomeRedirect from './home-redirect'; import { EmberListWithGiftLinks } from './gift-link-modal-host'; +import { PagesListGate, PostsListGate } from './posts-list-gate'; import { TagDetailGate } from './tag-detail-gate'; import { useFlagGatedRouteOwner } from './use-flag-gated-route-owner'; import { type AccessRouteHandle } from './route-access'; @@ -56,10 +57,28 @@ const EMBER_ROUTES: string[] = [ const emberFallbackHandle = { allowInForceUpgrade: true } satisfies AdminRouteHandle; +/** + * 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 AdminRouteHandle) + : emberFallbackHandle, })); const appRoutes: RouteObject[] = [ @@ -170,8 +189,11 @@ const appRoutes: RouteObject[] = [ requiresAccess: canAccessSettingsRoute, } satisfies 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, { @@ -205,6 +227,7 @@ const EMBER_ROUTE_COMPONENTS = new Set([EmberFallback, EmberListWithGif export function useIsEmberOwnedRoute(pathname: string): boolean { const tagDetailOwner = useFlagGatedRouteOwner('tagDetailsReact'); + const postsListOwner = useFlagGatedRouteOwner('postsListReact'); const leaf = matchRoutes(routes, pathname)?.at(-1)?.route; if (!leaf) { return true; @@ -212,5 +235,8 @@ export function useIsEmberOwnedRoute(pathname: string): boolean { if (leaf.Component === TagDetailGate) { return tagDetailOwner !== 'react'; } + if (leaf.Component === PostsListGate || leaf.Component === PagesListGate) { + return postsListOwner !== 'react'; + } return EMBER_ROUTE_COMPONENTS.has(leaf.Component); } diff --git a/apps/admin/src/settings/advanced/labs/private-features.tsx b/apps/admin/src/settings/advanced/labs/private-features.tsx index 1f6327fa2f3..ba19a8eb5d1 100644 --- a/apps/admin/src/settings/advanced/labs/private-features.tsx +++ b/apps/admin/src/settings/advanced/labs/private-features.tsx @@ -100,6 +100,12 @@ const features: Feature[] = [ 'Enables 3 and 6-month gift subscriptions with immediate or scheduled email delivery', 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', + }, { title: 'Self-serve archives', description: diff --git a/apps/admin/src/settings/membership/tiers-checkout.acceptance.test.tsx b/apps/admin/src/settings/membership/tiers-checkout.acceptance.test.tsx index c83f34fdca2..ff24956c5a5 100644 --- a/apps/admin/src/settings/membership/tiers-checkout.acceptance.test.tsx +++ b/apps/admin/src/settings/membership/tiers-checkout.acceptance.test.tsx @@ -214,6 +214,7 @@ describe('Tier checkout collection', () => { await page.getByRole('option', { name: nameField.name }).click(); await modal.getByRole('button', { name: 'Save' }).click(); await expect.element(modal.getByRole('button', { name: 'Saved' })).toBeVisible(); + await expect.poll(() => putApi.requests.length).toBe(1); const sent = ( putApi.lastRequest?.body as { diff --git a/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx b/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx index 27aef5f17fe..d710bc1e1ee 100644 --- a/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx +++ b/apps/admin/src/settings/site/design-and-branding/theme-preview.tsx @@ -20,6 +20,8 @@ type GlobalSettings = { interface ThemePreviewProps { settings: GlobalSettings; url: string; + // Hidden previews stay mounted and laid out, so revealing one needs no reflow. + isVisible?: boolean; } function getPreviewData({ @@ -71,7 +73,7 @@ function getPreviewData({ return params.toString(); } -const ThemePreview: React.FC = ({ settings, url }) => { +const ThemePreview: React.FC = ({ settings, url, isVisible = true }) => { const previewData = getPreviewData({ ...settings }); const injectContentIntoIframe = useCallback( @@ -133,7 +135,7 @@ const ThemePreview: React.FC = ({ settings, url }) => { className="absolute -top-px -left-px size-[calc(110%_+_3px)] origin-top-left scale-[.90909] bg-white max-[1600px]:size-[calc(130%_+_3px)] max-[1600px]:scale-[.76923]" generateContent={injectContentIntoIframe} height="100%" - parentClassName="relative h-full w-full overflow-hidden" + parentClassName={`absolute inset-0 overflow-hidden ${isVisible ? 'z-10 opacity-100' : 'z-0 opacity-0 pointer-events-none'}`} testId="theme-preview" width="100%" /> diff --git a/apps/admin/src/settings/site/design-modal.tsx b/apps/admin/src/settings/site/design-modal.tsx index c7d23bdbd24..3cd9fb18800 100644 --- a/apps/admin/src/settings/site/design-modal.tsx +++ b/apps/admin/src/settings/site/design-modal.tsx @@ -191,30 +191,34 @@ const DesignModal: React.FC = () => { } }; - let selectedTabURL = getHomepageUrl(siteData); - switch (selectedPreviewTab) { - case 'homepage': - selectedTabURL = getHomepageUrl(siteData); - break; - case 'post': - selectedTabURL = latestPost.url; - break; - } + // One preview per tab, all mounted up front, so switching needs no refetch. + const previewTargets = [ + { id: 'homepage', url: getHomepageUrl(siteData) }, + ...(latestPost ? [{ id: 'post', url: latestPost.url }] : []), + ]; + + const previewSettings = { + description, + accentColor, + icon, + logo, + coverImage, + themeSettings: formState.themeSettings, + headingFont, + bodyFont, + }; const rawPreviewContent = ( - +
      + {previewTargets.map(({ id, url }) => ( + + ))} +
      ); const previewContent = previewDevice === 'desktop' ? ( diff --git a/apps/admin/src/settings/site/design.acceptance.test.tsx b/apps/admin/src/settings/site/design.acceptance.test.tsx index a1fc4364f59..934d09e038e 100644 --- a/apps/admin/src/settings/site/design.acceptance.test.tsx +++ b/apps/admin/src/settings/site/design.acceptance.test.tsx @@ -74,9 +74,10 @@ describe('Design settings', () => { await renderAdminApp('/settings/design/edit'); const modal = settingsScreen.designModal(); + // Both previews are fetched up front, before either tab is selected. await expect(homepagePreview).toHaveRequestedPreview({ custom: '{}' }); - await settingsScreen.designToolbar().getByRole('tab', { name: 'Post' }).click(); await expect(postPreview!).toHaveRequestedPreview({ custom: '{}' }); + await settingsScreen.designToolbar().getByRole('tab', { name: 'Post' }).click(); await modal.getByRole('radio', { name: 'Mobile' }).click(); await expect.element(settingsScreen.previewMobile()).toBeVisible(); 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..d23c2c01f33 --- /dev/null +++ b/apps/admin/src/shared/filter-sources/use-post-author-value-source.ts @@ -0,0 +1,63 @@ +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..d83794de894 --- /dev/null +++ b/apps/admin/src/shared/filter-sources/use-post-tag-value-source.ts @@ -0,0 +1,77 @@ +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..c123fa07b34 --- /dev/null +++ b/apps/admin/src/shared/gift-link.test.ts @@ -0,0 +1,84 @@ +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..0e99042e11f --- /dev/null +++ b/apps/admin/src/shared/gift-link.ts @@ -0,0 +1,46 @@ +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 108dcc41b3e..2d46e78f43e 100644 --- a/apps/admin/test-utils/acceptance/index.ts +++ b/apps/admin/test-utils/acceptance/index.ts @@ -16,7 +16,9 @@ export { fakeMembers, fakeNewsletters, fakeOffers, + fakePages, fakePosts, + fakePostsListScreen, fakeRoles, fakeSettingsScreens, fakeTags, diff --git a/apps/admin/test-utils/acceptance/resources.ts b/apps/admin/test-utils/acceptance/resources.ts index 25baf97f3ac..d286779e286 100644 --- a/apps/admin/test-utils/acceptance/resources.ts +++ b/apps/admin/test-utils/acceptance/resources.ts @@ -98,7 +98,28 @@ function uncoveredFilterComponents(filter: string | undefined, covers: string[]) return []; } - return filter.split('+').filter((component) => { + const components: string[] = []; + let componentStart = 0; + let quote: "'" | '"' | undefined; + + for (let index = 0; index < filter.length; index += 1) { + const character = filter[index]; + + if (quote) { + if (character === quote && filter[index - 1] !== '\\') { + quote = undefined; + } + } else if (character === "'" || character === '"') { + quote = character; + } else if (character === '+') { + components.push(filter.slice(componentStart, index)); + componentStart = index + 1; + } + } + + components.push(filter.slice(componentStart)); + + return components.filter((component) => { const key = component.match(/^([\w.]+):/)?.[1]; return !key || !covers.includes(key); }); @@ -182,15 +203,15 @@ export function defineResource({ }; } -/** Tags list fake: declared-query semantics covering the `visibility` filter the tags tabs send. */ +/** Tags list fake: declared-query semantics for the tags tabs and remote tag pickers. */ export const fakeTags = defineResource({ resource: 'tags', semantics: { kind: 'declared-query', - covers: ['visibility'], + covers: ['visibility', 'tags.name'], select: (tags, { filter }) => { const visibility = filter?.match(/(?:^|\+)visibility:(\w+)/)?.[1]; - return visibility ? tags.filter((t) => t.visibility === visibility) : tags; + return visibility ? tags.filter((tag) => tag.visibility === visibility) : tags; }, }, }); @@ -262,6 +283,17 @@ export const fakePosts = defineResource({ 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', @@ -415,6 +447,25 @@ 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 cd81e8e5a6c..b009d885eea 100644 --- a/apps/ember-admin/app/services/feature.js +++ b/apps/ember-admin/app/services/feature.js @@ -84,6 +84,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/portal/src/components/pages/beta-gift-page.tsx b/apps/portal/src/components/pages/beta-gift-page.tsx index 131e42e4788..a13cd482185 100644 --- a/apps/portal/src/components/pages/beta-gift-page.tsx +++ b/apps/portal/src/components/pages/beta-gift-page.tsx @@ -210,7 +210,7 @@ const BetaGiftPage = () => { placeholder: t('Jamie Larson'), label: t('Your name'), name: 'buyerName', - required: false, + required: true, maxLength: GIFT_NAME_MAX_LENGTH, errorMessage: errors.buyerName || '', }; @@ -262,17 +262,20 @@ const BetaGiftPage = () => { }; const handleContinueToDelivery = () => { + const fieldsToValidate: GiftInputField[] = []; if (!isLoggedIn) { - const formErrors = validateInputForm({ - fields: [{ ...emailField, value: email.trim() }], - }); - const formHasErrors = Object.values(formErrors).some((errorMessage) => !!errorMessage); + fieldsToValidate.push({ ...emailField, value: email.trim() }); + } + fieldsToValidate.push({ ...buyerNameField, value: buyerName.trim() }); - setErrors(formErrors); + const formErrors = validateInputForm({ fields: fieldsToValidate }); - if (formHasErrors) { - return; - } + const formHasErrors = Object.values(formErrors).some((errorMessage) => !!errorMessage); + + setErrors(formErrors); + + if (formHasErrors) { + return; } setStep('delivery'); }; @@ -296,19 +299,12 @@ const BetaGiftPage = () => { const isScheduled = isEmailDelivery && effectiveDeliveryDate > minDeliveryDate; const fieldsToValidate: GiftInputField[] = []; - if (!isLoggedIn) { - fieldsToValidate.push({ ...emailField, value: customerEmail }); - } if (isEmailDelivery && trimmedRecipientEmail) { fieldsToValidate.push({ ...recipientEmailField, value: trimmedRecipientEmail }); } const formErrors = validateInputForm({ fields: fieldsToValidate }); - if (isEmailDelivery && !trimmedBuyerName) { - formErrors.buyerName = t('Enter your name'); - } - // No confirm-email field: the buyer gets a confirmation copy, which covers the unlikely // mistyped-recipient case. if (isEmailDelivery && !trimmedRecipientEmail) { @@ -330,9 +326,6 @@ const BetaGiftPage = () => { setErrors(formErrors); if (formHasErrors) { - if (formErrors.buyerName) { - setStep('plan'); - } return; } @@ -343,7 +336,7 @@ const BetaGiftPage = () => { deliveryMethod, ...(isEmailDelivery ? { recipientEmail: trimmedRecipientEmail } : {}), ...(isEmailDelivery && trimmedRecipientName ? { recipientName: trimmedRecipientName } : {}), - ...(trimmedBuyerName ? { buyerName: trimmedBuyerName } : {}), + buyerName: trimmedBuyerName, ...(isEmailDelivery && trimmedGiftMessage ? { personalMessage: trimmedGiftMessage } : {}), ...(isScheduled ? { deliveryDate: effectiveDeliveryDate } : {}), }); diff --git a/apps/portal/src/utils/form.js b/apps/portal/src/utils/form.js index f09923c0534..0e4f10045c0 100644 --- a/apps/portal/src/utils/form.js +++ b/apps/portal/src/utils/form.js @@ -5,6 +5,7 @@ export const FormInputError = ({ field }) => { if (field.required && !field.value) { switch (field.name) { case 'name': + case 'buyerName': return t(`Enter your name`); case 'email': diff --git a/apps/portal/test/unit/components/pages/beta-gift-page.test.tsx b/apps/portal/test/unit/components/pages/beta-gift-page.test.tsx index 939b3f5ea60..631fd39db38 100644 --- a/apps/portal/test/unit/components/pages/beta-gift-page.test.tsx +++ b/apps/portal/test/unit/components/pages/beta-gift-page.test.tsx @@ -40,9 +40,10 @@ function setup(site: SiteData, overrideContext: Record = {}) { describe('BetaGiftPage', () => { test('preserves focus on the checkout action when moving to delivery', () => { const site = buildSite({ labs: { giftSubCustomization: true } }); - const { getByRole } = setup(site); + const { getByLabelText, getByRole } = setup(site); const continueButton = getByRole('button', { name: 'Continue to delivery details' }); + fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); continueButton.focus(); fireEvent.click(continueButton); @@ -119,13 +120,15 @@ describe('BetaGiftPage', () => { }, portalDefaultPlan: 'yearly', }); - const { container, getByRole } = setup(site); + const { container, getByLabelText, getByRole } = setup(site); expect(getByRole('radio', { name: '1 year' })).toHaveAttribute('aria-checked', 'true'); + fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); + expect(getByLabelText("Recipient's email")).toBeInTheDocument(); expect(container.querySelector('.gh-portal-gift-email-lede')).toHaveTextContent( - "You've been gifted a 1-year Premium membership to The Blueprint", + 'Jamie has gifted you a 1-year Premium membership to The Blueprint', ); }); @@ -155,27 +158,27 @@ describe('BetaGiftPage', () => { expect(getByLabelText('Your name')).toHaveAttribute('maxlength', '191'); + fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); expect(getByLabelText("Recipient's email")).toHaveAttribute('maxlength', '191'); expect(getByLabelText("Recipient's name")).toHaveAttribute('maxlength', '191'); }); - test('requires a buyer name for email delivery', () => { + test('requires a buyer name before continuing to delivery', () => { const site = buildSite({ labs: { giftSubCustomization: true } }); - const { getByLabelText, getByRole, getByText, mockDoActionFn } = setup(site); + const { getByLabelText, getByRole, getByText, mockDoActionFn, queryByLabelText } = setup(site); fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); - fireEvent.change(getByLabelText("Recipient's email"), { - target: { value: 'recipient@example.com' }, - }); - fireEvent.click(getByRole('button', { name: 'Continue to payment' })); - expect(mockDoActionFn).not.toHaveBeenCalled(); + expect(queryByLabelText("Recipient's email")).not.toBeInTheDocument(); expect(getByText('Enter your name')).toBeInTheDocument(); fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); + fireEvent.change(getByLabelText("Recipient's email"), { + target: { value: 'recipient@example.com' }, + }); fireEvent.click(getByRole('button', { name: 'Continue to payment' })); expect(mockDoActionFn).toHaveBeenCalledWith( @@ -187,20 +190,29 @@ describe('BetaGiftPage', () => { ); }); - test('does not let a hidden recipient error lock the plan step', () => { + test('requires a recipient email before continuing to payment', () => { const site = buildSite({ labs: { giftSubCustomization: true } }); - const { getByLabelText, getByRole, getByText } = setup(site); + const { getByLabelText, getByRole, getByText, mockDoActionFn } = setup(site); + fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); fireEvent.click(getByRole('button', { name: 'Continue to payment' })); + expect(mockDoActionFn).not.toHaveBeenCalled(); + expect(getByText("Enter the recipient's email address")).toBeInTheDocument(); + expect(getByRole('button', { name: 'Continue to payment' })).toBeDisabled(); + }); + + test('clears the buyer name error as the buyer types', () => { + const site = buildSite({ labs: { giftSubCustomization: true } }); + const { getByLabelText, getByRole, getByText, queryByText } = setup(site); + + fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); + expect(getByText('Enter your name')).toBeInTheDocument(); fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); + expect(queryByText('Enter your name')).not.toBeInTheDocument(); expect(getByRole('button', { name: 'Continue to delivery details' })).not.toBeDisabled(); - - fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); - expect(getByText("Enter the recipient's email address")).toBeInTheDocument(); - expect(getByRole('button', { name: 'Continue to payment' })).toBeDisabled(); }); test('shows a buyer name field for a whitespace-only member name', () => { @@ -216,6 +228,49 @@ describe('BetaGiftPage', () => { expect(getByLabelText('Your name')).toBeInTheDocument(); }); + test('going back clears delivery errors so the plan step is not locked', () => { + const site = buildSite({ labs: { giftSubCustomization: true } }); + const { getByLabelText, getByRole, getByText, queryByText } = setup(site); + + fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); + fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); + fireEvent.click(getByRole('button', { name: 'Continue to payment' })); + + expect(getByText("Enter the recipient's email address")).toBeInTheDocument(); + + fireEvent.click(getByRole('button', { name: /back/i })); + + expect(queryByText("Enter the recipient's email address")).not.toBeInTheDocument(); + expect(getByRole('button', { name: 'Continue to delivery details' })).not.toBeDisabled(); + + fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); + expect(getByLabelText("Recipient's email")).toBeInTheDocument(); + }); + + test('continues without a name field for a member with a saved name', () => { + const site = buildSite({ labs: { giftSubCustomization: true } }); + const { getByLabelText, getByRole, mockDoActionFn, queryByLabelText } = setup(site, { + member: { + email: 'buyer@example.com', + name: 'Jamie Larson', + status: 'free', + }, + }); + + expect(queryByLabelText('Your name')).not.toBeInTheDocument(); + + fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); + fireEvent.change(getByLabelText("Recipient's email"), { + target: { value: 'recipient@example.com' }, + }); + fireEvent.click(getByRole('button', { name: 'Continue to payment' })); + + expect(mockDoActionFn).toHaveBeenCalledWith( + 'checkoutGift', + expect.objectContaining({ buyerName: 'Jamie Larson' }), + ); + }); + test('omits the selector when only one duration is available', () => { const site = buildSite({ labs: { @@ -249,6 +304,7 @@ describe('BetaGiftPage', () => { const site = buildSite({ labs: { giftSubCustomization: true } }); const { getByLabelText, getByRole, mockDoActionFn } = setup(site); + fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); fireEvent.change(getByLabelText("Recipient's name"), { target: { value: 'Taylor' } }); fireEvent.change(getByLabelText("Recipient's email"), { @@ -270,6 +326,7 @@ describe('BetaGiftPage', () => { tierId: 'tier_123', duration: 1, deliveryMethod: 'link', + buyerName: 'Jamie', }); }); diff --git a/apps/portal/test/unit/components/pages/gift-redemption-page.test.jsx b/apps/portal/test/unit/components/pages/gift-redemption-page.test.jsx index fc03032e6c8..3a48fba41f5 100644 --- a/apps/portal/test/unit/components/pages/gift-redemption-page.test.jsx +++ b/apps/portal/test/unit/components/pages/gift-redemption-page.test.jsx @@ -141,6 +141,16 @@ describe('BetaGiftRedemptionPage', () => { expect(queryByText(member.free.name)).not.toBeInTheDocument(); }); + test('shows the anonymous introduction for a gift without a buyer name', () => { + const { container } = renderGiftRedemptionPage(BetaGiftRedemptionPage); + + const subtitle = container.querySelector('.gh-portal-gift-checkout-subtitle'); + expect(subtitle).toHaveTextContent( + "You've been gifted a 1-year Premium membership to The Blueprint", + ); + expect(subtitle).toContainHTML('1-year'); + }); + test('presents the buyer details and prefills the intended recipient details', async () => { const personalizedGift = { ...gift, diff --git a/apps/portal/test/unit/components/popup-modal.test.jsx b/apps/portal/test/unit/components/popup-modal.test.jsx index 648e63aac78..92fa10a50d2 100644 --- a/apps/portal/test/unit/components/popup-modal.test.jsx +++ b/apps/portal/test/unit/components/popup-modal.test.jsx @@ -29,6 +29,7 @@ describe('PopupContent', () => { }, ); + fireEvent.change(getByLabelText('Your name'), { target: { value: 'Jamie' } }); fireEvent.click(getByRole('button', { name: 'Continue to delivery details' })); const message = getByLabelText('Optional message'); fireEvent.change(message, { target: { value: 'Enjoy!' } }); diff --git a/apps/shade/src/components/patterns/filters.tsx b/apps/shade/src/components/patterns/filters.tsx index d55dac477b5..22c31811d48 100644 --- a/apps/shade/src/components/patterns/filters.tsx +++ b/apps/shade/src/components/patterns/filters.tsx @@ -1569,6 +1569,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, @@ -1602,17 +1611,13 @@ function SelectOptionsList({ onSelect={() => onSelectSelected(option)} > {option.icon && option.icon} -
      - - {option.label} - - {option.detail && ( - - {option.detail} - - )} -
      - + + {option.label} + + ))} @@ -1626,21 +1631,30 @@ 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 710b77048c3..493a9dbadd9 100644 --- a/apps/shade/tailwind.theme.css +++ b/apps/shade/tailwind.theme.css @@ -216,6 +216,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 52e3d5ebe81..d01fefd6d5b 100644 --- a/apps/shade/theme-variables.css +++ b/apps/shade/theme-variables.css @@ -85,6 +85,11 @@ --control-disabled-surface: var(--color-gray-100); --control-border: color-mix(in oklab, var(--color-gray-200), var(--color-gray-300)); --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; } @@ -125,6 +130,11 @@ --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); --foreground: var(--color-gray-200); 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 3a45efeb8a1..9d9b95ca7c6 100644 --- a/e2e/helpers/pages/admin/posts/post/post-editor-page.ts +++ b/e2e/helpers/pages/admin/posts/post/post-editor-page.ts @@ -136,6 +136,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; @@ -155,6 +161,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 53246e4b8d1..ad52f9e8124 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,39 @@ 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 { @@ -51,31 +72,67 @@ 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(); } @@ -90,6 +147,105 @@ 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 `