diff --git a/apps/admin-x-framework/src/api/images.ts b/apps/admin-x-framework/src/api/images.ts index c0b016ffa7a6..2c7316578432 100644 --- a/apps/admin-x-framework/src/api/images.ts +++ b/apps/admin-x-framework/src/api/images.ts @@ -8,7 +8,13 @@ export interface ImagesResponseType { }[]; } -export const useUploadImage = createMutation({ +export interface UploadImagePayload { + file: File; + /** False when the caller handles an expired session itself instead of leaving the page. */ + sessionExpiryRedirect?: boolean; +} + +export const useUploadImage = createMutation({ method: 'POST', path: () => '/images/upload/', body: ({ file }) => { @@ -17,6 +23,7 @@ export const useUploadImage = createMutation formData.append('purpose', 'image'); return formData; }, + requestOptions: ({ sessionExpiryRedirect }) => ({ sessionExpiryRedirect }), }); const UploadedImageResponseSchema = z.object({ diff --git a/apps/admin-x-framework/src/hooks/use-koenig-file-upload.ts b/apps/admin-x-framework/src/hooks/use-koenig-file-upload.ts index 917e747af999..93e76ef2935f 100644 --- a/apps/admin-x-framework/src/hooks/use-koenig-file-upload.ts +++ b/apps/admin-x-framework/src/hooks/use-koenig-file-upload.ts @@ -1,6 +1,6 @@ import { useRef, useState } from 'react'; import { getGhostPaths } from '../utils/helpers'; -import { useFetchApi } from '../utils/api/fetch-api'; +import { useFetchApi, type RequestOptions } from '../utils/api/fetch-api'; export const koenigFileUploadTypes = { image: { @@ -55,6 +55,10 @@ interface UploadOptions { formData?: Record; } +type UploadRequestOptions = Pick; + +const DEFAULT_REQUEST_OPTIONS: UploadRequestOptions = {}; + interface UploadError { fileName: string; message: string; @@ -86,7 +90,11 @@ const getStringAtPath = (maybeObj: unknown, path: Iterable): null | return typeof current === 'string' ? current : null; }; -export const useKoenigFileUpload = (type: KoenigFileUploadType = 'image'): FileUploadHook => { +/** The session-expiry policy applies to every upload this hook makes. */ +export const useKoenigFileUpload = ( + type: KoenigFileUploadType = 'image', + requestOptions: UploadRequestOptions = DEFAULT_REQUEST_OPTIONS, +): FileUploadHook => { const [progress, setProgress] = useState(0); const [isLoading, setLoading] = useState(false); const [errors, setErrors] = useState([]); @@ -164,6 +172,7 @@ export const useKoenigFileUpload = (type: KoenigFileUploadType = 'image'): FileU try { const uploadResponse = await fetchApi(url, { + ...requestOptions, method: koenigFileUploadTypes[type].requestMethod, body: fileFormData, onUploadProgress(uploadProgress) { diff --git a/apps/admin-x-framework/test/unit/api/images.test.ts b/apps/admin-x-framework/test/unit/api/images.test.ts deleted file mode 100644 index 35b5a921d652..000000000000 --- a/apps/admin-x-framework/test/unit/api/images.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { getImageUrl } from '../../../src/api/images'; - -describe('getImageUrl', () => { - it.each(['https://example.com/image.png', '/content/images/image.png'])( - 'accepts an uploaded image URL (%s)', - (url) => { - expect(getImageUrl({ images: [{ url, ref: null }] })).toBe(url); - }, - ); - - it.each([ - null, - {}, - { images: [] }, - { images: [{}] }, - { images: [{ url: 123 }] }, - { images: [{ url: true }] }, - { images: [{ url: '' }] }, - ])('rejects a malformed upload response (%j)', (response) => { - expect(() => getImageUrl(response)).toThrow(); - }); -}); diff --git a/apps/admin-x-framework/test/unit/api/images.test.tsx b/apps/admin-x-framework/test/unit/api/images.test.tsx new file mode 100644 index 000000000000..dd69cfd88ca1 --- /dev/null +++ b/apps/admin-x-framework/test/unit/api/images.test.tsx @@ -0,0 +1,98 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import React, { ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { withMockFetch } from '../../utils/mock-fetch'; +import { FrameworkProvider } from '../../../src/providers/framework-provider'; + +const fetchApiCalls = vi.hoisted(() => [] as { endpoint: unknown; options: RequestOptionsLike }[]); + +interface RequestOptionsLike { + sessionExpiryRedirect?: boolean; +} + +// Wraps the real transport so requests still run, and records what each call asked for +vi.mock('../../../src/utils/api/fetch-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFetchApi: () => { + const fetchApi = actual.useFetchApi(); + return (endpoint: unknown, options: RequestOptionsLike = {}) => { + fetchApiCalls.push({ endpoint, options }); + return (fetchApi as (...args: unknown[]) => Promise)(endpoint, options); + }; + }, + }; +}); + +import { getImageUrl, useUploadImage } from '../../../src/api/images'; + +const wrapper: React.FC<{ children: ReactNode }> = ({ children }) => ( + {}} + ghostVersion="5.x" + sentryDSN="" + unsplashConfig={{ + Authorization: '', + 'Accept-Version': '', + 'Content-Type': '', + 'App-Pragma': '', + 'X-Unsplash-Cache': true, + }} + onDelete={() => {}} + onInvalidate={() => {}} + onUpdate={() => {}} + > + {children} + +); + +describe('getImageUrl', () => { + it.each(['https://example.com/image.png', '/content/images/image.png'])( + 'accepts an uploaded image URL (%s)', + (url) => { + expect(getImageUrl({ images: [{ url, ref: null }] })).toBe(url); + }, + ); + + it.each([ + null, + {}, + { images: [] }, + { images: [{}] }, + { images: [{ url: 123 }] }, + { images: [{ url: true }] }, + { images: [{ url: '' }] }, + ])('rejects a malformed upload response (%j)', (response) => { + expect(() => getImageUrl(response)).toThrow(); + }); +}); + +describe('useUploadImage', () => { + const uploaded = { images: [{ url: 'https://example.com/image.png', ref: null }] }; + + const upload = async (payload: { file: File; sessionExpiryRedirect?: boolean }) => { + fetchApiCalls.length = 0; + await withMockFetch({ json: uploaded }, async () => { + const { result } = renderHook(() => useUploadImage(), { wrapper }); + await waitFor(() => expect(result.current.mutateAsync).toBeTypeOf('function')); + await result.current.mutateAsync(payload); + }); + return fetchApiCalls[0]?.options; + }; + + it('keeps the session-expiry redirect when the caller asks for nothing', async () => { + const options = await upload({ file: new File(['image'], 'hills.png') }); + + expect(options?.sessionExpiryRedirect).toBeUndefined(); + }); + + it('passes the session-expiry opt-out to the transport', async () => { + const options = await upload({ + file: new File(['image'], 'hills.png'), + sessionExpiryRedirect: false, + }); + + expect(options?.sessionExpiryRedirect).toBe(false); + }); +}); diff --git a/apps/admin-x-framework/test/unit/hooks/use-koenig-file-upload.test.ts b/apps/admin-x-framework/test/unit/hooks/use-koenig-file-upload.test.ts index 1406a15bed3d..c1cce05a27a4 100644 --- a/apps/admin-x-framework/test/unit/hooks/use-koenig-file-upload.test.ts +++ b/apps/admin-x-framework/test/unit/hooks/use-koenig-file-upload.test.ts @@ -4,6 +4,28 @@ import { type AddressInfo } from 'node:net'; import http from 'node:http'; import { promisify } from 'node:util'; import * as helpers from '../../../src/utils/helpers'; + +interface RequestOptionsLike { + sessionExpiryRedirect?: boolean; +} + +const fetchApiCalls = vi.hoisted(() => [] as RequestOptionsLike[]); + +// Wraps the real transport so uploads still run, and records what each call asked for +vi.mock('../../../src/utils/api/fetch-api', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFetchApi: () => { + const fetchApi = actual.useFetchApi(); + return (endpoint: unknown, options: RequestOptionsLike = {}) => { + fetchApiCalls.push(options); + return (fetchApi as (...args: unknown[]) => Promise)(endpoint, options); + }; + }, + }; +}); + import { useKoenigFileUpload } from '../../../src/hooks/use-koenig-file-upload'; function makeFile(name: string, type = 'image/jpeg'): File { @@ -59,6 +81,7 @@ describe('useKoenigFileUpload', () => { beforeEach(async () => { uploadResponse = successfulUploadResponse; requestLog = []; + fetchApiCalls.length = 0; server = http.createServer((req, res) => { requestLog.push({ method: req.method, url: req.url }); @@ -303,6 +326,28 @@ describe('useKoenigFileUpload', () => { expect(result.current.errors).toHaveLength(0); }); + it('keeps the session-expiry redirect when the caller asks for nothing', async () => { + const { result } = renderHook(() => useKoenigFileUpload('image')); + + await act(async () => { + await result.current.upload([makeFile('photo.jpg')]); + }); + + expect(fetchApiCalls[0]?.sessionExpiryRedirect).toBeUndefined(); + }); + + it('passes the session-expiry opt-out to the transport', async () => { + const { result } = renderHook(() => + useKoenigFileUpload('image', { sessionExpiryRedirect: false }), + ); + + await act(async () => { + await result.current.upload([makeFile('photo.jpg')]); + }); + + expect(fetchApiCalls[0]?.sessionExpiryRedirect).toBe(false); + }); + it('accepts all supported image extensions', async () => { const supportedExtensions = ['gif', 'jpg', 'jpeg', 'png', 'svg', 'svgz', 'webp']; diff --git a/apps/admin/package.json b/apps/admin/package.json index 5a911904f1f6..8a2614731de7 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -25,6 +25,8 @@ "@codemirror/state": "catalog:", "@codemirror/theme-one-dark": "catalog:", "@dnd-kit/sortable": "catalog:", + "@radix-ui/react-focus-guards": "catalog:", + "@radix-ui/react-focus-scope": "catalog:", "@sentry/react": "catalog:", "@svg-maps/world": "2.0.0", "@tanstack/react-query": "catalog:", diff --git a/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx b/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx index 0619761a48d0..4991a873b842 100644 --- a/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-feature-image.acceptance.test.tsx @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { userEvent } from 'vitest/browser'; +import { page, userEvent } from 'vitest/browser'; import { + UNSPLASH_PICKED, + currentRoute, fakeAdminEndpoint, fakeEditorChrome, fakeEditorPost, + fakeUnsplashPhotos, post, renderAdminApp, submittedPost, @@ -200,6 +203,27 @@ describe('Post editor feature image', () => { SLOW, ); + it( + 'saves an image picked from Unsplash with the credit it carries', + async () => { + const saveApi = fakeSavablePost(); + fakeUnsplashPhotos(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.featureImageUnsplashButton()).toBeVisible(); + await editorScreen.featureImageUnsplashButton().click(); + await editorScreen.unsplashInsertImage().click(); + + await expect.poll(() => saveApi.requests.length, SAVE_POLL).toBe(1); + const saved = submittedPost(saveApi); + expect(saved.feature_image).toBe(UNSPLASH_PICKED); + // The photographer credit the picker hands over, as the caption stores it. + expect(String(saved.feature_image_caption)).toContain('A Photographer'); + await expect.element(editorScreen.removeFeatureImage()).toBeVisible(); + }, + SLOW, + ); + it( 'clears the alt text and caption along with the image', async () => { @@ -222,4 +246,33 @@ describe('Post editor feature image', () => { }, SLOW, ); + + it( + 'stays in the editor when the upload finds no session', + async () => { + fakeSavablePost(); + const uploadApi = fakeAdminEndpoint( + 'POST', + '/images/upload/', + { errors: [{ type: 'UnauthorizedError', message: 'Authorization failed' }] }, + { status: 401 }, + ); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + + await expect.element(editorScreen.featureImage()).toBeVisible(); + await editorScreen.titleInput().fill('Brand New Name'); + await userEvent.upload( + editorScreen.featureImageInput().element(), + new File(['image'], 'hills.png', { type: 'image/png' }), + ); + + // A 401 mid-upload must not navigate away from work that is still unsaved. + await expect.poll(() => uploadApi.requests.length, SAVE_POLL).toBe(1); + await expect.element(editorScreen.titleInput()).toHaveValue('Brand New Name'); + expect(currentRoute()).toBe(`/editor/post/${POST_ID}`); + await expect.element(editorScreen.featureImageInput()).toBeInTheDocument(); + await expect.element(page.getByText('Couldn’t upload the feature image.')).toBeVisible(); + }, + SLOW, + ); }); diff --git a/apps/admin/src/editor/editor-screen.tsx b/apps/admin/src/editor/editor-screen.tsx index 192834fdf480..0bccb336cde1 100644 --- a/apps/admin/src/editor/editor-screen.tsx +++ b/apps/admin/src/editor/editor-screen.tsx @@ -304,8 +304,8 @@ function useLexicalConversion(postType: PostType) { try { const record: EditorRecord | undefined = postType === 'page' - ? (await editPage({ page: payload, options })).pages[0] - : (await editPost({ post: payload, options })).posts[0]; + ? (await editPage({ page: payload, options, ...EDITOR_REQUEST_OPTIONS })).pages[0] + : (await editPost({ post: payload, options, ...EDITOR_REQUEST_OPTIONS })).posts[0]; setState(record ? { id: source.id, record } : { id: source.id, error: true }); } catch (error) { setState({ id: source.id, error }); diff --git a/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx index 80ced8dab2f6..64705683256f 100644 --- a/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-access.acceptance.test.tsx @@ -194,7 +194,13 @@ describe('Post settings access', () => { await chooseVisibility('Specific tier(s)'); // Nothing is selected yet, so the choice is held back rather than stripped. - await expect.element(editorScreen.settingsTiersError()).toBeVisible(); + await expect + .element(editorScreen.settingsTiersError()) + .toHaveTextContent('Please select at least one tier'); + await expect.element(editorScreen.settingsTiers()).toHaveAttribute('aria-invalid', 'true'); + await expect + .element(editorScreen.settingsTiers()) + .toHaveAttribute('aria-describedby', editorScreen.settingsTiersError().element().id); expect(saveApi.requests).toHaveLength(0); // Archived paid tiers are offered after the active ones; free tiers are not. await expect.element(editorScreen.settingsTier('Bronze')).toBeVisible(); diff --git a/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx index a5ee8c917e70..4c41319c3011 100644 --- a/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-facebook-card.acceptance.test.tsx @@ -2,18 +2,19 @@ import { describe, expect, it } from 'vitest'; import { userEvent } from 'vitest/browser'; import { + UNSPLASH_PICKED, currentUserResponse, fakeAdminEndpoint, fakeEditorChrome, fakeEditorPost, - fakeEndpoint, fakeTiers, + fakeUnsplashPhotos, post, renderAdminApp, - settingsResponse, staffRole, submittedPost, unsavedChangesGuarded, + withoutUnsplash, type StaffRoleName, } from '@test-utils/acceptance'; import { editorScreen } from '@/editor/editor.screen'; @@ -67,44 +68,6 @@ function fakeSavablePost(overrides: Partial = {}) { }); } -const UNSPLASH_REGULAR = 'https://images.unsplash.com/photo-1?ixid=1&w=1080'; -// The picker asks Unsplash for a wider rendition of the image it inserts. -const UNSPLASH_PICKED = 'https://images.unsplash.com/photo-1?ixid=1&w=2000'; - -/** One Unsplash photo, in the shape the search modal lays out and inserts. */ -function fakeUnsplashPhotos() { - fakeEndpoint('GET', 'https://api.unsplash.com/photos', [ - { - id: 'photo-1', - color: '#123456', - alt_description: 'A hillside', - height: 800, - width: 1200, - likes: 12, - urls: { regular: UNSPLASH_REGULAR }, - links: { - html: 'https://unsplash.com/photos/photo-1', - download: 'https://unsplash.com/photos/photo-1/download', - download_location: 'https://api.unsplash.com/photos/photo-1/download', - }, - user: { - name: 'A Photographer', - links: { html: 'https://unsplash.com/@photographer' }, - profile_image: { medium: 'https://images.unsplash.com/profile-1' }, - }, - }, - ]); - fakeEndpoint('GET', 'https://api.unsplash.com/photos/photo-1/download', {}); -} - -/** The site fixture turns Unsplash on, so only the off case needs an override. */ -function withoutUnsplash() { - return { - ...FLAG_ON, - boot: { browseSettings: { response: settingsResponse({ settings: { unsplash: false } }) } }, - }; -} - async function openFacebookCard() { await editorScreen.settingsToggle().click(); await expect.element(editorScreen.settingsSidebar()).toBeVisible(); @@ -378,7 +341,7 @@ describe('Post settings Facebook card', () => { 'leaves Unsplash out while the site’s integration is off', async () => { fakeSavablePost(); - await renderAdminApp(`/editor/post/${POST_ID}`, withoutUnsplash()); + await renderAdminApp(`/editor/post/${POST_ID}`, { ...FLAG_ON, ...withoutUnsplash() }); await openFacebookCard(); await expect.element(editorScreen.settingsFacebookImageInput()).toBeInTheDocument(); diff --git a/apps/admin/src/editor/editor-settings-meta-data.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-meta-data.acceptance.test.tsx index d0a3def24e21..3fc2e5028351 100644 --- a/apps/admin/src/editor/editor-settings-meta-data.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-meta-data.acceptance.test.tsx @@ -68,7 +68,7 @@ async function openMetaData() { /** Whether the countdown is showing the writer they are past the recommendation. */ function countdownIsOver(): boolean { - return !!editorScreen.settingsSubviewPane().element().querySelector('span.text-red'); + return !!editorScreen.settingsSubviewPane().element().querySelector('span.text-destructive'); } /** diff --git a/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx b/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx index 5d39f8603fca..bc13e026135e 100644 --- a/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx +++ b/apps/admin/src/editor/editor-settings-x-card.acceptance.test.tsx @@ -2,18 +2,19 @@ import { describe, expect, it } from 'vitest'; import { page, userEvent } from 'vitest/browser'; import { + UNSPLASH_PICKED, currentUserResponse, fakeAdminEndpoint, fakeEditorChrome, fakeEditorPost, - fakeEndpoint, fakeTiers, + fakeUnsplashPhotos, post, renderAdminApp, - settingsResponse, staffRole, submittedPost, unsavedChangesGuarded, + withoutUnsplash, type StaffRoleName, } from '@test-utils/acceptance'; import { editorScreen } from '@/editor/editor.screen'; @@ -73,44 +74,6 @@ function fakeImageUpload() { }); } -const UNSPLASH_REGULAR = 'https://images.unsplash.com/photo-1?ixid=1&w=1080'; -// The picker asks Unsplash for a wider rendition of the image it inserts. -const UNSPLASH_PICKED = 'https://images.unsplash.com/photo-1?ixid=1&w=2000'; - -/** One Unsplash photo, in the shape the search modal lays out and inserts. */ -function fakeUnsplashPhotos() { - fakeEndpoint('GET', 'https://api.unsplash.com/photos', [ - { - id: 'photo-1', - color: '#123456', - alt_description: 'A hillside', - height: 800, - width: 1200, - likes: 12, - urls: { regular: UNSPLASH_REGULAR }, - links: { - html: 'https://unsplash.com/photos/photo-1', - download: 'https://unsplash.com/photos/photo-1/download', - download_location: 'https://api.unsplash.com/photos/photo-1/download', - }, - user: { - name: 'A Photographer', - links: { html: 'https://unsplash.com/@photographer' }, - profile_image: { medium: 'https://images.unsplash.com/profile-1' }, - }, - }, - ]); - fakeEndpoint('GET', 'https://api.unsplash.com/photos/photo-1/download', {}); -} - -/** The site fixture turns Unsplash on, so only the off case needs an override. */ -function withoutUnsplash() { - return { - ...FLAG_ON, - boot: { browseSettings: { response: settingsResponse({ settings: { unsplash: false } }) } }, - }; -} - async function openXCard() { await editorScreen.settingsToggle().click(); await expect.element(editorScreen.settingsSidebar()).toBeVisible(); @@ -517,7 +480,7 @@ describe('Post settings X card', () => { 'leaves Unsplash out while the site’s integration is off', async () => { fakeSavablePost(); - await renderAdminApp(`/editor/post/${POST_ID}`, withoutUnsplash()); + await renderAdminApp(`/editor/post/${POST_ID}`, { ...FLAG_ON, ...withoutUnsplash() }); await openXCard(); await expect.element(editorScreen.settingsXImageInput()).toBeInTheDocument(); @@ -546,6 +509,38 @@ describe('Post settings X card', () => { SLOW, ); + it( + 'keeps keyboard navigation inside Unsplash and restores focus after Escape', + async () => { + fakeSavablePost(); + fakeUnsplashPhotos(); + await renderAdminApp(`/editor/post/${POST_ID}`, FLAG_ON); + await openXCard(); + + await editorScreen.settingsXImageUnsplashButton().click(); + await expect.element(editorScreen.unsplashSearchInput()).toHaveFocus(); + await expect.element(editorScreen.unsplashInsertImage()).toBeVisible(); + + // Back past the close button: focus must wrap inside the search rather + // than reaching the picker button in the pane behind it. + await userEvent.keyboard('{Shift>}{Tab}{Tab}{/Shift}'); + expect(editorScreen.unsplashSearch().element().contains(document.activeElement)).toBe(true); + + // Traverse past the close button, search field and one photo's links. + // Every stop stays inside, including the forward wrap. + for (let i = 0; i < 6; i++) { + await userEvent.keyboard('{Tab}'); + expect(editorScreen.unsplashSearch().element().contains(document.activeElement)).toBe(true); + } + await userEvent.keyboard('{Escape}'); + + await expect(editorScreen.unsplashModal()).toHaveCount(0); + await expect.element(editorScreen.settingsSubviewPane()).toBeVisible(); + await expect.element(editorScreen.settingsXImageUnsplashButton()).toHaveFocus(); + }, + SLOW, + ); + it( 'keeps the pane open when Escape dismisses the Unsplash search', async () => { diff --git a/apps/admin/src/editor/editor-status.tsx b/apps/admin/src/editor/editor-status.tsx index 05e03db12e99..cdb5f5e9edb3 100644 --- a/apps/admin/src/editor/editor-status.tsx +++ b/apps/admin/src/editor/editor-status.tsx @@ -66,7 +66,7 @@ function StatusBody({ }) { switch (view.kind) { case 'problem': - return {view.message}; + return {view.message}; case 'saving': return Saving…; case 'new': diff --git a/apps/admin/src/editor/editor.screen.ts b/apps/admin/src/editor/editor.screen.ts index 322956cc75fe..2680a3679be1 100644 --- a/apps/admin/src/editor/editor.screen.ts +++ b/apps/admin/src/editor/editor.screen.ts @@ -93,7 +93,6 @@ import { settingsTagsToken, settingsTemplateSelect, settingsTemplateSlugMatch, - settingsTiersError, settingsTiersPicker, settingsUrlPreview, settingsVisibilitySelect, @@ -101,6 +100,7 @@ import { stayInEditorButton, tkIndicator, toggleFeatureImageAltButton, + unsplashSearchModal, } from '@tryghost/test-data/selectors/editor'; /** Editor screen locators and gestures for acceptance specs; no assertions. */ @@ -178,7 +178,7 @@ export const editorScreen = { settingsTiers: () => page.getByTestId(settingsTiersPicker), settingsTier: (name: string) => page.getByTestId(settingsTiersPicker).getByRole('checkbox', { name }), - settingsTiersError: () => page.getByTestId(settingsTiersError), + settingsTiersError: () => page.getByTestId(settingsTiersPicker).getByRole('alert'), settingsTagsField: () => page.getByTestId(settingsTagsField), settingsTagsInput: () => page.getByTestId(settingsTagsInput), settingsTagsTokens: () => page.getByTestId(settingsTagsToken), @@ -285,7 +285,9 @@ export const editorScreen = { featureImageUnsplashButton: () => page.getByRole('button', { name: featureImageUnsplashButton }), /** The Unsplash search modal, wherever the picker that opened it sits. */ unsplashModal: () => page.getByRole('heading', { name: 'Unsplash' }), - unsplashInsertImage: () => page.getByText('Insert image'), + unsplashSearch: () => page.getByTestId(unsplashSearchModal), + unsplashSearchInput: () => page.getByPlaceholder('Search free high-resolution photos'), + unsplashInsertImage: () => page.getByTestId(unsplashSearchModal).getByText('Insert image'), removeFeatureImage: () => page.getByRole('button', { name: removeFeatureImageButton }), featureImageAltToggle: () => page.getByRole('button', { name: toggleFeatureImageAltButton }), featureImageAltInput: () => page.getByLabelText(featureImageAltLabel), diff --git a/apps/admin/src/editor/feature-image.tsx b/apps/admin/src/editor/feature-image.tsx index c51e16d6c64c..b1aa6e2e8872 100644 --- a/apps/admin/src/editor/feature-image.tsx +++ b/apps/admin/src/editor/feature-image.tsx @@ -21,6 +21,7 @@ import { import type { KoenigInstance } from '@/settings/components/koenig-loader'; import type { PostCardConfig } from './card-config'; import { FeatureImageCaption } from './feature-image-caption'; +import { EDITOR_REQUEST_OPTIONS } from './request-options'; import { UnsplashPicker } from './unsplash-picker'; const ALT_MAX_LENGTH = 191; @@ -67,7 +68,7 @@ export function FeatureImage({ const handleUpload = useCallback( async (file: File) => { try { - onImageChange(getImageUrl(await uploadImage({ file }))); + onImageChange(getImageUrl(await uploadImage({ file, ...EDITOR_REQUEST_OPTIONS }))); } catch (error) { toast.error(uploadErrorMessage(error, IMAGE_SUBJECT)); } diff --git a/apps/admin/src/editor/koenig-file-uploader.ts b/apps/admin/src/editor/koenig-file-uploader.ts new file mode 100644 index 000000000000..3dbfa87a5432 --- /dev/null +++ b/apps/admin/src/editor/koenig-file-uploader.ts @@ -0,0 +1,15 @@ +import { + koenigFileUploadTypes, + useKoenigFileUpload, + type KoenigFileUploadType, +} from '@tryghost/admin-x-framework/hooks'; +import { EDITOR_REQUEST_OPTIONS } from './request-options'; + +const useEditorFileUpload = (type: KoenigFileUploadType = 'image') => + useKoenigFileUpload(type, EDITOR_REQUEST_OPTIONS); + +/** The uploader Koenig cards use inside the editor, on the editor's request policy. */ +export const editorFileUploader = { + useFileUpload: useEditorFileUpload, + fileTypes: koenigFileUploadTypes, +}; diff --git a/apps/admin/src/editor/koenig-post-editor.tsx b/apps/admin/src/editor/koenig-post-editor.tsx index 52013a4b0d52..f2b790efcef8 100644 --- a/apps/admin/src/editor/koenig-post-editor.tsx +++ b/apps/admin/src/editor/koenig-post-editor.tsx @@ -1,6 +1,5 @@ import { Suspense, useCallback, useMemo } from 'react'; import { LoadingIndicator } from '@tryghost/shade/components'; -import { koenigFileUploadTypes, useKoenigFileUpload } from '@tryghost/admin-x-framework/hooks'; import ErrorBoundary from '@/settings/components/error-boundary'; import { type EditorResource, @@ -9,11 +8,7 @@ import { } from '@/settings/components/koenig-loader'; import type { PostCardConfig } from './card-config'; import { reportKoenigError } from './koenig-error'; - -const fileUploader = { - useFileUpload: useKoenigFileUpload, - fileTypes: koenigFileUploadTypes, -}; +import { editorFileUploader } from './koenig-file-uploader'; const NOOP = () => {}; @@ -68,7 +63,7 @@ function KoenigInstanceMount({ {visibility === 'tiers' ? ( - + !option.archived)} @@ -159,10 +169,10 @@ export function AccessSection({ session, postType }: AccessSectionProps) { selected={selected} onToggle={toggleTier} /> - {tiersIncomplete(session.settings) ? ( - + {tiersMissing ? ( + {TIERS_REQUIRED} - + ) : null} ) : null} diff --git a/apps/admin/src/editor/settings/authors-section.tsx b/apps/admin/src/editor/settings/authors-section.tsx index b8d57615e980..952657be8eb1 100644 --- a/apps/admin/src/editor/settings/authors-section.tsx +++ b/apps/admin/src/editor/settings/authors-section.tsx @@ -1,6 +1,5 @@ import { useCallback, useId, useState } from 'react'; -import { Label } from '@tryghost/shade/components'; -import { Text } from '@tryghost/shade/primitives'; +import { FieldError, Label } from '@tryghost/shade/components'; import { useBrowseUsers, type User } from '@tryghost/admin-x-framework/api/users'; import type { PostAuthor } from '@tryghost/admin-x-framework/api/posts'; import { settingsAuthorsError } from '@tryghost/test-data/selectors/editor'; @@ -61,15 +60,9 @@ export function AuthorsSection({ session, currentUser }: AuthorsSectionProps) { onRetry={() => void refetch()} /> {invalid ? ( - + {AUTHORS_REQUIRED} - + ) : null} ); diff --git a/apps/admin/src/editor/settings/code-injection-section.tsx b/apps/admin/src/editor/settings/code-injection-section.tsx index aca5f0ccbe11..42455a5a1c37 100644 --- a/apps/admin/src/editor/settings/code-injection-section.tsx +++ b/apps/admin/src/editor/settings/code-injection-section.tsx @@ -4,8 +4,8 @@ import type { PostType } from '@/editor/card-config'; import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; import { SettingsSubview } from './settings-subview'; -// An Escape no other binding answers leaves the event unprevented, closing the -// pane. Arm tab-focus mode for the 2s @codemirror/view does, so Tab leaves. +// A binding that returns true prevents the event's default, which the pane +// reads as answered. Arm tab-focus mode for the 2s @codemirror/view does. const TAB_FOCUS_ESCAPE = () => import('@uiw/react-codemirror').then(({ Prec, keymap }) => Prec.lowest( diff --git a/apps/admin/src/editor/settings/facebook-card-section.tsx b/apps/admin/src/editor/settings/facebook-card-section.tsx index 703b28ce783f..4427a983da4d 100644 --- a/apps/admin/src/editor/settings/facebook-card-section.tsx +++ b/apps/admin/src/editor/settings/facebook-card-section.tsx @@ -1,6 +1,6 @@ import { useCallback, useId } from 'react'; import { toast } from 'sonner'; -import { Input, Label, LoadingIndicator, Textarea } from '@tryghost/shade/components'; +import { FieldError, Input, Label, LoadingIndicator, Textarea } from '@tryghost/shade/components'; import { ImageUpload, ImageUploadAction, @@ -26,6 +26,7 @@ import { uploadErrorMessage, } from '@/shared/images/image-upload'; import type { PostCardConfig } from '@/editor/card-config'; +import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { OG_DESCRIPTION_MAX, OG_DESCRIPTION_TOO_LONG, @@ -35,7 +36,6 @@ import { } from '@/editor/session/settings-fields'; import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; import { UnsplashPicker } from '@/editor/unsplash-picker'; -import { FieldError } from './field-error'; import { truncate } from './meta-data-fields'; import { SettingsSubview } from './settings-subview'; import { @@ -108,7 +108,9 @@ export function FacebookCardSection({ const handleUpload = useCallback( async (file: File) => { try { - session.editSettings({ og_image: getImageUrl(await uploadImage({ file })) }); + session.editSettings({ + og_image: getImageUrl(await uploadImage({ file, ...EDITOR_REQUEST_OPTIONS })), + }); } catch (error) { toast.error(uploadErrorMessage(error, IMAGE_SUBJECT)); } @@ -180,7 +182,7 @@ export function FacebookCardSection({ // A cleared field is stored as no value, the way the excerpt is. onChange={(event) => session.stageSettings({ og_title: event.target.value || null })} /> - {titleError ? : null} + {titleError ? {titleError} : null} @@ -199,7 +201,7 @@ export function FacebookCardSection({ } /> {descriptionError ? ( - + {descriptionError} ) : null} diff --git a/apps/admin/src/editor/settings/field-error.tsx b/apps/admin/src/editor/settings/field-error.tsx deleted file mode 100644 index d3a0f4bcf143..000000000000 --- a/apps/admin/src/editor/settings/field-error.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { Text } from '@tryghost/shade/primitives'; - -/** What a settings field says about a value it will not save. */ -export function FieldError({ id, message }: { id: string; message: string }) { - return ( - - {message} - - ); -} diff --git a/apps/admin/src/editor/settings/meta-data-section.tsx b/apps/admin/src/editor/settings/meta-data-section.tsx index da55d97ddcf5..4d2a692a7b2c 100644 --- a/apps/admin/src/editor/settings/meta-data-section.tsx +++ b/apps/admin/src/editor/settings/meta-data-section.tsx @@ -1,5 +1,5 @@ import { useId } from 'react'; -import { Input, Label, Textarea } from '@tryghost/shade/components'; +import { FieldError, Input, Label, Textarea } from '@tryghost/shade/components'; import { Stack, Text } from '@tryghost/shade/primitives'; import { LucideIcon, cn, formatNumber } from '@tryghost/shade/utils'; import { @@ -15,7 +15,6 @@ import { overLength, } from '@/editor/session/settings-fields'; import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; -import { FieldError } from './field-error'; import { META_DESCRIPTION_RECOMMENDED, META_TITLE_RECOMMENDED, @@ -36,7 +35,9 @@ function Countdown({ id, value, recommended }: { id: string; value: string; reco return ( Recommended: {formatNumber(recommended)} characters. You've used{' '} - recommended ? 'text-red' : 'text-green')}> + recommended ? 'text-destructive' : 'text-state-success')} + > {formatNumber(used)} @@ -126,7 +127,7 @@ export function MetaDataSection({ session, siteUrl }: MetaDataSectionProps) { onChange={(event) => session.stageSettings({ meta_title: event.target.value || null })} /> - {titleError ? : null} + {titleError ? {titleError} : null} @@ -152,7 +153,7 @@ export function MetaDataSection({ session, siteUrl }: MetaDataSectionProps) { value={metaDescription} /> {descriptionError ? ( - + {descriptionError} ) : null} diff --git a/apps/admin/src/editor/settings/publish-date-section.tsx b/apps/admin/src/editor/settings/publish-date-section.tsx index bed5342c6d5d..4425945b83aa 100644 --- a/apps/admin/src/editor/settings/publish-date-section.tsx +++ b/apps/admin/src/editor/settings/publish-date-section.tsx @@ -1,5 +1,5 @@ import { useId } from 'react'; -import { Label } from '@tryghost/shade/components'; +import { FieldError, Label } from '@tryghost/shade/components'; import { Text } from '@tryghost/shade/primitives'; import { getSettingValue, useBrowseSettings } from '@tryghost/admin-x-framework/api/settings'; import { @@ -65,15 +65,9 @@ export function PublishDateSection({ session }: PublishDateSectionProps) { onChange={(date) => session.editPublishedAt(date.toISOString())} /> {invalid ? ( - + {PUBLISHED_AT_MUST_BE_PAST} - + ) : null} {isScheduled && !isPastScheduled ? ( diff --git a/apps/admin/src/editor/settings/revision-preview.tsx b/apps/admin/src/editor/settings/revision-preview.tsx index bdc1d3f0076a..9d7333b353bc 100644 --- a/apps/admin/src/editor/settings/revision-preview.tsx +++ b/apps/admin/src/editor/settings/revision-preview.tsx @@ -2,7 +2,6 @@ import DOMPurify from 'dompurify'; import { Suspense, useCallback, useMemo } from 'react'; import { LoadingIndicator } from '@tryghost/shade/components'; import { Inline, Text } from '@tryghost/shade/primitives'; -import { koenigFileUploadTypes, useKoenigFileUpload } from '@tryghost/admin-x-framework/hooks'; import { postHistoryPreview, postHistoryPreviewBody, @@ -18,13 +17,9 @@ import { } from '@/settings/components/koenig-loader'; import type { PostCardConfig } from '@/editor/card-config'; import { reportKoenigError } from '@/editor/koenig-error'; +import { editorFileUploader } from '@/editor/koenig-file-uploader'; import type { RevisionEntry } from './post-history'; -const fileUploader = { - useFileUpload: useKoenigFileUpload, - fileTypes: koenigFileUploadTypes, -}; - /** The part of Lexical's editor the loader's minimal instance type leaves out. */ type LexicalEditable = { setEditable: (editable: boolean) => void }; @@ -62,7 +57,7 @@ function RevisionBody({ diff --git a/apps/admin/src/editor/settings/url-section.tsx b/apps/admin/src/editor/settings/url-section.tsx index f58cd3726f36..7daf62cd2def 100644 --- a/apps/admin/src/editor/settings/url-section.tsx +++ b/apps/admin/src/editor/settings/url-section.tsx @@ -1,5 +1,5 @@ import { type KeyboardEvent, useCallback, useId, useState } from 'react'; -import { Input, Label } from '@tryghost/shade/components'; +import { FieldError, Input, Label } from '@tryghost/shade/components'; import { Text } from '@tryghost/shade/primitives'; import { settingsSlugError, @@ -74,15 +74,9 @@ export function UrlSection({ onKeyDown={onKeyDown} /> {failed ? ( - + {EDIT_FAILED} - + ) : null} {formatUrlPreview(siteUrl, value)} diff --git a/apps/admin/src/editor/settings/x-card-section.tsx b/apps/admin/src/editor/settings/x-card-section.tsx index 61b55fd40a61..b53143b7a64e 100644 --- a/apps/admin/src/editor/settings/x-card-section.tsx +++ b/apps/admin/src/editor/settings/x-card-section.tsx @@ -1,6 +1,6 @@ import { useCallback, useId } from 'react'; import { toast } from 'sonner'; -import { Input, Label, LoadingIndicator, Textarea } from '@tryghost/shade/components'; +import { FieldError, Input, Label, LoadingIndicator, Textarea } from '@tryghost/shade/components'; import { ImageUpload, ImageUploadAction, @@ -29,6 +29,7 @@ import { uploadErrorMessage, } from '@/shared/images/image-upload'; import type { PostCardConfig } from '@/editor/card-config'; +import { EDITOR_REQUEST_OPTIONS } from '@/editor/request-options'; import { X_DESCRIPTION_MAX, X_DESCRIPTION_TOO_LONG, @@ -38,7 +39,6 @@ import { } from '@/editor/session/settings-fields'; import type { EditorSessionHandle } from '@/editor/session/use-editor-session'; import { UnsplashPicker } from '@/editor/unsplash-picker'; -import { FieldError } from './field-error'; import { truncate } from './meta-data-fields'; import { SettingsSubview } from './settings-subview'; import { @@ -104,7 +104,9 @@ export function XCardSection({ session, siteUrl, featureImage, cardConfig }: XCa const handleUpload = useCallback( async (file: File) => { try { - session.editSettings({ twitter_image: getImageUrl(await uploadImage({ file })) }); + session.editSettings({ + twitter_image: getImageUrl(await uploadImage({ file, ...EDITOR_REQUEST_OPTIONS })), + }); } catch (error) { toast.error(uploadErrorMessage(error, IMAGE_SUBJECT)); } @@ -176,7 +178,7 @@ export function XCardSection({ session, siteUrl, featureImage, cardConfig }: XCa // A cleared field is stored as no value, the way the excerpt is. onChange={(event) => session.stageSettings({ twitter_title: event.target.value || null })} /> - {titleError ? : null} + {titleError ? {titleError} : null} @@ -195,7 +197,7 @@ export function XCardSection({ session, siteUrl, featureImage, cardConfig }: XCa } /> {descriptionError ? ( - + {descriptionError} ) : null} diff --git a/apps/admin/src/editor/unsplash-picker.test.tsx b/apps/admin/src/editor/unsplash-picker.test.tsx new file mode 100644 index 000000000000..f5c3e0101706 --- /dev/null +++ b/apps/admin/src/editor/unsplash-picker.test.tsx @@ -0,0 +1,86 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { UnsplashPicker } from './unsplash-picker'; + +vi.mock('@tryghost/kg-unsplash-selector', () => ({ + UnsplashSearchModal: ({ onClose }: { onClose: () => void }) => ( +
+ Unsplash search + +
+ ), +})); + +vi.mock('@tryghost/admin-x-framework', () => ({ + useFramework: () => ({ unsplashConfig: null }), +})); + +const LABEL = 'Select an image from Unsplash'; + +/** Whether an Escape raised from `from` is marked as already answered. */ +function escapeMarked(from: EventTarget): boolean { + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + from.dispatchEvent(event); + return event.defaultPrevented; +} + +function picker(enabled: boolean) { + return ; +} + +function openSearch() { + fireEvent.click(screen.getByRole('button', { name: LABEL })); + return screen.getByText('Unsplash search'); +} + +/** + * The Unsplash affordance on an image field. Only the search itself answers + * Escape, so the pane it opened over keeps its own. + */ +describe('UnsplashPicker', () => { + it('returns focus to the picker when the search closes', async () => { + render(picker(true)); + const trigger = screen.getByRole('button', { name: LABEL }); + trigger.focus(); + openSearch(); + + const close = screen.getByRole('button', { name: 'Close search' }); + expect(close).toHaveFocus(); + fireEvent.click(close); + + await waitFor(() => expect(trigger).toHaveFocus()); + }); + + it('marks only the Escape raised inside the open search', () => { + render(picker(true)); + + const search = openSearch(); + + expect(escapeMarked(search)).toBe(true); + // Anything the search does not contain still reaches the pane behind it. + expect(escapeMarked(window)).toBe(false); + expect(escapeMarked(document.body)).toBe(false); + }); + + it('closes the search and gives Escape back when the integration goes off', () => { + const { rerender } = render(picker(true)); + + const search = openSearch(); + + expect(search).toBeInTheDocument(); + expect(escapeMarked(search)).toBe(true); + + rerender(picker(false)); + + expect(screen.queryByText('Unsplash search')).not.toBeInTheDocument(); + expect(escapeMarked(search)).toBe(false); + + rerender(picker(true)); + + // The affordance comes back closed rather than reopening what was dismissed. + expect(screen.queryByText('Unsplash search')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: LABEL })).toBeVisible(); + }); +}); diff --git a/apps/admin/src/editor/unsplash-picker.tsx b/apps/admin/src/editor/unsplash-picker.tsx index 475c567d5afd..33bc700c329e 100644 --- a/apps/admin/src/editor/unsplash-picker.tsx +++ b/apps/admin/src/editor/unsplash-picker.tsx @@ -1,10 +1,13 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { FocusGuards } from '@radix-ui/react-focus-guards'; +import { FocusScope } from '@radix-ui/react-focus-scope'; import { UnsplashSearchModal } from '@tryghost/kg-unsplash-selector'; import { Button } from '@tryghost/shade/components'; import { ImageUploadActions } from '@tryghost/shade/patterns'; import { cn } from '@tryghost/shade/utils'; import { useFramework } from '@tryghost/admin-x-framework'; +import { unsplashSearchModal } from '@tryghost/test-data/selectors/editor'; import BrandIcon from '@/shared/brand-icon/brand-icon'; export interface UnsplashSelection { @@ -36,12 +39,22 @@ export function UnsplashPicker({ onSelect, }: UnsplashPickerProps) { const { unsplashConfig } = useFramework(); + const triggerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); + const [modalRoot, setModalRoot] = useState(null); + + // A gate that goes off while the search is open closes it, so nothing is left + // listening for a field the writer can no longer see. + useEffect(() => { + if (!enabled) { + setIsOpen(false); + } + }, [enabled]); // The modal answers Escape itself but does not mark it, so a settings pane // behind it would read the same Escape as its own and close too. useEffect(() => { - if (!isOpen) { + if (!modalRoot) { return; } @@ -51,9 +64,9 @@ export function UnsplashPicker({ } }; - window.addEventListener('keydown', markHandled, true); - return () => window.removeEventListener('keydown', markHandled, true); - }, [isOpen]); + modalRoot.addEventListener('keydown', markHandled, true); + return () => modalRoot.removeEventListener('keydown', markHandled, true); + }, [modalRoot]); if (!enabled) { return null; @@ -63,6 +76,7 @@ export function UnsplashPicker({ <> )} - + ); }; diff --git a/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-provider.tsx b/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-provider.tsx index f0a3312fcdb8..75fb55a1a47b 100644 --- a/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-provider.tsx +++ b/apps/admin/src/posts/analytics/email-sending-status/email-sending-status-provider.tsx @@ -1,5 +1,4 @@ import { EmailSendingStatusContext } from './email-sending-status-context'; -import { APIError } from '@tryghost/admin-x-framework/errors'; import { feedbackDataType } from '@tryghost/admin-x-framework/api/feedback'; import { hasBeenEmailed } from '@tryghost/admin-x-framework'; import { linksDataType } from '@tryghost/admin-x-framework/api/links'; @@ -8,17 +7,13 @@ import { newsletterBasicStatsDataType, newsletterClickStatsDataType, } from '@tryghost/admin-x-framework/api/stats'; -import { - useBrowseEmailBatches, - useEmailSendingStatus, - useRetryEmail, -} from '@tryghost/admin-x-framework/api/emails'; +import { useBrowseEmailBatches, useRetryEmail } from '@tryghost/admin-x-framework/api/emails'; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useFeatureFlag, useHandleError } from '@tryghost/admin-x-framework/hooks'; import { usePostAnalytics } from '@/posts/analytics/providers/post-analytics-context'; +import { useEmailSendingStatusPolling } from '@/posts/email-sending-status/use-email-sending-status'; import { useQueryClient } from '@tanstack/react-query'; -const STATUS_POLL_INTERVAL = import.meta.env.MODE === 'test' ? 50 : 2000; const NEWSLETTER_DATA_TYPES = new Set([ postsDataType, linksDataType, @@ -37,36 +32,17 @@ const EmailSendingStatusProvider = ({ children }: { children: ReactNode }) => { Boolean(emailId) && (post?.status === 'published' || post?.status === 'sent'); const shouldQuery = enabled && hasPublishedEmail && Boolean(emailStatus); - const statusQuery = useEmailSendingStatus(emailId ?? '', { - enabled: (query) => { - const queriedStatus = query.state.data?.email_statuses[0]?.sending.status; - const missingBackend = - !query.state.data && - query.state.error instanceof APIError && - query.state.error.response?.status === 404; - const submittedBeforeStatusLoaded = !query.state.data && emailStatus === 'submitted'; - return ( - shouldQuery && - !missingBackend && - !submittedBeforeStatusLoaded && - queriedStatus !== 'submitted' - ); - }, - defaultErrorHandler: false, - refetchInterval: (query) => { - const status = query.state.data?.email_statuses[0]?.sending.status; - return status === 'preparing' || status === 'submitting' ? STATUS_POLL_INTERVAL : false; - }, - refetchIntervalInBackground: false, - refetchOnWindowFocus: true, - retry: false, + const statusQuery = useEmailSendingStatusPolling({ + emailId, + emailStatus, + enabled: shouldQuery, }); const { mutateAsync: retryEmail, isPending: isRetryMutationPending } = useRetryEmail(); const { refetch: refetchStatus } = statusQuery; const handleError = useHandleError(); const [isRetryRefreshPending, setIsRetryRefreshPending] = useState(false); - const status = statusQuery.data?.email_statuses[0]; + const status = statusQuery.status; const sendingStatus = status?.sending.status; const shouldQueryBatches = Boolean(enabled && emailId && sendingStatus === 'failed'); const batchesQuery = useBrowseEmailBatches(emailId ?? '', { diff --git a/apps/admin/src/posts/analytics/email-sending-status/use-sending-eta.test.ts b/apps/admin/src/posts/analytics/email-sending-status/use-sending-eta.test.ts index 7e5072244cad..65433eec072a 100644 --- a/apps/admin/src/posts/analytics/email-sending-status/use-sending-eta.test.ts +++ b/apps/admin/src/posts/analytics/email-sending-status/use-sending-eta.test.ts @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { useSendingEta } from './use-sending-eta'; +import { useSendingEta } from '@/posts/email-sending-status/use-sending-eta'; import type { EmailSendingStatus } from '@tryghost/admin-x-framework/api/emails'; function status( @@ -18,13 +18,15 @@ function status( } describe('useSendingEta', () => { - it('hides the time label until an active phase has an estimate', () => { + it('hides the time label until sending has an estimate', () => { const { result, rerender } = renderHook(useSendingEta, { initialProps: undefined as EmailSendingStatus | undefined, }); expect(result.current).toBeNull(); rerender(status(null, 'preparing')); expect(result.current).toBeNull(); + rerender(status(30, 'preparing')); + expect(result.current).toBeNull(); rerender(status(null, 'submitting')); expect(result.current).toBeNull(); rerender(status(30)); @@ -65,6 +67,7 @@ describe('useSendingEta', () => { const { result, rerender } = renderHook(useSendingEta, { initialProps: status(20, 'preparing'), }); + expect(result.current).toBeNull(); rerender(status(45)); expect(result.current).toBe('About 1 minute left'); rerender(status(35, 'submitting', 'email-2')); diff --git a/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx b/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx index e55ade25df51..f71cbbed27d8 100644 --- a/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx +++ b/apps/admin/src/posts/analytics/post-analytics.acceptance.test.tsx @@ -291,11 +291,17 @@ describe('Post analytics overview', () => { await expect .element(postAnalyticsScreen.emailSendingStatusBanner()) - .toHaveTextContent('Sending emails · 250 of 1,000'); + .toHaveTextContent(/Sending emails\s*250 of 1,000/); + await expect + .element(postAnalyticsScreen.emailSendingStatusBanner()) + .not.toHaveTextContent('minute'); estimate = 30; await expect .element(postAnalyticsScreen.emailSendingStatusBanner()) - .toHaveTextContent('Sending emails · 250 of 1,000 · Less than 1 minute left'); + .toHaveTextContent(/Sending emails\s*250 of 1,000/); + await expect + .element(postAnalyticsScreen.emailSendingStatusBanner()) + .toHaveTextContent('Less than 1 minute left'); }); it('moves a failed send and its retry action into the banner', async () => { @@ -461,7 +467,12 @@ describe('Post analytics overview', () => { boot: webAnalyticsBootOverrides(), }); - await expect.element(page.getByText('Preparing emails')).toBeVisible(); + await expect + .element(postAnalyticsScreen.emailSendingStatusBanner()) + .toHaveTextContent(/Preparing emails\s*10% complete · 1,000 total/); + await expect + .element(postAnalyticsScreen.emailSendingStatusBanner()) + .not.toHaveTextContent('minute'); // Advance the fake server only after the initial state is visible: extra // mount-time requests must not race the assertion straight into failure. const initialStatusRequests = statusRequestCount; diff --git a/apps/admin/src/posts/email-sending-status/email-sending-status-copy.test.ts b/apps/admin/src/posts/email-sending-status/email-sending-status-copy.test.ts new file mode 100644 index 000000000000..dacf2cac2b05 --- /dev/null +++ b/apps/admin/src/posts/email-sending-status/email-sending-status-copy.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { getEmailSendingProgressCopy } from './email-sending-status-copy'; + +describe('getEmailSendingProgressCopy', () => { + it('formats preparing progress and an estimate', () => { + expect( + getEmailSendingProgressCopy( + { + status: 'preparing', + progress: { completed: 1200, total: 5000, estimated_seconds_remaining: 60 }, + }, + 'About 1 minute left', + ), + ).toEqual({ + title: 'Preparing emails', + detail: '1,200 of 5,000 · About 1 minute left', + }); + }); + + it('formats sending progress without an estimate', () => { + expect( + getEmailSendingProgressCopy( + { + status: 'submitting', + progress: { completed: 250, total: 1000, estimated_seconds_remaining: null }, + }, + null, + ), + ).toEqual({ title: 'Sending emails', detail: '250 of 1,000' }); + }); + + it('shows only an estimate before a total is available', () => { + expect( + getEmailSendingProgressCopy( + { + status: 'preparing', + progress: { completed: 0, total: 0, estimated_seconds_remaining: 30 }, + }, + 'Less than 1 minute left', + ), + ).toEqual({ title: 'Preparing emails', detail: 'Less than 1 minute left' }); + }); +}); diff --git a/apps/admin/src/posts/email-sending-status/email-sending-status-copy.ts b/apps/admin/src/posts/email-sending-status/email-sending-status-copy.ts new file mode 100644 index 000000000000..32516f40b334 --- /dev/null +++ b/apps/admin/src/posts/email-sending-status/email-sending-status-copy.ts @@ -0,0 +1,27 @@ +import { formatNumber } from '@tryghost/shade/utils'; +import type { EmailSendingState } from '@tryghost/admin-x-framework/api/emails'; + +type NonFailedEmailSendingState = Exclude; + +export interface EmailSendingProgressCopy { + title: 'Preparing emails' | 'Sending emails'; + detail: string | null; +} + +/** + * The active send wording shared by post analytics and the posts list. + * `submitted` is accepted because analytics keeps the sending UI visible while + * its dependent post and newsletter data refreshes. + */ +export function getEmailSendingProgressCopy( + sending: NonFailedEmailSendingState, + estimate: string | null, +): EmailSendingProgressCopy { + const { completed, total } = sending.progress; + const progress = total === 0 ? null : `${formatNumber(completed)} of ${formatNumber(total)}`; + + return { + title: sending.status === 'preparing' ? 'Preparing emails' : 'Sending emails', + detail: [progress, estimate].filter(Boolean).join(' · ') || null, + }; +} diff --git a/apps/admin/src/posts/email-sending-status/use-email-sending-status.test.ts b/apps/admin/src/posts/email-sending-status/use-email-sending-status.test.ts new file mode 100644 index 000000000000..4cb8990abc9c --- /dev/null +++ b/apps/admin/src/posts/email-sending-status/use-email-sending-status.test.ts @@ -0,0 +1,117 @@ +import { createElement, type ReactNode } from 'react'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useEmailSendingStatusPolling } from './use-email-sending-status'; +import type { EmailStatusesResponseType } from '@tryghost/admin-x-framework/api/emails'; + +const { fetchStatus } = vi.hoisted(() => ({ fetchStatus: vi.fn() })); + +// Keep the real query observer and production cache lifetime; only replace HTTP. +vi.mock('@tryghost/admin-x-framework/api/emails', () => ({ + useEmailSendingStatus: (id: string, options: object) => + useQuery({ + ...options, + queryKey: ['email-status', id], + queryFn: fetchStatus, + }), +})); + +function response( + status: 'preparing' | 'submitting' | 'submitted' | 'failed', +): EmailStatusesResponseType { + const progress = { completed: 10, total: 100, estimated_seconds_remaining: null }; + return { + email_statuses: [ + { + id: 'email-1', + sending: + status === 'failed' + ? { status, progress, failed_during: 'submitting' } + : { status, progress }, + }, + ], + }; +} + +const clients: QueryClient[] = []; + +afterEach(() => { + clients.forEach((client) => client.clear()); + clients.length = 0; + vi.resetAllMocks(); +}); + +function setup(cached: EmailStatusesResponseType, emailStatus = 'submitting') { + const client = new QueryClient({ + defaultOptions: { queries: { staleTime: 5 * 60 * 1000, retry: false } }, + }); + clients.push(client); + client.setQueryData(['email-status', 'email-1'], cached); + const wrapper = ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client }, children); + return renderHook( + () => + useEmailSendingStatusPolling({ + emailId: 'email-1', + emailStatus, + enabled: true, + }), + { wrapper }, + ); +} + +describe('email status on screen entry', () => { + it('fetches the current retry progress instead of reusing a cached send failure', async () => { + let resolve!: (value: EmailStatusesResponseType) => void; + fetchStatus.mockImplementation( + () => + new Promise((done) => { + resolve = done; + }), + ); + // The list previously observed a failure. After a retry elsewhere, the post + // says submitting, but the status query still has that failure in its cache. + const { result, unmount } = setup(response('failed'), 'submitting'); + + expect(fetchStatus).toHaveBeenCalledTimes(1); + expect(result.current.status).toBeUndefined(); + expect(result.current.isLoading).toBe(true); + + act(() => { + resolve(response('submitting')); + }); + await waitFor(() => expect(result.current.status?.sending.status).toBe('submitting')); + // Failed sends stop polling; the refreshed retry must start it again. + await waitFor(() => expect(fetchStatus.mock.calls.length).toBeGreaterThan(1)); + unmount(); + }); + + it('does not expose the cached failure if the entry refresh fails', async () => { + fetchStatus.mockRejectedValue(new Error('Bad gateway')); + const { result, unmount } = setup(response('failed')); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + expect(result.current.status).toBeUndefined(); + unmount(); + }); + + it('does not revive cached sending UI for a post already submitted', () => { + const { result, unmount } = setup(response('failed'), 'submitted'); + expect(fetchStatus).not.toHaveBeenCalled(); + expect(result.current.status).toBeUndefined(); + unmount(); + }); + + it('propagates an explicit refresh failure so analytics can report a failed retry', async () => { + fetchStatus.mockResolvedValue(response('failed')); + const { result, unmount } = setup(response('failed'), 'failed'); + await waitFor(() => expect(result.current.status?.sending.status).toBe('failed')); + + const error = new Error('Bad gateway'); + fetchStatus.mockRejectedValue(error); + await act(async () => { + await expect(result.current.refetch({ throwOnError: true })).rejects.toBe(error); + }); + unmount(); + }); +}); diff --git a/apps/admin/src/posts/email-sending-status/use-email-sending-status.ts b/apps/admin/src/posts/email-sending-status/use-email-sending-status.ts new file mode 100644 index 000000000000..905d2e49b174 --- /dev/null +++ b/apps/admin/src/posts/email-sending-status/use-email-sending-status.ts @@ -0,0 +1,64 @@ +import { APIError } from '@tryghost/admin-x-framework/errors'; +import { useEmailSendingStatus } from '@tryghost/admin-x-framework/api/emails'; +import { useCallback } from 'react'; +import type { EmailSendingStatus } from '@tryghost/admin-x-framework/api/emails'; + +const STATUS_POLL_INTERVAL = import.meta.env.MODE === 'test' ? 50 : 2000; + +interface UseEmailSendingStatusPollingOptions { + emailId?: string | null; + emailStatus?: string | null; + enabled: boolean; +} + +interface EmailSendingStatusPollingResult { + status: EmailSendingStatus | undefined; + isLoading: boolean; + isUnsupported: boolean; + refetch: (options?: { throwOnError?: boolean }) => Promise; +} + +/** + * The polling and older-backend compatibility contract shared by every Admin + * surface that reports an email send in progress. + */ +export function useEmailSendingStatusPolling({ + emailId, + emailStatus, + enabled, +}: UseEmailSendingStatusPollingOptions): EmailSendingStatusPollingResult { + const shouldQuery = enabled && Boolean(emailId) && Boolean(emailStatus); + const query = useEmailSendingStatus(emailId ?? '', { + // The post's terminal state needs no status check. For every other state, + // refresh on entry, even if a previous screen cached a failure or completion. + enabled: shouldQuery && emailStatus !== 'submitted', + staleTime: 0, + defaultErrorHandler: false, + refetchInterval: (currentQuery) => { + const status = currentQuery.state.data?.email_statuses[0]?.sending.status; + return status === 'preparing' || status === 'submitting' ? STATUS_POLL_INTERVAL : false; + }, + refetchIntervalInBackground: false, + refetchOnWindowFocus: true, + retry: false, + }); + // Cached results must not drive banners or completion/failure effects before + // this screen's request succeeds (including when that request fails). + const data = query.isFetchedAfterMount && !query.isError ? query.data : undefined; + const isUnsupported = + !data && query.error instanceof APIError && query.error.response?.status === 404; + const { refetch: refetchQuery } = query; + const refetch = useCallback( + async (options?: { throwOnError?: boolean }) => { + await refetchQuery(options); + }, + [refetchQuery], + ); + + return { + status: data?.email_statuses[0], + isLoading: query.isLoading || (!data && query.isFetching), + isUnsupported, + refetch, + }; +} diff --git a/apps/admin/src/posts/analytics/email-sending-status/use-sending-eta.ts b/apps/admin/src/posts/email-sending-status/use-sending-eta.ts similarity index 89% rename from apps/admin/src/posts/analytics/email-sending-status/use-sending-eta.ts rename to apps/admin/src/posts/email-sending-status/use-sending-eta.ts index ae345d753d90..415625ab5516 100644 --- a/apps/admin/src/posts/analytics/email-sending-status/use-sending-eta.ts +++ b/apps/admin/src/posts/email-sending-status/use-sending-eta.ts @@ -18,8 +18,9 @@ function etaMinutes(seconds: number, previous: number | null): number { export function useSendingEta(status: EmailSendingStatus | undefined): string | null { const sending = status?.sending; const key = status ? `${status.id}:${status.sending.status}` : null; - const isActive = sending?.status === 'preparing' || sending?.status === 'submitting'; - const seconds = isActive ? sending.progress.estimated_seconds_remaining : null; + // Database timing makes preparation estimates unreliable. + const seconds = + sending?.status === 'submitting' ? sending.progress.estimated_seconds_remaining : null; const [previous, setPrevious] = useState<{ key: string | null; minutes: number | null }>({ key: null, minutes: null, diff --git a/apps/admin/src/posts/list/components/post-list-row-email-status-state.test.ts b/apps/admin/src/posts/list/components/post-list-row-email-status-state.test.ts new file mode 100644 index 000000000000..ea9c2df57ac5 --- /dev/null +++ b/apps/admin/src/posts/list/components/post-list-row-email-status-state.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { hasInProgressEmail } from './post-list-row-email-status-state'; +import type { PostListItem } from '@/posts/list/hooks/use-posts-list'; + +const post = (overrides: Partial = {}): PostListItem => ({ + id: 'post-1', + uuid: 'post-uuid', + title: 'A post', + slug: 'a-post', + url: 'https://example.com/a-post/', + status: 'published', + email: { + id: 'email-1', + status: 'submitting', + email_count: 1000, + opened_count: 0, + }, + ...overrides, +}); + +describe('hasInProgressEmail', () => { + it.each(['pending', 'submitting'] as const)('includes %s emails', (status) => { + expect( + hasInProgressEmail( + post({ email: { id: 'email-1', status, email_count: 0, opened_count: 0 } }), + 'posts', + true, + ), + ).toBe(true); + }); + + it('includes email-only sent posts', () => { + expect(hasInProgressEmail(post({ status: 'sent', email_only: true }), 'posts', true)).toBe( + true, + ); + }); + + it.each([ + ['feature disabled', post(), 'posts', false], + ['page', post(), 'pages', true], + ['draft', post({ status: 'draft' }), 'posts', true], + ['scheduled', post({ status: 'scheduled' }), 'posts', true], + [ + 'missing email ID', + post({ email: { status: 'submitting', email_count: 0, opened_count: 0 } }), + 'posts', + true, + ], + [ + 'submitted email', + post({ + email: { id: 'email-1', status: 'submitted', email_count: 1000, opened_count: 0 }, + }), + 'posts', + true, + ], + [ + 'failed email', + post({ email: { id: 'email-1', status: 'failed', email_count: 0, opened_count: 0 } }), + 'posts', + true, + ], + ] as const)('excludes a %s', (_label, candidate, resource, enabled) => { + expect(hasInProgressEmail(candidate, resource, enabled)).toBe(false); + }); +}); diff --git a/apps/admin/src/posts/list/components/post-list-row-email-status-state.ts b/apps/admin/src/posts/list/components/post-list-row-email-status-state.ts new file mode 100644 index 000000000000..25d6c8d3c987 --- /dev/null +++ b/apps/admin/src/posts/list/components/post-list-row-email-status-state.ts @@ -0,0 +1,26 @@ +import type { PostListItem } from '@/posts/list/hooks/use-posts-list'; +import type { PostResource } from '@/posts/list/post-resource'; +import type { EmailSendingProgressCopy } from '@/posts/email-sending-status/email-sending-status-copy'; + +export type PostListRowEmailStatusState = + | { status: 'settled' } + | { status: 'sending'; copy: EmailSendingProgressCopy } + | { status: 'failed' }; + +export const SETTLED_POST_LIST_ROW_EMAIL_STATUS: PostListRowEmailStatusState = { + status: 'settled', +}; + +export function hasInProgressEmail( + post: PostListItem, + resource: PostResource, + improveSendingUI: boolean, +): boolean { + return Boolean( + improveSendingUI && + resource === 'posts' && + (post.status === 'published' || post.status === 'sent') && + post.email?.id && + (post.email.status === 'pending' || post.email.status === 'submitting'), + ); +} diff --git a/apps/admin/src/posts/list/components/post-list-row-email-status.tsx b/apps/admin/src/posts/list/components/post-list-row-email-status.tsx new file mode 100644 index 000000000000..7e6fbd42ab66 --- /dev/null +++ b/apps/admin/src/posts/list/components/post-list-row-email-status.tsx @@ -0,0 +1,101 @@ +import { postsDataType } from '@tryghost/admin-x-framework/api/posts'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState, type ReactNode } from 'react'; +import { getEmailSendingProgressCopy } from '@/posts/email-sending-status/email-sending-status-copy'; +import { useEmailSendingStatusPolling } from '@/posts/email-sending-status/use-email-sending-status'; +import { useSendingEta } from '@/posts/email-sending-status/use-sending-eta'; +import type { PostListItem } from '@/posts/list/hooks/use-posts-list'; +import { + SETTLED_POST_LIST_ROW_EMAIL_STATUS, + type PostListRowEmailStatusState, +} from '@/posts/list/components/post-list-row-email-status-state'; + +interface PostListRowEmailStatusProps { + post: PostListItem; + children: (state: PostListRowEmailStatusState) => ReactNode; +} + +/** + * Owns the live query for one in-progress row. Settled rows never mount this + * component, keeping their memo boundary independent of polling updates. + */ +export function PostListRowEmailStatus({ post, children }: PostListRowEmailStatusProps) { + const queryClient = useQueryClient(); + const emailId = post.email?.id; + const emailStatus = post.email?.status; + const query = useEmailSendingStatusPolling({ emailId, emailStatus, enabled: true }); + const status = query.status; + const sending = status?.sending; + const sendingStatus = sending?.status; + const estimate = useSendingEta(status); + const [refreshedSubmittedEmailId, setRefreshedSubmittedEmailId] = useState(null); + + useEffect(() => { + if (!emailId || !sendingStatus) { + return; + } + + if (sendingStatus !== 'submitted') { + setRefreshedSubmittedEmailId((currentEmailId) => + currentEmailId === emailId ? null : currentEmailId, + ); + } + + if (sendingStatus === 'failed') { + void queryClient.invalidateQueries({ queryKey: [postsDataType] }); + return; + } + + if (sendingStatus === 'submitted') { + let cancelled = false; + void queryClient.invalidateQueries({ queryKey: [postsDataType] }).then(() => { + if (!cancelled) { + setRefreshedSubmittedEmailId(emailId); + } + }); + + return () => { + cancelled = true; + }; + } + }, [emailId, queryClient, sendingStatus]); + + if (query.isUnsupported) { + return <>{children(SETTLED_POST_LIST_ROW_EMAIL_STATUS)}; + } + + if (sending?.status === 'failed') { + return <>{children({ status: 'failed' })}; + } + + const isRefreshingSubmittedData = Boolean( + emailId && sending?.status === 'submitted' && refreshedSubmittedEmailId !== emailId, + ); + + if (sending && (sending.status !== 'submitted' || isRefreshingSubmittedData)) { + return ( + <> + {children({ + status: 'sending', + copy: getEmailSendingProgressCopy(sending, estimate), + })} + + ); + } + + if (!sending && (emailStatus === 'pending' || emailStatus === 'submitting')) { + return ( + <> + {children({ + status: 'sending', + copy: { + title: emailStatus === 'pending' ? 'Preparing emails' : 'Sending emails', + detail: null, + }, + })} + + ); + } + + return <>{children(SETTLED_POST_LIST_ROW_EMAIL_STATUS)}; +} diff --git a/apps/admin/src/posts/list/components/post-list-row.tsx b/apps/admin/src/posts/list/components/post-list-row.tsx index 75e6c65e8de1..9bb6ba04da24 100644 --- a/apps/admin/src/posts/list/components/post-list-row.tsx +++ b/apps/admin/src/posts/list/components/post-list-row.tsx @@ -13,6 +13,12 @@ import { } 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 { PostListRowEmailStatus } from '@/posts/list/components/post-list-row-email-status'; +import { + hasInProgressEmail, + SETTLED_POST_LIST_ROW_EMAIL_STATUS, + type PostListRowEmailStatusState, +} from '@/posts/list/components/post-list-row-email-status-state'; import { forwardRef, memo, useState } from 'react'; import type { ComponentPropsWithoutRef, MouseEvent as ReactMouseEvent } from 'react'; import type { PostListItem } from '@/posts/list/hooks/use-posts-list'; @@ -51,6 +57,11 @@ interface PostListRowProps extends Omit, 'onClick metricsSettings: PostMetricsSettings; visitorCounts?: Record; memberCounts?: Record; + improveSendingUI?: boolean; +} + +interface PostListRowComponentProps extends PostListRowProps { + emailSendingState: PostListRowEmailStatusState; } /** @@ -108,7 +119,7 @@ function FeatureImage({ post }: { post: PostListItem }) { return ; } -const PostListRowComponent = forwardRef( +const PostListRowComponent = forwardRef( function PostListRowComponent( { post, @@ -128,6 +139,8 @@ const PostListRowComponent = forwardRef( metricsSettings, visitorCounts, memberCounts, + improveSendingUI: _improveSendingUI, + emailSendingState, // Everything else lands on the
  • : the context menu wraps each row with // `asChild`, so Radix hands its trigger props and ref straight through. ...rest @@ -141,6 +154,15 @@ const PostListRowComponent = forwardRef( const statusLabel = getPostStatusLabel(post, resource); const statusDetail = getPostStatusDetail(post, { timezone, resource }); const isFailed = didPostEmailFail(post, resource); + const displayedPost = + emailSendingState.status === 'failed' + ? ({ ...post, email: { ...post.email, status: 'failed' } } as PostListItem) + : post; + const displayedIsFailed = emailSendingState.status === 'failed' || isFailed; + const displayedStatusLabel = + emailSendingState.status === 'failed' + ? getPostStatusLabel(displayedPost, resource) + : statusLabel; // Strictly `published`, matching Ember's `isPublished`. An email-only // `sent` post still opens in the editor for a contributor. @@ -243,18 +265,30 @@ const PostListRowComponent = forwardRef( )} - - {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}} - + {emailSendingState.status === 'sending' ? ( + + {emailSendingState.copy.title} + {emailSendingState.copy.detail && ( + {` · ${emailSendingState.copy.detail}`} + )} + + ) : ( + + {displayedStatusLabel} + {/* 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. */} + {emailSendingState.status !== 'failed' && isHovered && statusDetail && ( + {statusDetail} + )} + + )} ( }, ); +const PostListRowWithEmailStatus = forwardRef( + function PostListRowWithEmailStatus(props, ref) { + if (hasInProgressEmail(props.post, props.resource, props.improveSendingUI ?? false)) { + return ( + + {(emailSendingState) => ( + + )} + + ); + } + + return ( + + ); + }, +); + /** * 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 @@ -321,4 +377,4 @@ const PostListRowComponent = forwardRef( * Every prop is either a primitive or memoised upstream; `metricsSettings` in * particular is built with `useMemo` for this reason. */ -export const PostListRow = memo(PostListRowComponent); +export const PostListRow = memo(PostListRowWithEmailStatus); diff --git a/apps/admin/src/posts/list/components/post-metrics-cells.tsx b/apps/admin/src/posts/list/components/post-metrics-cells.tsx index 96d3462daa3d..07595ba5f3a8 100644 --- a/apps/admin/src/posts/list/components/post-metrics-cells.tsx +++ b/apps/admin/src/posts/list/components/post-metrics-cells.tsx @@ -28,6 +28,7 @@ interface PostMetricsCellsProps { memberCounts?: Record; paidMembersEnabled?: boolean; className?: string; + hideEmailMetrics?: boolean; } const EMAIL_KEYS: PostMetricKey[] = ['opens', 'clicks', 'sent']; @@ -74,8 +75,11 @@ export function PostMetricsCells({ memberCounts, paidMembersEnabled, className, + hideEmailMetrics, }: PostMetricsCellsProps) { - const columns = getPostMetricColumns(post, settings, resource); + const columns = getPostMetricColumns(post, settings, resource).filter( + (column) => !(hideEmailMetrics && EMAIL_KEYS.includes(column.key)), + ); const shown = new Set(columns.map((column) => column.key)); const members = memberCounts?.[post.id]; 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 index 32e1af825c54..2ad1426742a7 100644 --- a/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx +++ b/apps/admin/src/posts/list/posts-list-rows.acceptance.test.tsx @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it } from 'vitest'; +import { focusManager } from '@tanstack/react-query'; import { currentRoute, @@ -16,6 +17,17 @@ import { import { postsListScreen } from './posts-list.screen'; const FLAG_ON = { labs: { postsListReact: true } }; +const SENDING_FLAG_ON = { labs: { postsListReact: true, improveSendingUI: true } }; +const EMAIL_ID = '64d623b64676110001e897ab'; + +const emailMetricsSettings = settingsResponse({ + settings: { + email_track_opens: true, + email_track_clicks: true, + web_analytics_enabled: true, + members_signup_access: 'all', + }, +}); /** * What a row says, and the two empty states — the parity-critical surface of @@ -175,6 +187,310 @@ describe('Posts list rows', () => { }); }); +describe('Posts list email sending status', () => { + beforeEach(() => { + fakePostsListScreen(); + }); + + it('shows preparing progress and hides only email metrics', async () => { + fakePosts([ + post({ + id: 'preparing-post', + title: 'Preparing post', + status: 'published', + email: { + id: EMAIL_ID, + status: 'pending', + email_count: 1000, + opened_count: 0, + track_opens: true, + track_clicks: true, + }, + count: { clicks: 0, positive_feedback: 0, negative_feedback: 0 }, + }), + ]); + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, { + email_statuses: [ + { + id: EMAIL_ID, + sending: { + status: 'preparing', + progress: { completed: 250, total: 1000, estimated_seconds_remaining: 30 }, + }, + }, + ], + }); + + await renderAdminApp('/posts?type=published', { + ...SENDING_FLAG_ON, + boot: { browseSettings: { response: emailMetricsSettings } }, + }); + + const row = postsListScreen.listItems().first(); + await expect.element(row).toHaveTextContent('Preparing emails · 250 of 1,000'); + await expect.element(row).not.toHaveTextContent('minute'); + await expect.element(row).not.toHaveTextContent('Published and sent'); + await expect.element(row.getByLabelText(/Visitors/)).toBeVisible(); + await expect.element(row.getByLabelText(/Sent/)).not.toBeInTheDocument(); + await expect.element(row.getByLabelText(/Opens/)).not.toBeInTheDocument(); + await expect.element(row.getByLabelText(/Clicks/)).not.toBeInTheDocument(); + await expect.element(row.getByRole('status')).not.toBeInTheDocument(); + }); + + it('shows coarse sending copy through a transient error and recovers on focus', async () => { + fakePosts([ + post({ + id: 'sending-post', + title: 'Sending post', + status: 'published', + email: { id: EMAIL_ID, status: 'submitting', email_count: 1000, opened_count: 0 }, + }), + ]); + const failedStatusApi = fakeAdminEndpoint( + 'GET', + `/emails/${EMAIL_ID}/status/`, + { errors: [{ message: 'Bad gateway' }] }, + { status: 502 }, + ); + + await renderAdminApp('/posts?type=published', SENDING_FLAG_ON); + + const row = postsListScreen.listItems().first(); + await expect.element(row).toHaveTextContent('Sending emails'); + await expect.element(row).not.toHaveTextContent('Published and sent'); + await expect.poll(() => failedStatusApi.requests.length).toBe(1); + + const recoveredStatusApi = fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, { + email_statuses: [ + { + id: EMAIL_ID, + sending: { + status: 'submitting', + progress: { completed: 500, total: 1000, estimated_seconds_remaining: null }, + }, + }, + ], + }); + focusManager.setFocused(false); + focusManager.setFocused(true); + + await expect.poll(() => recoveredStatusApi.requests.length).toBeGreaterThan(0); + await expect.element(row).toHaveTextContent('Sending emails · 500 of 1,000'); + focusManager.setFocused(undefined); + }); + + it('refreshes the post and restores settled copy and metrics after sending', async () => { + const sendingPost = post({ + id: 'completed-post', + title: 'Completed post', + status: 'published', + email: { + id: EMAIL_ID, + status: 'submitting', + email_count: 0, + opened_count: 0, + track_opens: true, + track_clicks: true, + }, + count: { clicks: 0, positive_feedback: 0, negative_feedback: 0 }, + }); + const submittedPost = post({ + ...sendingPost, + email: { + ...sendingPost.email!, + status: 'submitted', + email_count: 1000, + opened_count: 400, + }, + count: { clicks: 60, positive_feedback: 0, negative_feedback: 0 }, + }); + let sendingComplete = false; + const postsApi = fakePosts(() => [sendingComplete ? submittedPost : sendingPost]); + let statusRequestCount = 0; + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, () => { + statusRequestCount += 1; + return { + email_statuses: [ + { + id: EMAIL_ID, + sending: sendingComplete + ? { + status: 'submitted', + progress: { completed: 1000, total: 1000, estimated_seconds_remaining: 0 }, + } + : { + status: 'submitting', + progress: { completed: 500, total: 1000, estimated_seconds_remaining: null }, + }, + }, + ], + }; + }); + + await renderAdminApp('/posts?type=published', { + ...SENDING_FLAG_ON, + boot: { browseSettings: { response: emailMetricsSettings } }, + }); + + const row = postsListScreen.listItems().first(); + await expect.element(row).toHaveTextContent('Sending emails · 500 of 1,000'); + await expect.element(row.getByLabelText(/Sent/)).not.toBeInTheDocument(); + + sendingComplete = true; + const pendingStatusRequestCount = statusRequestCount; + await expect + .poll(() => statusRequestCount, { timeout: 3500 }) + .toBeGreaterThan(pendingStatusRequestCount); + await expect.poll(() => postsApi.requests.length).toBeGreaterThan(1); + await expect.element(row).toHaveTextContent('Published and sent'); + await expect.element(row).not.toHaveTextContent('Sending emails'); + await expect.element(row.getByLabelText(/Opens/)).toHaveTextContent('40%'); + await expect.element(row.getByLabelText(/Clicks/)).toHaveTextContent('6%'); + }); + + it('uses the existing failure state when polling reports a failure', async () => { + const sendingPost = post({ + id: 'failed-post', + title: 'Failed post', + status: 'published', + email: { id: EMAIL_ID, status: 'submitting', email_count: 0, opened_count: 0 }, + }); + let sendingFailed = false; + const postsApi = fakePosts(() => [ + sendingFailed + ? post({ + ...sendingPost, + email: { ...sendingPost.email!, status: 'failed', error: 'The send failed.' }, + }) + : sendingPost, + ]); + let statusRequestCount = 0; + fakeAdminEndpoint('GET', `/emails/${EMAIL_ID}/status/`, () => { + statusRequestCount += 1; + return { + email_statuses: [ + { + id: EMAIL_ID, + sending: sendingFailed + ? { + status: 'failed', + failed_during: 'submitting', + progress: { completed: 250, total: 1000, estimated_seconds_remaining: null }, + } + : { + status: 'submitting', + progress: { completed: 250, total: 1000, estimated_seconds_remaining: null }, + }, + }, + ], + }; + }); + + await renderAdminApp('/posts?type=published', SENDING_FLAG_ON); + const row = postsListScreen.listItems().first(); + await expect.element(row).toHaveTextContent('Sending emails'); + + sendingFailed = true; + const pendingStatusRequestCount = statusRequestCount; + await expect + .poll(() => statusRequestCount, { timeout: 3500 }) + .toBeGreaterThan(pendingStatusRequestCount); + await expect.element(row).toHaveTextContent('Published but failed to send newsletter'); + await expect.element(row).not.toHaveTextContent('Emails failed to send'); + await expect.poll(() => postsApi.requests.length).toBeGreaterThan(1); + }); + + it('falls back to the existing row when the status endpoint is unavailable', async () => { + fakePosts([ + post({ + id: 'unsupported-post', + title: 'Unsupported post', + status: 'published', + email: { id: EMAIL_ID, status: 'submitting', email_count: 1000, opened_count: 0 }, + }), + ]); + const statusApi = fakeAdminEndpoint( + 'GET', + `/emails/${EMAIL_ID}/status/`, + { errors: [{ message: 'Resource not found' }] }, + { status: 404 }, + ); + + const app = await renderAdminApp('/posts?type=published', SENDING_FLAG_ON); + + const row = postsListScreen.listItems().first(); + await expect.element(row).toHaveTextContent('Published and sent'); + await expect.element(row).not.toHaveTextContent('Sending emails'); + await expect.poll(() => statusApi.requests.length).toBe(1); + await app.unmount(); + }); + + it('polls every concurrently active email independently', async () => { + const firstEmailId = '64d623b64676110001e897a1'; + const secondEmailId = '64d623b64676110001e897a2'; + fakePosts([ + post({ + id: 'first-active-post', + title: 'First active post', + status: 'published', + email: { id: firstEmailId, status: 'submitting', email_count: 1000, opened_count: 0 }, + }), + post({ + id: 'second-active-post', + title: 'Second active post', + status: 'sent', + email_only: true, + email: { id: secondEmailId, status: 'pending', email_count: 2000, opened_count: 0 }, + }), + ]); + const firstStatusApi = fakeAdminEndpoint('GET', `/emails/${firstEmailId}/status/`, { + email_statuses: [ + { + id: firstEmailId, + sending: { + status: 'submitting', + progress: { completed: 100, total: 1000, estimated_seconds_remaining: null }, + }, + }, + ], + }); + const secondStatusApi = fakeAdminEndpoint('GET', `/emails/${secondEmailId}/status/`, { + email_statuses: [ + { + id: secondEmailId, + sending: { + status: 'preparing', + progress: { completed: 200, total: 2000, estimated_seconds_remaining: null }, + }, + }, + ], + }); + + await renderAdminApp('/posts?type=published', SENDING_FLAG_ON); + + await expect.element(postsListScreen.listItems().nth(0)).toHaveTextContent('100 of 1,000'); + await expect.element(postsListScreen.listItems().nth(1)).toHaveTextContent('200 of 2,000'); + await expect.poll(() => firstStatusApi.requests.length).toBeGreaterThan(0); + await expect.poll(() => secondStatusApi.requests.length).toBeGreaterThan(0); + }); + + it('does not request status when the sending UI flag is off', async () => { + fakePosts([ + post({ + title: 'Flagged off post', + status: 'published', + email: { id: EMAIL_ID, status: 'submitting', email_count: 1000, opened_count: 0 }, + }), + ]); + + await renderAdminApp('/posts?type=published', FLAG_ON); + + const row = postsListScreen.listItems().first(); + await expect.element(row).toHaveTextContent('Published and sent'); + await expect.element(row).not.toHaveTextContent('Sending emails'); + }); +}); + 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. diff --git a/apps/admin/src/posts/list/posts-list-screen.tsx b/apps/admin/src/posts/list/posts-list-screen.tsx index 17b62bf06ffa..6117dc7858a6 100644 --- a/apps/admin/src/posts/list/posts-list-screen.tsx +++ b/apps/admin/src/posts/list/posts-list-screen.tsx @@ -46,6 +46,7 @@ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } fro import { useLocation } from '@tryghost/admin-x-framework'; import { usePostAnalyticsCounts } from './hooks/use-post-analytics-counts'; import { usePostsList } from './hooks/use-posts-list'; +import { useFeatureFlag } from '@tryghost/admin-x-framework/hooks'; /** * The React posts and pages list screens, served behind the `postsListReact` @@ -63,6 +64,7 @@ export function PostsListScreen({ resource }: { resource: PostResource }) { usePostsFilterState(); const { data: currentUser } = useCurrentUser(); const { data: settingsData } = useBrowseSettings(); + const improveSendingUI = useFeatureFlag('improveSendingUI'); // Report the current filters so the sidebar's Posts link can return here. const location = useLocation(); @@ -376,6 +378,7 @@ export function PostsListScreen({ resource }: { resource: PostResource }) { key={item.id} getMenuItems={getMenuItems} hasAdminAccess={isAdmin} + improveSendingUI={improveSendingUI} isContributor={isContributor} isSelected={selection.isSelected(item.id)} memberCounts={memberCounts} diff --git a/apps/admin/src/shared/images/image-upload.ts b/apps/admin/src/shared/images/image-upload.ts index 96504588cb82..b28978705577 100644 --- a/apps/admin/src/shared/images/image-upload.ts +++ b/apps/admin/src/shared/images/image-upload.ts @@ -11,7 +11,7 @@ export const ACCEPTED_IMAGE_TYPES = { 'image/png': ['.png'], 'image/svg+xml': ['.svg', '.svgz'], 'image/webp': ['.webp'], -}; +} as const; export const UNSUPPORTED_IMAGE_MESSAGE = 'The image type you uploaded is not supported. Please use .GIF, .JPG, .JPEG, .PNG, .SVG, .SVGZ, .WEBP'; diff --git a/apps/admin/test-utils/acceptance/editor.ts b/apps/admin/test-utils/acceptance/editor.ts index 396378d4569a..1a13587cf5a8 100644 --- a/apps/admin/test-utils/acceptance/editor.ts +++ b/apps/admin/test-utils/acceptance/editor.ts @@ -1,6 +1,7 @@ -import { buildLexicalParagraph, post, type Post } from '@tryghost/test-data'; +import { buildLexicalParagraph, post, settingsResponse, type Post } from '@tryghost/test-data'; +import type { RenderAdminAppOptions } from './render-admin-app'; import { fakeMembers, fakeNewsletters, fakePosts, fakeSnippets } from './resources'; -import { fakeAdminEndpoint, type EndpointCapture } from './worker'; +import { fakeAdminEndpoint, fakeEndpoint, type EndpointCapture } from './worker'; /** Supporting reads shared by the editor's header and card configuration. */ export function fakeEditorChrome(): void { @@ -44,3 +45,40 @@ export function submittedPost(capture: EndpointCapture, index = -1): Record[] } | undefined; return body?.posts[0] ?? {}; } + +const UNSPLASH_REGULAR = 'https://images.unsplash.com/photo-1?ixid=1&w=1080'; +// The picker asks Unsplash for a wider rendition of the image it inserts. +export const UNSPLASH_PICKED = 'https://images.unsplash.com/photo-1?ixid=1&w=2000'; + +/** One Unsplash photo, in the shape the search modal lays out and inserts. */ +export function fakeUnsplashPhotos(): void { + fakeEndpoint('GET', 'https://api.unsplash.com/photos', [ + { + id: 'photo-1', + color: '#123456', + alt_description: 'A hillside', + height: 800, + width: 1200, + likes: 12, + urls: { regular: UNSPLASH_REGULAR }, + links: { + html: 'https://unsplash.com/photos/photo-1', + download: 'https://unsplash.com/photos/photo-1/download', + download_location: 'https://api.unsplash.com/photos/photo-1/download', + }, + user: { + name: 'A Photographer', + links: { html: 'https://unsplash.com/@photographer' }, + profile_image: { medium: 'https://images.unsplash.com/profile-1' }, + }, + }, + ]); + fakeEndpoint('GET', 'https://api.unsplash.com/photos/photo-1/download', {}); +} + +/** The site fixture turns Unsplash on, so only the off case needs an override. */ +export function withoutUnsplash(): RenderAdminAppOptions { + return { + boot: { browseSettings: { response: settingsResponse({ settings: { unsplash: false } }) } }, + }; +} diff --git a/apps/admin/test-utils/acceptance/index.ts b/apps/admin/test-utils/acceptance/index.ts index b071efd2d204..6aff384bc99a 100644 --- a/apps/admin/test-utils/acceptance/index.ts +++ b/apps/admin/test-utils/acceptance/index.ts @@ -1,6 +1,13 @@ /** Acceptance-harness public surface — see README.md for the spec anatomy. */ export { fakeAnalyticsOverview } from './analytics'; -export { fakeEditorChrome, fakeEditorPost, submittedPost } from './editor'; +export { + UNSPLASH_PICKED, + fakeEditorChrome, + fakeEditorPost, + fakeUnsplashPhotos, + submittedPost, + withoutUnsplash, +} from './editor'; export { currentRoute, renderAdminApp } from './render-admin-app'; export type { RenderAdminAppOptions } from './render-admin-app'; export { diff --git a/apps/admin/tsconfig.app.json b/apps/admin/tsconfig.app.json index 976d11533634..1531adba2436 100644 --- a/apps/admin/tsconfig.app.json +++ b/apps/admin/tsconfig.app.json @@ -10,6 +10,7 @@ /* Bundler mode */ "moduleResolution": "bundler", + "resolveJsonModule": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", diff --git a/apps/ember-admin/app/components/posts/debug.hbs b/apps/ember-admin/app/components/posts/debug.hbs index 71572cf33de4..c9cae667e2dd 100644 --- a/apps/ember-admin/app/components/posts/debug.hbs +++ b/apps/ember-admin/app/components/posts/debug.hbs @@ -411,11 +411,11 @@ {{else}} {{#if this.showCustomSchedule}} - + - + diff --git a/apps/ember-admin/app/components/posts/debug.js b/apps/ember-admin/app/components/posts/debug.js index 436d59b98015..31c4803d81b1 100644 --- a/apps/ember-admin/app/components/posts/debug.js +++ b/apps/ember-admin/app/components/posts/debug.js @@ -60,8 +60,8 @@ export default class Debug extends Component { statusClass: this.email?.status, status: this.getStatusLabel(this.email?.status), recipientFilter: this.email?.recipientFilter, - createdAt: this.email?.createdAtUTC ? moment(this.email.createdAtUTC).format('DD MMM, YYYY, HH:mm:ss') : '', - submittedAt: this.email?.submittedAtUTC ? moment(this.email.submittedAtUTC).format('DD MMM, YYYY, HH:mm:ss') : '', + createdAt: this.email?.createdAtUTC ? moment.utc(this.email.createdAtUTC).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', + submittedAt: this.email?.submittedAtUTC ? moment.utc(this.email.submittedAtUTC).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', emailsSent: this.email?.emailCount, emailsDelivered: this.email?.deliveredCount, emailsOpened: this.email?.openedCount, @@ -88,7 +88,7 @@ export default class Debug extends Component { id: batch.id, status: this.getStatusLabel(batch.status), statusClass: batch.status, - createdAt: batch.created_at ? moment(batch.created_at).format('DD MMM, YYYY, HH:mm:ss') : '', + createdAt: batch.created_at ? moment.utc(batch.created_at).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', segment: batch.member_segment || '', mailgunMessageId: batch.mailgun_message_id || null, errorMessage: batch.error_message || '', @@ -105,8 +105,8 @@ export default class Debug extends Component { return { id: failure.id, code: failure.code, - failedAt: failure.failed_at ? moment(failure.failed_at).format('DD MMM, YYYY, HH:mm:ss') : '', - processedAt: failure.email_recipient.processed_at ? moment(failure.email_recipient.processed_at).format('DD MMM, YYYY, HH:mm:ss') : '', + failedAt: failure.failed_at ? moment.utc(failure.failed_at).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', + processedAt: failure.email_recipient.processed_at ? moment.utc(failure.email_recipient.processed_at).format('DD MMM, YYYY, HH:mm:ss [UTC]') : '', batchId: failure.email_recipient.batch_id, enhancedCode: failure.enhanced_code, message: failure.message, @@ -277,7 +277,7 @@ export default class Debug extends Component { let object = result[type]; for (const key of ['lastStarted', 'lastBegin', 'lastEventTimestamp']) { if (object[key]) { - object[key] = moment(object[key]).format('DD MMM, YYYY, HH:mm:ss.SSS'); + object[key] = moment.utc(object[key]).format('DD MMM, YYYY, HH:mm:ss.SSS [UTC]'); } else { object[key] = 'N/A'; } @@ -287,7 +287,7 @@ export default class Debug extends Component { object = object.schedule; for (const key of ['begin', 'end']) { if (object[key]) { - object[key] = moment(object[key]).format('DD MMM, YYYY, HH:mm:ss.SSS'); + object[key] = moment.utc(object[key]).format('DD MMM, YYYY, HH:mm:ss.SSS [UTC]'); } else { object[key] = 'N/A'; } @@ -300,9 +300,9 @@ export default class Debug extends Component { toggleCustomSchedule() { this.showCustomSchedule = !this.showCustomSchedule; if (this.showCustomSchedule) { - this.customBeginDate = moment(this.email?.createdAtUTC).format('YYYY-MM-DDTHH:mm'); - const createdAt = moment(this.email?.createdAtUTC); - const maxEnd = moment.min(moment().subtract(1, 'hour'), createdAt.clone().add(7, 'days')); + this.customBeginDate = moment.utc(this.email?.createdAtUTC).format('YYYY-MM-DDTHH:mm'); + const createdAt = moment.utc(this.email?.createdAtUTC); + const maxEnd = moment.min(moment.utc().subtract(1, 'hour'), createdAt.clone().add(7, 'days')); this.customEndDate = maxEnd.format('YYYY-MM-DDTHH:mm'); } else { this.customBeginDate = null; @@ -339,10 +339,10 @@ export default class Debug extends Component { *_scheduleAnalytics() { const url = new URL(this.ghostPaths.url.api(`/emails/${this.post.email.id}/analytics`), window.location.origin); if (this.customBeginDate) { - url.searchParams.set('begin', new Date(this.customBeginDate).toISOString()); + url.searchParams.set('begin', moment.utc(this.customBeginDate).toISOString()); } if (this.customEndDate) { - url.searchParams.set('end', new Date(this.customEndDate).toISOString()); + url.searchParams.set('end', moment.utc(this.customEndDate).toISOString()); } yield this.ajax.put(url.pathname + url.search, {}); yield this.fetchAnalyticsStatus(); diff --git a/apps/ember-admin/package.json b/apps/ember-admin/package.json index 2cd8133cfe0c..f46aedd24374 100644 --- a/apps/ember-admin/package.json +++ b/apps/ember-admin/package.json @@ -1,6 +1,6 @@ { "name": "ghost-admin", - "version": "6.63.1-rc.0", + "version": "6.64.0-rc.0", "description": "Ember.js admin client for Ghost", "author": "Ghost Foundation", "homepage": "http://ghost.org", diff --git a/ghost/core/core/server/data/exporter/table-lists.js b/ghost/core/core/server/data/exporter/table-lists.js index 21466ed1514c..e3aadf3b92a2 100644 --- a/ghost/core/core/server/data/exporter/table-lists.js +++ b/ghost/core/core/server/data/exporter/table-lists.js @@ -76,6 +76,7 @@ const BACKUP_TABLES = [ 'automation_runs', 'welcome_email_automation_runs', 'welcome_email_automated_emails', + 'tinybird_syncs', ]; // NOTE: exposing only tables which are going to be included in a "default" export file diff --git a/ghost/core/core/server/data/migrations/versions/6.64/2026-09-09-13-56-18-add-automation-run-updated-at-indexes.js b/ghost/core/core/server/data/migrations/versions/6.64/2026-09-09-13-56-18-add-automation-run-updated-at-indexes.js new file mode 100644 index 000000000000..999e1b042ca0 --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.64/2026-09-09-13-56-18-add-automation-run-updated-at-indexes.js @@ -0,0 +1,6 @@ +const { combineTransactionalMigrations, createAddIndexMigration } = require('../../utils'); + +module.exports = combineTransactionalMigrations( + createAddIndexMigration('automation_runs', ['updated_at']), + createAddIndexMigration('automation_run_steps', ['updated_at']), +); diff --git a/ghost/core/core/server/data/migrations/versions/6.64/2026-09-09-13-56-18-add-tinybird-syncs-table.js b/ghost/core/core/server/data/migrations/versions/6.64/2026-09-09-13-56-18-add-tinybird-syncs-table.js new file mode 100644 index 000000000000..7595d62532dc --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.64/2026-09-09-13-56-18-add-tinybird-syncs-table.js @@ -0,0 +1,10 @@ +const { addTable } = require('../../utils'); + +module.exports = addTable('tinybird_syncs', { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + table_name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + last_synced_updated_at: { type: 'dateTime', nullable: false }, + last_synced_id: { type: 'string', maxlength: 24, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, +}); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index cabfcc2e27cb..4242135c08bb 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -2270,7 +2270,7 @@ module.exports = { nullable: false, validations: { isEmail: true }, }, - '@@INDEXES@@': [['automation_id', 'created_at']], + '@@INDEXES@@': [['automation_id', 'created_at'], ['updated_at']], }, automation_run_steps: { id: { type: 'string', maxlength: 24, nullable: false, primary: true }, @@ -2314,7 +2314,7 @@ module.exports = { }, locked_by: { type: 'string', maxlength: 191, nullable: true }, locked_at: { type: 'dateTime', nullable: true }, - '@@INDEXES@@': [['status', 'ready_at', 'created_at', 'id']], + '@@INDEXES@@': [['status', 'ready_at', 'created_at', 'id'], ['updated_at']], }, welcome_email_automated_emails: { id: { type: 'string', maxlength: 24, nullable: false, primary: true }, @@ -2577,4 +2577,12 @@ module.exports = { { columns: ['email_provider_message_id'], length: 31 }, ], }, + tinybird_syncs: { + id: { type: 'string', maxlength: 24, nullable: false, primary: true }, + table_name: { type: 'string', maxlength: 191, nullable: false, unique: true }, + last_synced_updated_at: { type: 'dateTime', nullable: false }, + last_synced_id: { type: 'string', maxlength: 24, nullable: false }, + created_at: { type: 'dateTime', nullable: false }, + updated_at: { type: 'dateTime', nullable: true }, + }, }; diff --git a/ghost/core/core/server/data/tinybird/datasources/_mv_automation_run_steps.datasource b/ghost/core/core/server/data/tinybird/datasources/_mv_automation_run_steps.datasource new file mode 100644 index 000000000000..f393cc6140fd --- /dev/null +++ b/ghost/core/core/server/data/tinybird/datasources/_mv_automation_run_steps.datasource @@ -0,0 +1,10 @@ +SCHEMA > + `site_uuid` UUID, + `id` String, + `automation_run_id` String, + `status` LowCardinality(String), + `updated_at` DateTime64(3) + +ENGINE ReplacingMergeTree +ENGINE_SORTING_KEY site_uuid, id +ENGINE_VER updated_at diff --git a/ghost/core/core/server/data/tinybird/datasources/_mv_automation_runs.datasource b/ghost/core/core/server/data/tinybird/datasources/_mv_automation_runs.datasource new file mode 100644 index 000000000000..265244e7aaa9 --- /dev/null +++ b/ghost/core/core/server/data/tinybird/datasources/_mv_automation_runs.datasource @@ -0,0 +1,10 @@ +SCHEMA > + `site_uuid` UUID, + `id` String, + `automation_id` String, + `created_at` DateTime64(3), + `updated_at` DateTime64(3) + +ENGINE ReplacingMergeTree +ENGINE_SORTING_KEY site_uuid, id +ENGINE_VER updated_at diff --git a/ghost/core/core/server/data/tinybird/datasources/_mv_pending_automation_runs.datasource b/ghost/core/core/server/data/tinybird/datasources/_mv_pending_automation_runs.datasource new file mode 100644 index 000000000000..09a6917d0df6 --- /dev/null +++ b/ghost/core/core/server/data/tinybird/datasources/_mv_pending_automation_runs.datasource @@ -0,0 +1,10 @@ +SCHEMA > + `site_uuid` UUID, + `id` String, + `automation_run_id` String, + `is_pending` UInt8, + `updated_at` DateTime64(3) + +ENGINE ReplacingMergeTree +ENGINE_SORTING_KEY site_uuid, id +ENGINE_VER updated_at diff --git a/ghost/core/core/server/data/tinybird/datasources/automation_run_events.datasource b/ghost/core/core/server/data/tinybird/datasources/automation_run_events.datasource new file mode 100644 index 000000000000..6bbe98e578df --- /dev/null +++ b/ghost/core/core/server/data/tinybird/datasources/automation_run_events.datasource @@ -0,0 +1,12 @@ +TOKEN "tracker" APPEND +TOKEN "analytics-service" APPEND + +SCHEMA > + `site_uuid` UUID `json:$.site_uuid`, + `id` String `json:$.id`, + `updated_at` DateTime64(3) `json:$.updated_at`, + `inserted_at` DateTime64(3) `json:$.inserted_at` DEFAULT now64(), + `payload` String `json:$.payload` + +ENGINE MergeTree +ENGINE_SORTING_KEY site_uuid, id diff --git a/ghost/core/core/server/data/tinybird/datasources/automation_run_step_events.datasource b/ghost/core/core/server/data/tinybird/datasources/automation_run_step_events.datasource new file mode 100644 index 000000000000..6bbe98e578df --- /dev/null +++ b/ghost/core/core/server/data/tinybird/datasources/automation_run_step_events.datasource @@ -0,0 +1,12 @@ +TOKEN "tracker" APPEND +TOKEN "analytics-service" APPEND + +SCHEMA > + `site_uuid` UUID `json:$.site_uuid`, + `id` String `json:$.id`, + `updated_at` DateTime64(3) `json:$.updated_at`, + `inserted_at` DateTime64(3) `json:$.inserted_at` DEFAULT now64(), + `payload` String `json:$.payload` + +ENGINE MergeTree +ENGINE_SORTING_KEY site_uuid, id diff --git a/ghost/core/core/server/data/tinybird/endpoints/api_automation_browse_stats.pipe b/ghost/core/core/server/data/tinybird/endpoints/api_automation_browse_stats.pipe new file mode 100644 index 000000000000..d2a0efe228f1 --- /dev/null +++ b/ghost/core/core/server/data/tinybird/endpoints/api_automation_browse_stats.pipe @@ -0,0 +1,43 @@ +NODE automation_browse_stats +SQL > + % + SELECT + runs.automation_id AS automation_id, + formatDateTimeInJodaSyntax( + max(runs.created_at), + 'yyyy-MM-dd''T''HH:mm:ss.SSS''Z''', + 'UTC' + ) AS last_run_created_at, + count() AS total_run_count, + countIf( + runs.id IN ( + SELECT automation_run_id + FROM _mv_pending_automation_runs FINAL + WHERE + site_uuid + = {{ + String( + site_uuid, + '00000000-0000-0000-0000-000000000000', + description="Site UUID", + required=True, + ) + }} + AND is_pending = 1 + ) + ) AS in_progress_run_count + FROM _mv_automation_runs AS runs FINAL + WHERE + runs.site_uuid + = {{ + String( + site_uuid, + '00000000-0000-0000-0000-000000000000', + description="Site UUID", + required=True, + ) + }} + GROUP BY runs.automation_id + ORDER BY runs.automation_id + +TYPE ENDPOINT diff --git a/ghost/core/core/server/data/tinybird/fixtures/automation_run_events.ndjson b/ghost/core/core/server/data/tinybird/fixtures/automation_run_events.ndjson new file mode 100644 index 000000000000..34ef4a08d741 --- /dev/null +++ b/ghost/core/core/server/data/tinybird/fixtures/automation_run_events.ndjson @@ -0,0 +1,7 @@ +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-0","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-0","automation_id":"automation-1","created_at":"2020-01-01T01:00:00.000Z","updated_at":"2020-01-01T01:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-1","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-1","automation_id":"automation-1","created_at":"2020-01-01T01:00:00.000Z","updated_at":"2020-01-01T01:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-2","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-2","automation_id":"automation-1","created_at":"2020-01-01T01:00:00.000Z","updated_at":"2020-01-01T01:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-3","updated_at":"2020-02-01T01:00:00.000Z","inserted_at":"2020-02-01T02:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-3","automation_id":"automation-1","created_at":"2020-02-01T01:00:00.123Z","updated_at":"2020-02-01T01:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-3","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-02-01T03:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-3","automation_id":"automation-1","created_at":"2029-12-31T01:00:00.000Z","updated_at":"2020-01-01T01:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-4","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"run-4","automation_id":"automation-2","created_at":"2020-01-01T01:00:00.000Z","updated_at":"2020-01-01T01:00:00.000Z"}} +{"site_uuid":"6cce57c1-c0df-4771-bb86-53cbf494f90f","id":"run-1","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"6cce57c1-c0df-4771-bb86-53cbf494f90f","id":"run-1","automation_id":"automation-1","created_at":"2020-01-01T01:00:00.000Z","updated_at":"2020-01-01T01:00:00.000Z"}} diff --git a/ghost/core/core/server/data/tinybird/fixtures/automation_run_step_events.ndjson b/ghost/core/core/server/data/tinybird/fixtures/automation_run_step_events.ndjson new file mode 100644 index 000000000000..318fe158fcd3 --- /dev/null +++ b/ghost/core/core/server/data/tinybird/fixtures/automation_run_step_events.ndjson @@ -0,0 +1,8 @@ +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-1","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-1","automation_run_id":"run-1","status":"pending","updated_at":"2020-01-01T01:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-1","updated_at":"2020-01-01T03:00:00.000Z","inserted_at":"2020-01-01T04:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-1","automation_run_id":"run-1","status":"finished","updated_at":"2020-01-01T03:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-2","updated_at":"2020-01-01T05:00:00.000Z","inserted_at":"2020-01-01T06:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-2","automation_run_id":"run-1","status":"pending","updated_at":"2020-01-01T05:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-2","updated_at":"2020-01-01T07:00:00.000Z","inserted_at":"2020-01-01T08:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-2","automation_run_id":"run-1","status":"pending","updated_at":"2020-01-01T07:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-3","updated_at":"2020-01-01T01:00:00.000Z","inserted_at":"2020-01-01T02:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-3","automation_run_id":"run-2","status":"pending","updated_at":"2020-01-01T01:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-3","updated_at":"2020-01-01T03:00:00.000Z","inserted_at":"2020-01-01T04:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-3","automation_run_id":"run-2","status":"finished","updated_at":"2020-01-01T03:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-4","updated_at":"2020-01-01T05:00:00.000Z","inserted_at":"2020-01-01T06:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-4","automation_run_id":"run-2","status":"pending","updated_at":"2020-01-01T05:00:00.000Z"}} +{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-4","updated_at":"2020-01-01T07:00:00.000Z","inserted_at":"2020-01-01T08:00:00.000Z","payload":{"site_uuid":"bd05ceed-1df9-4af7-832a-d3b5faa7ca1d","id":"step-4","automation_run_id":"run-2","status":"pending","updated_at":"2020-01-01T07:00:00.000Z"}} diff --git a/ghost/core/core/server/data/tinybird/pipes/mv_automation_run_steps.pipe b/ghost/core/core/server/data/tinybird/pipes/mv_automation_run_steps.pipe new file mode 100644 index 000000000000..a3e450198425 --- /dev/null +++ b/ghost/core/core/server/data/tinybird/pipes/mv_automation_run_steps.pipe @@ -0,0 +1,13 @@ +NODE extract_automation_run_steps +SQL > + WITH JSONExtract(payload, 'Tuple(automation_run_id String, status String)') AS payload_json + SELECT + site_uuid, + id, + updated_at, + getSubcolumn(payload_json, 'automation_run_id') AS automation_run_id, + getSubcolumn(payload_json, 'status') AS status + FROM automation_run_step_events + +TYPE MATERIALIZED +DATASOURCE _mv_automation_run_steps diff --git a/ghost/core/core/server/data/tinybird/pipes/mv_automation_runs.pipe b/ghost/core/core/server/data/tinybird/pipes/mv_automation_runs.pipe new file mode 100644 index 000000000000..64a70555a119 --- /dev/null +++ b/ghost/core/core/server/data/tinybird/pipes/mv_automation_runs.pipe @@ -0,0 +1,15 @@ +NODE extract_automation_runs +SQL > + WITH JSONExtract(payload, 'Tuple(automation_id String, created_at String)') AS payload_json + SELECT + site_uuid, + id, + updated_at, + getSubcolumn(payload_json, 'automation_id') AS automation_id, + parseDateTime64BestEffort( + getSubcolumn(payload_json, 'created_at'), 3, 'UTC' + ) AS created_at + FROM automation_run_events + +TYPE MATERIALIZED +DATASOURCE _mv_automation_runs diff --git a/ghost/core/core/server/data/tinybird/pipes/mv_pending_automation_runs.pipe b/ghost/core/core/server/data/tinybird/pipes/mv_pending_automation_runs.pipe new file mode 100644 index 000000000000..a2d2bd574b4b --- /dev/null +++ b/ghost/core/core/server/data/tinybird/pipes/mv_pending_automation_runs.pipe @@ -0,0 +1,7 @@ +NODE extract_pending_automation_runs +SQL > + SELECT site_uuid, id, automation_run_id, status = 'pending' AS is_pending, updated_at + FROM _mv_automation_run_steps + +TYPE MATERIALIZED +DATASOURCE _mv_pending_automation_runs diff --git a/ghost/core/core/server/data/tinybird/tests/api_automation_browse_stats.yaml b/ghost/core/core/server/data/tinybird/tests/api_automation_browse_stats.yaml new file mode 100644 index 000000000000..78b2b920f316 --- /dev/null +++ b/ghost/core/core/server/data/tinybird/tests/api_automation_browse_stats.yaml @@ -0,0 +1,18 @@ +- name: automation_browse_stats + description: Uses latest run and step versions for automation browse statistics + parameters: site_uuid=bd05ceed-1df9-4af7-832a-d3b5faa7ca1d + expected_result: | + {"automation_id":"automation-1","last_run_created_at":"2020-02-01T01:00:00.123Z","total_run_count":4,"in_progress_run_count":2} + {"automation_id":"automation-2","last_run_created_at":"2020-01-01T01:00:00.000Z","total_run_count":1,"in_progress_run_count":0} + +- name: automation_browse_stats_other_site + description: Restricts statistics to one site + parameters: site_uuid=6cce57c1-c0df-4771-bb86-53cbf494f90f + expected_result: | + {"automation_id":"automation-1","last_run_created_at":"2020-01-01T01:00:00.000Z","total_run_count":1,"in_progress_run_count":0} + +- name: automation_browse_stats_missing_site_uuid + description: Requires site UUID + expected_http_status: 400 + parameters: '' + expected_result: '' diff --git a/ghost/core/core/server/services/tinybird/tinybird-service.js b/ghost/core/core/server/services/tinybird/tinybird-service.js index e8844636f799..34d6cfcbdcf6 100644 --- a/ghost/core/core/server/services/tinybird/tinybird-service.js +++ b/ghost/core/core/server/services/tinybird/tinybird-service.js @@ -58,6 +58,7 @@ const TINYBIRD_PIPES = [ 'api_top_utm_terms', 'api_top_devices', 'api_gift_link_visits', + 'api_automation_browse_stats', // v2 pipes (materialized view optimization) 'api_kpis_v2', 'api_active_visitors_v2', diff --git a/ghost/core/test/integration/exporter/exporter.test.js b/ghost/core/test/integration/exporter/exporter.test.js index d5e187fd0fb0..51570fa7706e 100644 --- a/ghost/core/test/integration/exporter/exporter.test.js +++ b/ghost/core/test/integration/exporter/exporter.test.js @@ -114,6 +114,7 @@ describe('Exporter', function () { 'subscriptions', 'suppressions', 'tags', + 'tinybird_syncs', 'tokens', 'users', 'webhooks', diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index 61d26d2ec87d..7530b75762c2 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -37,7 +37,7 @@ const parseYaml = require('../../../../../core/server/services/route-settings/ya */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '83816146af992ec6a8df98956c3e6bd9'; + const currentSchemaHash = 'f8167b5e21aac21f007f008e6e60a332'; const currentFixturesHash = '5718e0d4eb037f159c312369e949829a'; const currentSettingsHash = '6ea42a00cca61a1ba87f66eb6e25a78a'; const currentRoutesHash = 'd8c25fa01bf6d22a2bcb05ba0de70dc1'; diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 91acbe28795d..00502f33a424 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -44,6 +44,7 @@ }, "./locale-data.json": { "source": "./src/locale-data.json", + "types": "./src/locale-data.json", "default": "./build/locale-data.json" }, "./package.json": "./package.json" diff --git a/packages/testing/test-data/src/selectors/editor.ts b/packages/testing/test-data/src/selectors/editor.ts index 0c88d7f6563c..3204a995d738 100644 --- a/packages/testing/test-data/src/selectors/editor.ts +++ b/packages/testing/test-data/src/selectors/editor.ts @@ -35,6 +35,7 @@ export const featureImageTkIndicator = 'feature-image-tk-indicator'; export const editorLeaveDialog = 'editor-leave-dialog'; export const editorHeaderActions = 'editor-header-actions'; export const editorPublishInputsError = 'editor-publish-inputs-error'; +export const unsplashSearchModal = 'unsplash-search-modal'; // settings sidebar testids export const postSettingsSidebar = 'post-settings-sidebar'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ad73050c3c6..88dc44b1e798 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,12 @@ catalogs: '@radix-ui/react-context-menu': specifier: 2.3.2 version: 2.3.2 + '@radix-ui/react-focus-guards': + specifier: 1.1.4 + version: 1.1.4 + '@radix-ui/react-focus-scope': + specifier: 1.1.11 + version: 1.1.11 '@radix-ui/react-form': specifier: 0.1.11 version: 0.1.11 @@ -938,6 +944,12 @@ importers: '@dnd-kit/sortable': specifier: 'catalog:' version: 7.0.2(@dnd-kit/core@6.3.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + '@radix-ui/react-focus-guards': + specifier: 'catalog:' + version: 1.1.4(@types/react@18.3.31)(react@18.3.1) + '@radix-ui/react-focus-scope': + specifier: 'catalog:' + version: 1.1.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@sentry/react': specifier: 'catalog:' version: 7.120.4(react@18.3.1) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index cf98b775a958..decc2f6c5575 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -62,6 +62,8 @@ catalog: '@radix-ui/react-avatar': 1.2.1 '@radix-ui/react-checkbox': 1.3.6 '@radix-ui/react-context-menu': 2.3.2 + '@radix-ui/react-focus-guards': 1.1.4 + '@radix-ui/react-focus-scope': 1.1.11 '@radix-ui/react-form': 0.1.11 '@radix-ui/react-popover': 1.1.18 '@radix-ui/react-radio-group': 1.4.2