Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/css/edit/_sidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
}
}

.snippet-id-value {
margin-inline-end: auto;
}

.activation-switch-container {
inline-size: 100%;
display: flex;
Expand Down
2 changes: 2 additions & 0 deletions src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -50,6 +51,7 @@ export const EditorSidebar: React.FC<EditorSidebarProps> = ({ setIsUpgradeDialog
<div className="snippet-editor-sidebar-settings">
<ConditionModalButton setIsModalOpen={setIsUpgradeDialogOpen} />
<SnippetLocationInput />
<SnippetIdInfo />
<ShortcodeInfo />
<PriorityInput />
</div>
Expand Down
16 changes: 16 additions & 0 deletions src/js/components/EditMenu/EditorSidebar/actions/SnippetIdInfo.tsx
Original file line number Diff line number Diff line change
@@ -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
? <div className="inline-form-field">
<strong>{__('ID', 'code-snippets')}</strong>
<span className="snippet-id-value">{snippet.id}</span>
<CopyToClipboardButton small text={String(snippet.id)} />
</div>
: null
}
22 changes: 12 additions & 10 deletions src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,17 @@ const CloneExportMenuItems: React.FC<SnippetCardActionsProps> = ({ snippet }) =>

return (
<>
<KebabMenuItem
onSelect={() => {
api.create(cloneSnippetObject(snippet))
.then(refreshSnippetsList)
.catch(handleUnknownError)
}}
>
{__('Clone', 'code-snippets')}
</KebabMenuItem>
{!snippet.trashed
? <KebabMenuItem
onSelect={() => {
api.create(cloneSnippetObject(snippet))
.then(refreshSnippetsList)
.catch(handleUnknownError)
}}
>
{__('Clone', 'code-snippets')}
</KebabMenuItem>
: null}

<KebabMenuItem
onSelect={() => {
Expand Down Expand Up @@ -126,7 +128,7 @@ const CardActionsMenu: React.FC<SnippetCardActionsProps> = ({ snippet }) => {
getSnippetDisplayName(snippet)
)}
>
{!snippet.trashed ? <CloneExportMenuItems snippet={snippet} /> : null}
<CloneExportMenuItems snippet={snippet} />

<KebabMenuRow className="kebab-menu-priority">
<label htmlFor={`snippet-${snippet.id}-priority`}>{__('Priority', 'code-snippets')}</label>
Expand Down
16 changes: 7 additions & 9 deletions src/js/components/ManageMenu/SnippetsTable/RowActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,15 +139,13 @@ const ActionLinks: React.FC<RowActionsProps> = ({ snippet }) => {
/>
: null

const exportButton = !snippet.trashed
? <SnippetActionButton
label={__('Export', 'code-snippets')}
workingLabel={__('Exporting…', 'code-snippets')}
action={() =>
api.export(snippet)
.then(response => downloadSnippetExportFile(response, snippet))}
/>
: null
const exportButton = <SnippetActionButton
label={__('Export', 'code-snippets')}
workingLabel={__('Exporting…', 'code-snippets')}
action={() =>
api.export(snippet)
.then(response => downloadSnippetExportFile(response, snippet))}
/>

const restoreButton = snippet.trashed
? <SnippetActionButton
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { __, _x, sprintf } from '@wordpress/i18n'
import React, { Fragment, useId, useMemo } from 'react'
import React, { Fragment, useId, useMemo, useState } from 'react'
import { useRestAPI } from '../../../hooks/useRestAPI'
import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI'
import { useSnippetsList } from '../../../hooks/useSnippetsList'
import { handleUnknownError } from '../../../utils/errors'
import { downloadBulkSnippetExportFile } from '../../../utils/files'
import { isNetworkAdmin } from '../../../utils/screen'
import { REST_BASES } from '../../../utils/restAPI'
import { buildUrl } from '../../../utils/urls'
import { Button } from '../../common/Button'
import { SubmitButton } from '../../common/SubmitButton'
import { SearchArea } from './SnippetsTableSearch'
import { useFilteredSnippets } from './WithFilteredSnippetsContext'
Expand Down Expand Up @@ -128,7 +132,36 @@ export const SnippetsTableNavigation: React.FC<SnippetsTableNavigationProps> = (
{'top' === which && <SearchArea />}
</>

const ExportAllButton: React.FC = () => {
const snippetsApi = useSnippetsAPI()
const [isExporting, setIsExporting] = useState(false)

const handleExportAll = async (): Promise<void> => {
setIsExporting(true)

try {
const snippets = await snippetsApi.fetchAll(isNetworkAdmin())
downloadBulkSnippetExportFile(snippets)
} catch (error: unknown) {
handleUnknownError(error)
} finally {
setIsExporting(false)
}
}

return <div className="alignright actions">
<Button
secondary
disabled={isExporting}
onClick={() => void handleExportAll()}
>
{isExporting ? __('Exporting…', 'code-snippets') : __('Export All', 'code-snippets')}
</Button>
</div>
}

export const SnippetsTableToolbar = () =>
<div className="snippets-table-toolbar">
<SnippetStatusCounts />
<ExportAllButton />
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const BULK_ACTIONS: ListTableAction<SnippetsTableAction>[] = [

export const TRASHED_BULK_ACTIONS: ListTableAction<SnippetsTableAction>[] = [
{ key: 'restore', label: __('Restore', 'code-snippets') },
{ key: 'export', label: __('Export', 'code-snippets') },
{ key: 'delete', label: __('Delete Permanently', 'code-snippets') }
]

Expand Down
35 changes: 35 additions & 0 deletions tests/e2e/code-snippets-edit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
109 changes: 109 additions & 0 deletions tests/e2e/code-snippets-list.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[] }><unknown>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 }[] }><unknown>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 }[] }><unknown>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)
Expand Down
4 changes: 4 additions & 0 deletions tests/e2e/helpers/SnippetsTestHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ export class SnippetsTestHelper {
return Number(await wpCli(['eval', php]))
}

static async trashSnippet(snippetId: number): Promise<void> {
await wpCli(['eval', `\\Code_Snippets\\trash_snippet(${snippetId}, false);`])
}

static async cleanupSnippetsByPrefix(prefix: string): Promise<void> {
const php = `
global $wpdb;
Expand Down