From dcde019b8cf09bdf636e854a616d6c7b0f11ea05 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Tue, 13 Oct 2026 01:42:29 +0600 Subject: [PATCH] feat: show snippet ID in editor and add trash and export-all options --- src/css/edit/_sidebar.scss | 4 + .../EditMenu/EditorSidebar/EditorSidebar.tsx | 2 + .../EditorSidebar/actions/SnippetIdInfo.tsx | 16 +++ .../SnippetsTable/ManageSnippetCard.tsx | 22 ++-- .../ManageMenu/SnippetsTable/RowActions.tsx | 16 ++- .../SnippetsTable/SnippetsTableControls.tsx | 35 +++++- .../SnippetsTable/useApplyBulkAction.ts | 1 + tests/e2e/code-snippets-edit.spec.ts | 35 ++++++ tests/e2e/code-snippets-list.spec.ts | 109 ++++++++++++++++++ tests/e2e/helpers/SnippetsTestHelper.ts | 4 + 10 files changed, 224 insertions(+), 20 deletions(-) create mode 100644 src/js/components/EditMenu/EditorSidebar/actions/SnippetIdInfo.tsx diff --git a/src/css/edit/_sidebar.scss b/src/css/edit/_sidebar.scss index 77b5a91af..d868037fd 100644 --- a/src/css/edit/_sidebar.scss +++ b/src/css/edit/_sidebar.scss @@ -33,6 +33,10 @@ } } +.snippet-id-value { + margin-inline-end: auto; +} + .activation-switch-container { inline-size: 100%; display: flex; diff --git a/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx b/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx index 6ded77fc5..80d61d498 100644 --- a/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx +++ b/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx @@ -11,6 +11,7 @@ import { ConditionModalButton } from '../ConditionModal/ConditionModalButton' import { SnippetLocationInput } from '../SnippetForm/fields/SnippetLocationInput' import { Notices } from '../SnippetForm/page/Notices' import { ShortcodeInfo } from './actions/ShortcodeInfo' +import { SnippetIdInfo } from './actions/SnippetIdInfo' import { MultisiteSharingSettings } from './controls/MultisiteSharingSettings' import { ExportButtons } from './actions/ExportButtons' import { SubmitButtons } from './actions/SubmitButtons' @@ -50,6 +51,7 @@ export const EditorSidebar: React.FC = ({ setIsUpgradeDialog
+
diff --git a/src/js/components/EditMenu/EditorSidebar/actions/SnippetIdInfo.tsx b/src/js/components/EditMenu/EditorSidebar/actions/SnippetIdInfo.tsx new file mode 100644 index 000000000..a1e490580 --- /dev/null +++ b/src/js/components/EditMenu/EditorSidebar/actions/SnippetIdInfo.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' +import { CopyToClipboardButton } from '../../../common/CopyToClipboardButton' + +export const SnippetIdInfo: React.FC = () => { + const { snippet } = useSnippetForm() + + return snippet.id + ?
+ {__('ID', 'code-snippets')} + {snippet.id} + +
+ : null +} diff --git a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx index 3648a397e..cc79d78c2 100644 --- a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx @@ -49,15 +49,17 @@ const CloneExportMenuItems: React.FC = ({ snippet }) => return ( <> - { - api.create(cloneSnippetObject(snippet)) - .then(refreshSnippetsList) - .catch(handleUnknownError) - }} - > - {__('Clone', 'code-snippets')} - + {!snippet.trashed + ? { + api.create(cloneSnippetObject(snippet)) + .then(refreshSnippetsList) + .catch(handleUnknownError) + }} + > + {__('Clone', 'code-snippets')} + + : null} { @@ -126,7 +128,7 @@ const CardActionsMenu: React.FC = ({ snippet }) => { getSnippetDisplayName(snippet) )} > - {!snippet.trashed ? : null} + diff --git a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx index ae320c95f..803389565 100644 --- a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx @@ -139,15 +139,13 @@ const ActionLinks: React.FC = ({ snippet }) => { /> : null - const exportButton = !snippet.trashed - ? - api.export(snippet) - .then(response => downloadSnippetExportFile(response, snippet))} - /> - : null + const exportButton = + api.export(snippet) + .then(response => downloadSnippetExportFile(response, snippet))} + /> const restoreButton = snippet.trashed ? = ( {'top' === which && } +const ExportAllButton: React.FC = () => { + const snippetsApi = useSnippetsAPI() + const [isExporting, setIsExporting] = useState(false) + + const handleExportAll = async (): Promise => { + setIsExporting(true) + + try { + const snippets = await snippetsApi.fetchAll(isNetworkAdmin()) + downloadBulkSnippetExportFile(snippets) + } catch (error: unknown) { + handleUnknownError(error) + } finally { + setIsExporting(false) + } + } + + return
+ +
+} + export const SnippetsTableToolbar = () =>
+
diff --git a/src/js/components/ManageMenu/SnippetsTable/useApplyBulkAction.ts b/src/js/components/ManageMenu/SnippetsTable/useApplyBulkAction.ts index 6fa763444..2235b3c38 100644 --- a/src/js/components/ManageMenu/SnippetsTable/useApplyBulkAction.ts +++ b/src/js/components/ManageMenu/SnippetsTable/useApplyBulkAction.ts @@ -23,6 +23,7 @@ export const BULK_ACTIONS: ListTableAction[] = [ export const TRASHED_BULK_ACTIONS: ListTableAction[] = [ { key: 'restore', label: __('Restore', 'code-snippets') }, + { key: 'export', label: __('Export', 'code-snippets') }, { key: 'delete', label: __('Delete Permanently', 'code-snippets') } ] diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index 2257461bb..8d5aaaf65 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -166,4 +166,39 @@ test.describe('Code Snippets Admin', () => { await helper.deleteSnippetFromList(snippetName) await helper.expectElementCount(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`, 0) }) + + test('Shows the snippet ID with a copy control in the editor sidebar', async ({ page }) => { + const snippetName = SnippetsTestHelper.makeUniqueSnippetName() + await helper.createSnippet({ + name: snippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + + // OpenSnippet() navigates table rows, so leave the table view active even if + // an earlier test persisted the card view preference. + await helper.navigateToSnippetsAdmin() + await page.getByRole('button', { name: 'Table view' }).click().catch(() => undefined) + + await helper.openSnippet(snippetName) + + const idField = page.locator('.snippet-editor-sidebar-settings .inline-form-field').filter({ hasText: 'ID' }) + await expect(idField).toBeVisible() + + // The ID shown in the editor must match the ID in the editor URL. + const urlId = new URL(page.url()).searchParams.get('id') + expect(urlId).toBeTruthy() + + await expect(page.locator('.snippet-id-value')).toHaveText(String(urlId)) + + // Grant clipboard permission so the copy control can write to the clipboard. + await page.context().grantPermissions(['clipboard-read', 'clipboard-write']) + + await idField.getByRole('button', { name: 'Copy' }).click() + await expect(idField.getByRole('button', { name: 'Copy' })).toBeVisible() + + const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) + expect(clipboardText).toBe(String(urlId)) + + await helper.cleanupSnippet(snippetName) + }) }) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index 9d70ae0a4..5e337de31 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -355,6 +355,115 @@ test.describe('Code Snippets List Page Actions', () => { await helper.cleanupSnippet(secondSnippetName) }) + test('Can export all snippets, including trashed ones', async ({ page }) => { + test.setTimeout(TIMEOUTS.LONG) + const trashedSnippetName = SnippetsTestHelper.makeUniqueSnippetName('E2E Export All Trashed') + const trashedSnippetId = await SnippetsTestHelper.createSnippetViaCli({ + name: trashedSnippetName, + active: false + }) + await SnippetsTestHelper.trashSnippet(trashedSnippetId) + + try { + await helper.navigateToSnippetsAdmin() + + const exportAllButton = page.getByRole('button', { name: 'Export All' }) + await expect(exportAllButton).toBeVisible() + + const download = await Promise.all([ + page.waitForEvent('download'), + exportAllButton.click() + ]).then(([downloadEvent]) => downloadEvent) + + expect(download.suggestedFilename()).toBe('snippets.code-snippets.json') + + const downloadPath = await download.path() + if (!downloadPath) { + throw new Error('Download did not produce a local file path') + } + const parsed = <{ snippets: { name: string, trashed?: boolean }[] }>JSON.parse(readFileSync(downloadPath, 'utf-8')) + const names = parsed.snippets.map(snippet => snippet.name) + + expect(names).toContain(snippetName) + expect(names).toContain(trashedSnippetName) + + const trashedExport = parsed.snippets.find(snippet => snippet.name === trashedSnippetName) + expect(trashedExport?.trashed).toBe(true) + } finally { + await helper.cleanupSnippet(trashedSnippetName) + } + }) + + test('Can export a trashed snippet from its row actions', async ({ page }) => { + test.setTimeout(TIMEOUTS.LONG) + + await clickRowAction(snippetRowByName(page, snippetName), SELECTORS.DELETE_ACTION) + await page.locator('a[href*="status=trashed"]').first().click() + await expect(page).toHaveURL(/status=trashed/) + + const trashedRow = snippetRowByName(page, snippetName) + await expect(trashedRow).toBeVisible() + + const download = await Promise.all([ + page.waitForEvent('download'), + trashedRow.locator(SELECTORS.EXPORT_ACTION).first().click() + ]).then(([downloadEvent]) => downloadEvent) + + expect(download.suggestedFilename()).toMatch(/\.code-snippets\.json$/) + + const downloadPath = await download.path() + if (!downloadPath) { + throw new Error('Download did not produce a local file path') + } + const parsed = <{ snippets: { name: string, trashed?: boolean }[] }>JSON.parse(readFileSync(downloadPath, 'utf-8')) + expect(parsed.snippets).toHaveLength(1) + expect(parsed.snippets[0].name).toBe(snippetName) + expect(parsed.snippets[0].trashed).toBe(true) + }) + + test('Can export multiple trashed snippets from bulk actions', async ({ page }) => { + test.setTimeout(TIMEOUTS.LONG) + const secondSnippetName = SnippetsTestHelper.makeUniqueSnippetName() + + await helper.createAndActivateSnippet({ + name: secondSnippetName, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.navigateToSnippetsAdmin() + + for (const name of [snippetName, secondSnippetName]) { + await clickRowAction(snippetRowByName(page, name), SELECTORS.DELETE_ACTION) + } + + await page.locator('a[href*="status=trashed"]').first().click() + await expect(page).toHaveURL(/status=trashed/) + + await page.locator(`${SELECTORS.SNIPPET_ROW}:has-text("${snippetName}")`).first() + .locator('input[name="checked[]"]').check({ force: true }) + await page.locator(`${SELECTORS.SNIPPET_ROW}:has-text("${secondSnippetName}")`).first() + .locator('input[name="checked[]"]').check({ force: true }) + + await page.locator('select[name="action2"], select[name="action"]').last().selectOption({ label: 'Export' }) + + const download = await Promise.all([ + page.waitForEvent('download'), + page.locator('#doaction2').click() + ]).then(([downloadEvent]) => downloadEvent) + + expect(download.suggestedFilename()).toBe('snippets.code-snippets.json') + + const downloadPath = await download.path() + if (!downloadPath) { + throw new Error('Download did not produce a local file path') + } + const parsed = <{ snippets: { name: string }[] }>JSON.parse(readFileSync(downloadPath, 'utf-8')) + const names = parsed.snippets.map(snippet => snippet.name) + expect(names).toContain(snippetName) + expect(names).toContain(secondSnippetName) + + await helper.cleanupSnippet(secondSnippetName) + }) + test('Can download a single snippet from bulk actions', async ({ page }) => { test.setTimeout(TIMEOUTS.LONG) await helper.filterSnippetsByName(snippetName) diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index c279bea47..6a7844f10 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -115,6 +115,10 @@ export class SnippetsTestHelper { return Number(await wpCli(['eval', php])) } + static async trashSnippet(snippetId: number): Promise { + await wpCli(['eval', `\\Code_Snippets\\trash_snippet(${snippetId}, false);`]) + } + static async cleanupSnippetsByPrefix(prefix: string): Promise { const php = ` global $wpdb;