diff --git a/src/css/common/_modal.scss b/src/css/common/_modal.scss index 3a90d67f8..8eadf5d28 100644 --- a/src/css/common/_modal.scss +++ b/src/css/common/_modal.scss @@ -66,13 +66,10 @@ border-block-end-color: #ddd; } - // The badge lives in the modal content because the minimum-supported - // WordPress Modal (5.5–6.3) has no headerActions prop; shift it up into - // the header area, clear of the close button. .code-snippets-preview-modal__badge { - position: absolute; - inset-block-start: 18px; - inset-inline-end: 56px; + display: flex; + flex-shrink: 0; + justify-content: flex-end; } // Header and footer stay pinned; the CodeMirror editor is the only @@ -85,9 +82,9 @@ overflow: hidden; } - // The modal component wraps its children in an unstyled focus container; - // it must join the flex chain or the editor grows to its content height. - .components-modal__header + div { + // Newer Modal versions wrap children in an unstyled focus container; older + // supported versions render the badge directly after the header. + .components-modal__header + div:not(.code-snippets-preview-modal__badge) { display: flex; flex-flow: column; flex: 1; diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx index 2810b01f1..88d8ac701 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx @@ -7,6 +7,7 @@ import { REST_BASES } from '../../../utils/restAPI' import { isLicensed } from '../../../utils/screen' import { isProSnippet } from '../../../utils/snippets/snippets' import { TableNav } from '../../common/ListTable/TableNavigation' +import { LoadingStatusNotices } from '../../common/LoadingStatusNotices' import { SnippetViewToggle } from '../../common/SnippetViewToggle' import { CloudSnippetsTable } from './CloudSnippetsTable' import { CloudSnippetAuthor, SearchResult } from './SearchResult' @@ -191,18 +192,7 @@ const SearchResultsTable: React.FC = ({ snippetView, set } const SearchResults: React.FC = ({ snippetView, setSnippetView }) => { - const { searchResults, searchParams, isErrored } = useCloudSearch() - - if (isErrored) { - return ( -
-

{__( - 'An error occurred while fetching search results. Please try again.', - 'code-snippets' - )}

-
- ) - } + const { searchResults, searchParams } = useCloudSearch() if (!searchResults) { return null @@ -229,6 +219,21 @@ const SearchResults: React.FC = ({ snippetView, setSnipp : null } +const SearchStatus = () => { + const { isErrored, isLoading } = useCloudSearch() + + return isErrored || isLoading + ? + : null +} + export interface CloudSearchProps { snippetView: SnippetView setSnippetView: (view: SnippetView) => void @@ -237,5 +242,6 @@ export interface CloudSearchProps { export const CloudSearch: React.FC = ({ snippetView, setSnippetView }) =>
+
diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx index 1be29a711..e18182771 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx @@ -7,6 +7,7 @@ import { Button } from '../../common/Button' import { CloudSnippetDownloadButton } from '../../common/cloud/CloudSnippetDownloadButton' import { CloudSnippetPreviewModal } from '../../common/cloud/CloudSnippetPreviewModal' import { CloudStatusBadge } from '../../common/cloud/CloudStatusBadge' +import { useCloudSearch } from './WithCloudSearchContext' import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' import type { Dispatch, SetStateAction } from 'react' @@ -27,6 +28,38 @@ interface CloudSnippetRowProps { setSelected: SetSelected } +interface CloudSnippetActionsProps extends Pick { + isPreviewOpen: boolean + setIsPreviewOpen: (isOpen: boolean) => void +} + +const CloudSnippetActions: React.FC = ({ + snippet, + isPreviewOpen, + setIsPreviewOpen +}) => { + const { doSearch } = useCloudSearch() + + return ( + <> +
+ + + +
+ + + + ) +} + const CloudSnippetRow: React.FC = ({ snippet, selected, setSelected }) => { const [isPreviewOpen, setIsPreviewOpen] = useState(false) @@ -69,15 +102,7 @@ const CloudSnippetRow: React.FC = ({ snippet, selected, se -
- - - -
- - + ) diff --git a/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx b/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx index 190713ac1..df21ffb5c 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx @@ -5,6 +5,7 @@ import { useSnippetView } from '../../../hooks/useSnippetView' import { fetchConstQueryParam, updateQueryParams } from '../../../utils/urls' import { ScreenMetaSlot } from '../../common/ScreenMetaSlot' import { SubnavTabs } from '../../common/SubnavTabs' +import { WithCloudSnippetDownloadsContext } from '../../common/cloud/WithCloudSnippetDownloadsContext' import { WithCloudSearchContext, useCloudSearch } from './WithCloudSearchContext' import { CloudSearch } from './CloudSearch' @@ -75,7 +76,9 @@ const CommunityCloudInner = () => { export const CommunityCloud = () => - - - + + + + + diff --git a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx index e59313549..58cf6bfed 100644 --- a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx @@ -10,6 +10,7 @@ import { CloudSnippetPreviewModal } from '../../common/cloud/CloudSnippetPreview import { CloudStatusIndicator } from '../../common/cloud/CloudStatusBadge' import { CloudUpdateIcon } from '../../common/icons/CloudIcons' import { SnippetCard } from '../../common/SnippetCard' +import { useCloudSearch } from './WithCloudSearchContext' import type { ReactNode } from 'react' import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' @@ -100,6 +101,7 @@ export const SearchResult: React.FC = ({ isSelected = false, onSelectedChange }) => { + const { doSearch } = useCloudSearch() const [isPreviewOpen, setIsPreviewOpen] = useState(false) return ( @@ -126,7 +128,7 @@ export const SearchResult: React.FC = ({ {__('Preview', 'code-snippets')} - + } > @@ -135,6 +137,7 @@ export const SearchResult: React.FC = ({ snippet={snippet} isOpen={isPreviewOpen} setIsOpen={setIsPreviewOpen} + onDownloaded={doSearch} /> ) diff --git a/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx b/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx index 20a541284..118e8ae96 100644 --- a/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx @@ -164,6 +164,7 @@ const useRequestIds = () => { export interface CloudSearchContext { isErrored: boolean doSearch: (paramsDelta?: Partial) => void + isLoading: boolean isSearching: boolean searchParams: CloudSearchParams searchResults: CloudSearchResults | undefined @@ -175,6 +176,7 @@ const useSearchApi = () => { const { api } = useRestAPI() const { isCurrentRequest, nextRequestId } = useRequestIds() const [currentSearch, setCurrentSearch] = useState(false) + const [isLoading, setIsLoading] = useState(false) const [searchResults, setSearchResults] = useState() const [availableFilters, setAvailableFilters] = useState({}) @@ -182,6 +184,7 @@ const useSearchApi = () => { const requestId = nextRequestId() const isFeaturedSearch = '' === params.query.trim() + setIsLoading(true) setCurrentSearch(!isFeaturedSearch) const baseUrl = isFeaturedSearch ? SEARCH_URLS.FEATURED : SEARCH_URLS.SEARCH_QUERY @@ -197,10 +200,16 @@ const useSearchApi = () => { setSearchResults(false) } }) - .finally(() => setCurrentSearch(false)) + .finally(() => { + if (isCurrentRequest(requestId)) { + setCurrentSearch(false) + setIsLoading(false) + } + }) }, [api, nextRequestId, isCurrentRequest]) return { + isLoading, isSearching: currentSearch, searchResults, availableFilters, @@ -211,7 +220,7 @@ const useSearchApi = () => { const [Context, useCloudSearch] = createContextHook('useCloudSearch') export const WithCloudSearchContext: React.FC = ({ children }) => { - const { isSearching, makeSearchRequest, availableFilters, searchResults } = useSearchApi() + const { isLoading, isSearching, makeSearchRequest, availableFilters, searchResults } = useSearchApi() const [searchParams, setSearchParams] = useState(fetchSearchQueryParams) const [madeInitialRequest, setMadeInitialRequest] = useState(false) @@ -236,6 +245,7 @@ export const WithCloudSearchContext: React.FC = ({ children } const value: CloudSearchContext = { doSearch, + isLoading, isSearching, searchParams, availableFilters, diff --git a/src/js/components/common/LoadingStatusNotices.tsx b/src/js/components/common/LoadingStatusNotices.tsx new file mode 100644 index 000000000..c4de79571 --- /dev/null +++ b/src/js/components/common/LoadingStatusNotices.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { Notice } from './Notice' +import type { ReactNode } from 'react' + +export interface LoadingStatusNoticesProps { + isLoading: boolean + errorMessage: string | undefined + loadingNotice: ReactNode + noticeLabel: string +} + +export const LoadingStatusNotices: React.FC = ({ + isLoading, + errorMessage, + loadingNotice, + noticeLabel +}) => { + switch (true) { + case isLoading: + return ( + +

{loadingNotice}

+
+ ) + + case errorMessage !== undefined: + return ( + +

{errorMessage}

+
+ ) + + default: + return ( + +

{__('An unknown error occurred. Please try again.', 'code-snippets')}

+
+ ) + } +} diff --git a/src/js/components/common/SnippetPreviewModal.tsx b/src/js/components/common/SnippetPreviewModal.tsx index dc5b4c5e8..47bfc9c6f 100644 --- a/src/js/components/common/SnippetPreviewModal.tsx +++ b/src/js/components/common/SnippetPreviewModal.tsx @@ -35,6 +35,7 @@ export interface SnippetPreviewModalProps { setIsOpen: (isOpen: boolean) => void snippet?: Snippet extraActions?: PreviewExtraActions + footerActions?: ReactNode } const EDITOR_MODES: Record = { @@ -229,15 +230,32 @@ const SnippetPreviewActions: React.FC = ({ ) } -/** - * The minimum-supported WordPress Modal (5.5–6.3) has no headerActions prop, - * so the badge renders in the content and CSS moves it into the header area. - */ const PreviewTypeBadge: React.FC<{ type: SnippetType }> = ({ type }) =>
+interface PreviewFooterProps { + snippet?: Snippet + extraActions?: PreviewExtraActions + footerActions?: ReactNode + closeModal: () => void +} + +const PreviewFooter: React.FC = ({ + snippet, + extraActions, + footerActions, + closeModal +}) => + snippet + ? + : footerActions + ?
+
{footerActions}
+
+ : null + /** * Modal for quickly viewing a snippet's code in a read-only CodeMirror editor, * without navigating to the edit page. Shared between local snippets and cloud @@ -251,7 +269,8 @@ export const SnippetPreviewModal: React.FC = ({ isOpen, setIsOpen, snippet, - extraActions + extraActions, + footerActions }) => { const textareaRef = useRef(null) @@ -291,13 +310,10 @@ export const SnippetPreviewModal: React.FC = ({ /> - {snippet - ? setIsOpen(false)} - /> - : null} + setIsOpen(false)} + /> : null } diff --git a/src/js/components/common/cloud/CloudSnippetDownloadButton.tsx b/src/js/components/common/cloud/CloudSnippetDownloadButton.tsx index d3e9110fd..fdbd9ba6a 100644 --- a/src/js/components/common/cloud/CloudSnippetDownloadButton.tsx +++ b/src/js/components/common/cloud/CloudSnippetDownloadButton.tsx @@ -8,6 +8,7 @@ import { isLicensed } from '../../../utils/screen' import { Button } from '../Button' import { ErrorTooltip } from '../Tooltip' import { UpsellDialog } from '../UpsellDialog' +import { useCloudSnippetDownloads } from './WithCloudSnippetDownloadsContext' import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' export interface DownloadSnippetResponse { @@ -16,16 +17,18 @@ export interface DownloadSnippetResponse { link_id: number } -interface CloudSnippetDownloadButtonProps { +export interface CloudSnippetDownloadButtonProps { + onDownloaded: VoidFunction snippet: CloudSnippetSchema } -export const CloudSnippetDownloadButton: React.FC = ({ snippet }) => { +export const CloudSnippetDownloadButton: React.FC = ({ snippet, onDownloaded }) => { const { api } = useRestAPI() - const [isWorking, setIsWorking] = useState(false) + const { downloadRecords, updateDownloadRecord } = useCloudSnippetDownloads() const [errorMessage, setErrorMessage] = useState() - const [localSnippetId, setLocalSnippetId] = useState(snippet.local_id ?? 0) const [isUpsellOpen, setIsUpsellOpen] = useState(false) + const { isDownloading = false, localId } = downloadRecords[snippet.id] ?? {} + const localSnippetId = snippet.local_id ?? localId if (localSnippetId) { return ( @@ -47,27 +50,28 @@ export const CloudSnippetDownloadButton: React.FC { - setIsWorking(true) + updateDownloadRecord(snippet.id, { isDownloading: true }) setErrorMessage(undefined) api.post(`${REST_BASES.cloud.snippets}/${snippet.id}/download`) .then(response => { - setLocalSnippetId(response.snippet_id) + updateDownloadRecord(snippet.id, { localId: response.snippet_id }) + onDownloaded() }) .catch((error: unknown) => { setErrorMessage('string' === typeof error ? error : __('An error occurred while trying to download the snippet.', 'code-snippets')) }) - .finally(() => setIsWorking(false)) + .finally(() => updateDownloadRecord(snippet.id, { isDownloading: false })) } return ( <> - {isWorking && } + {isDownloading && } {errorMessage && } - diff --git a/src/js/components/common/cloud/CloudSnippetPreviewModal.tsx b/src/js/components/common/cloud/CloudSnippetPreviewModal.tsx index 5e0c66511..946b8263b 100644 --- a/src/js/components/common/cloud/CloudSnippetPreviewModal.tsx +++ b/src/js/components/common/cloud/CloudSnippetPreviewModal.tsx @@ -1,19 +1,27 @@ import React from 'react' import { getSnippetType } from '../../../utils/snippets/snippets' import { SnippetPreviewModal } from '../SnippetPreviewModal' +import { CloudSnippetDownloadButton } from './CloudSnippetDownloadButton' import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' export interface CloudSnippetPreviewModalProps { isOpen: boolean + onDownloaded: VoidFunction snippet: CloudSnippetSchema setIsOpen: (isOpen: boolean) => void } -export const CloudSnippetPreviewModal: React.FC = ({ snippet, isOpen, setIsOpen }) => +export const CloudSnippetPreviewModal: React.FC = ({ + snippet, + isOpen, + setIsOpen, + onDownloaded +}) => } /> diff --git a/src/js/components/common/cloud/WithCloudSnippetDownloadsContext.tsx b/src/js/components/common/cloud/WithCloudSnippetDownloadsContext.tsx new file mode 100644 index 000000000..11dcb8cec --- /dev/null +++ b/src/js/components/common/cloud/WithCloudSnippetDownloadsContext.tsx @@ -0,0 +1,44 @@ +import React, { useCallback, useState } from 'react' +import { createContextHook } from '../../../utils/bootstrap' +import type { PropsWithChildren } from 'react' +import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' + +export interface CloudSnippetDownloadRecord { + isDownloading: boolean + localId?: number +} + +interface CloudSnippetDownloadsContext { + downloadRecords: Partial> + updateDownloadRecord: ( + snippetId: CloudSnippetSchema['id'], + update: Partial + ) => void +} + +const [Context, useCloudSnippetDownloads] = + createContextHook('useCloudSnippetDownloads') + +export const WithCloudSnippetDownloadsContext: React.FC = ({ children }) => { + const [downloadRecords, setDownloadRecords] = + useState({}) + const updateDownloadRecord = useCallback( + (snippetId, update) => setDownloadRecords(previous => ({ + ...previous, + [snippetId]: { + isDownloading: false, + ...previous[snippetId], + ...update + } + })), + [] + ) + + return ( + + {children} + + ) +} + +export { useCloudSnippetDownloads } diff --git a/tests/e2e/code-snippets-community-featured.spec.ts b/tests/e2e/code-snippets-community-featured.spec.ts index b6470004d..8574e4d9d 100644 --- a/tests/e2e/code-snippets-community-featured.spec.ts +++ b/tests/e2e/code-snippets-community-featured.spec.ts @@ -1,5 +1,62 @@ import { expect, test } from '@playwright/test' import { TIMEOUTS, URLS } from './helpers/constants' +import type { Page } from '@playwright/test' + +const switchSnippetView = async (page: Page, view: 'Card view' | 'Table view') => { + const saved = page + .waitForResponse( + response => response.url().includes('/snippet-view') && 'GET' !== response.request().method(), + { timeout: TIMEOUTS.SHORT } + ) + .catch(() => undefined) + await page.getByRole('button', { name: view }).click() + await saved +} + +const closePreviewIfOpen = async (page: Page) => { + const closeButton = page.getByRole('button', { name: 'Close' }) + + if (await closeButton.isVisible()) { + await closeButton.click() + } +} + +const isFeaturedRequest = (url: URL): boolean => + url.pathname.includes('/cloud/snippets/featured') || + true === url.searchParams.get('rest_route')?.includes('/cloud/snippets/featured') + +const isSnippetDownloadRequest = (url: URL): boolean => + url.pathname.includes('/cloud/snippets/501/download') || + true === url.searchParams.get('rest_route')?.includes('/cloud/snippets/501/download') + +const makeCloudSnippet = (id: number, name: string, localId: number | null = null) => ({ + id, + slug: `mock-cloud-snippet-${id}`, + name, + description: 'Mock description', + code: ' ({ + snippets, + page: 1, + total_pages: 1, + total_snippets: snippets.length, + available_filters: {} +}) test.describe('Community Cloud Featured Snippets', () => { const jsErrors: string[] = [] @@ -67,42 +124,199 @@ test.describe('Community Cloud Featured Snippets', () => { await expect(page.locator('.cloud-snippets-heading', { hasText: 'Featured Snippets' })).not.toBeVisible() }) - test('Table checkboxes share the cloud selection state', async ({ page }) => { - await page.route( - url => - url.pathname.includes('/cloud/snippets/featured') || - true === url.searchParams.get('rest_route')?.includes('/cloud/snippets/featured'), - route => route.fulfill({ + test('Shows a page-level notice while featured snippets load', async ({ page }) => { + let releaseRequest: () => void = () => undefined + const requestPending = new Promise(resolve => { + releaseRequest = () => resolve() + }) + + await page.route(isFeaturedRequest, async route => { + await requestPending + await route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(makeFeaturedResponse()) + }) + }) + + await page.reload() + + try { + await expect(page.getByRole('status', { name: 'Community snippets status' })) + .toContainText('Loading community snippets…') + } finally { + releaseRequest() + } + + await expect(page.locator('.cloud-search .notice')).toBeHidden() + }) + + test('Shows a page-level notice when featured snippets fail to load', async ({ page }) => { + await page.route(isFeaturedRequest, route => route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Cloud unavailable' }) + })) + await page.reload() + + await expect(page.getByRole('alert', { name: 'Community snippets status' })) + .toContainText('An error occurred while fetching search results. Please try again.') + }) + + test('Preview offers the same download or edit action as its card', async ({ page }) => { + await page.route(isFeaturedRequest, route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(makeFeaturedResponse([ + makeCloudSnippet(501, 'Downloadable Cloud Snippet'), + makeCloudSnippet(502, 'Installed Cloud Snippet', 42) + ])) + })) + await page.reload() + + const openPreview = async (snippetName: string) => { + await page.getByRole('button', { name: snippetName }).click() + await expect(page.locator('.code-snippets-preview-modal')).toBeVisible() + } + + await openPreview('Downloadable Cloud Snippet') + await expect(page.locator('.code-snippets-preview-modal').getByRole('button', { name: 'Download' })) + .toBeVisible() + await page.getByRole('button', { name: 'Close' }).click() + + await openPreview('Installed Cloud Snippet') + await expect(page.locator('.code-snippets-preview-modal').getByRole('link', { name: 'Edit' })) + .toBeVisible() + }) + + test('Shares pending and downloaded state before refresh completes', async ({ page }) => { + let releaseDownload: () => void = () => undefined + let releaseRefresh: () => void = () => undefined + const downloadPending = new Promise(resolve => { + releaseDownload = () => resolve() + }) + const refreshPending = new Promise(resolve => { + releaseRefresh = () => resolve() + }) + let downloadRequests = 0 + let featuredRequests = 0 + + await page.route(isFeaturedRequest, async route => { + featuredRequests += 1 + + if (1 < featuredRequests) { + await refreshPending + } + + return route.fulfill({ contentType: 'application/json', - body: JSON.stringify({ - snippets: [{ - id: 501, - slug: 'mock-cloud-snippet', - name: 'Mock Cloud Snippet', - description: 'Mock description', - code: ' { + downloadRequests += 1 + await downloadPending + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, snippet_id: 42, link_id: 501 }) + }) + }) + await page.reload() + + await switchSnippetView(page, 'Card view') + + try { + const card = page.locator('.cloud-search-result', { hasText: 'Downloadable Cloud Snippet' }) + const cardActions = card.locator('.snippet-card-footer-actions') + await card.getByRole('button', { name: 'Downloadable Cloud Snippet' }).click() + + const preview = page.locator('.code-snippets-preview-modal') + await preview.getByRole('button', { name: 'Download' }).click() + + await expect(preview.getByRole('button', { name: 'Download' })).toBeDisabled() + await expect(cardActions.getByRole( + 'button', + { name: 'Download', exact: true, includeHidden: true } + )).toBeDisabled() + + releaseDownload() + await expect(preview.getByRole('link', { name: 'Edit' })).toBeVisible() + await expect.poll(() => featuredRequests).toBe(2) + await page.getByRole('button', { name: 'Close' }).click() + + await expect(cardActions.getByRole('link', { name: 'Edit', exact: true })).toBeVisible() + await expect(cardActions.getByRole('button', { name: 'Download', exact: true })).toHaveCount(0) + expect(downloadRequests).toBe(1) + } finally { + releaseDownload() + releaseRefresh() + await closePreviewIfOpen(page) + await switchSnippetView(page, 'Table view').catch(() => undefined) + } + }) + + test('Keeps downloaded state when refresh fails', async ({ page }) => { + let downloadRequests = 0 + let featuredRequests = 0 + + await page.route(isFeaturedRequest, route => { + featuredRequests += 1 + + return 2 === featuredRequests + ? route.fulfill({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ message: 'Cloud unavailable' }) + }) + : route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(makeFeaturedResponse([ + makeCloudSnippet(501, 'Downloadable Cloud Snippet') + ])) }) + }) + await page.route(isSnippetDownloadRequest, route => { + downloadRequests += 1 + return route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ success: true, snippet_id: 42, link_id: 501 }) }) - ) + }) + await page.reload() + + await switchSnippetView(page, 'Card view') + + try { + const card = page.locator('.cloud-search-result', { hasText: 'Downloadable Cloud Snippet' }) + const cardActions = card.locator('.snippet-card-footer-actions') + await card.getByRole('button', { name: 'Downloadable Cloud Snippet' }).click() + + const preview = page.locator('.code-snippets-preview-modal') + await preview.getByRole('button', { name: 'Download' }).click() + + await expect(page.getByRole('alert', { name: 'Community snippets status' })) + .toContainText('An error occurred while fetching search results. Please try again.') + + await page.locator('.cloud-search-form') + .getByRole('button', { name: 'Search Cloud Library' }) + .click() + + await expect(cardActions.getByRole('link', { name: 'Edit', exact: true })).toBeVisible() + await expect(cardActions.getByRole('button', { name: 'Download', exact: true })).toHaveCount(0) + expect(downloadRequests).toBe(1) + } finally { + await closePreviewIfOpen(page) + await switchSnippetView(page, 'Table view').catch(() => undefined) + } + }) + + test('Table checkboxes share the cloud selection state', async ({ page }) => { + await page.route(isFeaturedRequest, route => route.fulfill({ + contentType: 'application/json', + body: JSON.stringify(makeFeaturedResponse()) + })) await page.reload() + await switchSnippetView(page, 'Table view') const table = page.locator('.cloud-snippets-table') await expect(table).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) diff --git a/tests/e2e/code-snippets-preview.spec.ts b/tests/e2e/code-snippets-preview.spec.ts index eed05a74a..6ded7360e 100644 --- a/tests/e2e/code-snippets-preview.spec.ts +++ b/tests/e2e/code-snippets-preview.spec.ts @@ -5,6 +5,8 @@ import { wpCli } from './helpers/wpCli' import type { Locator, Page } from '@playwright/test' const MAXIMUM_FOCUS_ATTEMPTS = 10 +const MAXIMUM_BADGE_ALIGNMENT_OFFSET = 4 +const PREVIEW_VIEWPORT_WIDTHS = [1280, 640] // Disabling the admin's "Syntax Highlighting" preference makes // wp_enqueue_code_editor() a no-op, so window.wp.codeEditor is undefined when @@ -105,8 +107,6 @@ test.describe('Code Snippets Preview Modal', () => { await expect(codeArea).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) await expect(codeArea).toHaveValue(new RegExp(snippetName)) - // The type badge renders in the modal content (the minimum-supported - // WordPress Modal has no headerActions prop). await expect(preview.locator('.code-snippets-preview-modal__badge .badge')) .toBeVisible() @@ -130,6 +130,37 @@ test.describe('Code Snippets Preview Modal', () => { await expect(editor.locator('[aria-label="Snippet code preview"]')).toBeAttached() }) + test('Preview type badge sits above code at inline end', async ({ page }) => { + await openPreviewEditor(page) + + const modal = page.locator('.code-snippets-preview-modal') + const badge = modal.locator('.code-snippets-preview-modal__badge .badge') + const editor = modal.locator('.code-snippets-preview-modal__editor') + + for (const width of PREVIEW_VIEWPORT_WIDTHS) { + await page.setViewportSize({ width, height: 800 }) + await expect(badge).toBeVisible() + await expect(editor).toBeVisible() + await expect(async () => { + const [badgeBox, editorBox] = + await Promise.all([badge.boundingBox(), editor.boundingBox()]) + + expect(badgeBox).not.toBeNull() + expect(editorBox).not.toBeNull() + + if (badgeBox && editorBox) { + const badgeInlineEnd = badgeBox.x + badgeBox.width + const editorInlineEnd = editorBox.x + editorBox.width + + expect(Math.abs(badgeInlineEnd - editorInlineEnd)).toBeLessThanOrEqual( + MAXIMUM_BADGE_ALIGNMENT_OFFSET + ) + expect(badgeBox.y + badgeBox.height).toBeLessThanOrEqual(editorBox.y) + } + }).toPass({ timeout: TIMEOUTS.SHORT }) + } + }) + for (const keypress of ['Tab', 'Shift+Tab']) { test(`${keypress} leaves the preview editor`, async ({ page }) => { const editor = await openPreviewEditor(page)