setSnippet(previous => ({ ...previous, code_error: null }))}
+ onDismiss={() => setSnippet(previous => ({ ...previous, code_error: null, code_error_trace: null }))}
>
{sprintf(
diff --git a/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx b/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx
index 004a68ce..218e9180 100644
--- a/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx
+++ b/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx
@@ -8,6 +8,8 @@ import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchem
const SEARCH_PARAM = 's'
const SEARCH_METHOD_PARAM = 'by'
+const DEFAULT_SNIPPETS_PER_PAGE = 10
+const MAX_CLOUD_RESULTS_PER_PAGE = 100
export interface CloudSearchContext {
page: number
@@ -31,6 +33,10 @@ export const WithCloudSearchContext: React.FC = ({ children }
const [page, setPage] = useState(1)
const [query, setQuery] = useState(() => fetchQueryParam(SEARCH_PARAM) ?? '')
const [searchByCodevault, setSearchByCodevault] = useState(() => 'codevault' === fetchQueryParam(SEARCH_METHOD_PARAM))
+ const snippetsPerPage = Math.min(
+ window.CODE_SNIPPETS_MANAGE?.cloudSearchPerPage ?? window.CODE_SNIPPETS_MANAGE?.snippetsPerPage ?? DEFAULT_SNIPPETS_PER_PAGE,
+ MAX_CLOUD_RESULTS_PER_PAGE
+ )
const [totalItems, setTotalItems] = useState(0)
const [totalPages, setTotalPages] = useState(0)
@@ -45,21 +51,21 @@ export const WithCloudSearchContext: React.FC = ({ children }
updateQueryParam(SEARCH_METHOD_PARAM, searchByCodevault ? 'codevault' : 'term')
setIsSearching(true)
- api.getResponse(buildUrl(REST_BASES.cloud, { query, searchByCodevault, page }))
+ api.getResponse(
+ buildUrl(REST_BASES.cloud, { query, searchByCodevault, page, per_page: snippetsPerPage })
+ )
.then(response => {
- console.log(response.headers)
setTotalItems(Number(response.headers['x-wp-total']))
setTotalPages(Number(response.headers['x-wp-totalpages']))
setSearchResults(response.data)
setIsSearching(false)
})
- .catch((error: unknown) => {
- console.error(error)
+ .catch(() => {
setIsSearching(false)
setError(true)
})
}
- }, [api, page, query, searchByCodevault])
+ }, [api, page, query, searchByCodevault, snippetsPerPage])
const value: CloudSearchContext = {
page,
diff --git a/src/js/components/ManageMenu/ManageMenu.tsx b/src/js/components/ManageMenu/ManageMenu.tsx
index 8710cedc..efc0b9b4 100644
--- a/src/js/components/ManageMenu/ManageMenu.tsx
+++ b/src/js/components/ManageMenu/ManageMenu.tsx
@@ -1,4 +1,4 @@
-import React, { useMemo } from 'react'
+import React, { useEffect, useMemo } from 'react'
import { fetchQueryParam } from '../../utils/urls'
import { Toolbar } from '../common/Toolbar'
import { CommunityCloud } from './CommunityCloud/CommunityCloud'
@@ -7,6 +7,26 @@ import { SnippetsTable } from './SnippetsTable'
export const ManageMenu = () => {
const subpage = useMemo(() => fetchQueryParam('subpage'), [])
+ useEffect(() => {
+ if ('cloud-community' === subpage) {
+ return
+ }
+
+ const screenOptionsForm = document.getElementById('adv-settings')
+ const tableOptions = screenOptionsForm?.querySelector('fieldset.table-options-prefs')
+ // Locate the first column-visibility fieldset that is NOT the table-options one.
+ // This relies on WordPress core rendering #adv-settings with .metabox-prefs fieldsets.
+ // Verified against WP 6.5+ (core/Screen_Options). If WP changes this structure the
+ // reordering will silently no-op, which is acceptable — it is a cosmetic improvement only.
+ const columns = Array.from(
+ screenOptionsForm?.querySelectorAll('fieldset.metabox-prefs') ?? []
+ ).find(fieldset => !fieldset.classList.contains('table-options-prefs'))
+
+ if (screenOptionsForm && tableOptions && columns) {
+ screenOptionsForm.insertBefore(tableOptions, columns)
+ }
+ }, [subpage])
+
return (
<>
diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx
index 0d1b4760..6df6385f 100644
--- a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx
+++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx
@@ -1,9 +1,11 @@
import { __, _x, sprintf } from '@wordpress/i18n'
-import React, { Fragment, useEffect, useMemo } from 'react'
+import React, { Fragment, useEffect, useMemo, useState } from 'react'
+import classnames from 'classnames'
import { createInterpolateElement } from '@wordpress/element'
import { useRestAPI } from '../../../hooks/useRestAPI'
import { useSnippetsList } from '../../../hooks/useSnippetsList'
import { handleUnknownError } from '../../../utils/errors'
+import { downloadBulkSnippetExportFile } from '../../../utils/files'
import { REST_BASES } from '../../../utils/restAPI'
import { getSnippetType } from '../../../utils/snippets/snippets'
import { buildUrl } from '../../../utils/urls'
@@ -11,38 +13,11 @@ import { ListTable } from '../../common/ListTable'
import { SubmitButton } from '../../common/SubmitButton'
import { INDEX_STATUS, useSnippetsFilters } from './WithSnippetsTableFilters'
import { useFilteredSnippets } from './WithFilteredSnippetsContext'
-import { TableColumns } from './TableColumns'
+import { getTableColumns } from './TableColumns'
import type { SnippetStatus} from './WithSnippetsTableFilters'
import type { ListTableBulkAction } from '../../common/ListTable'
import type { Snippet } from '../../../types/Snippet'
-const actions: ListTableBulkAction[] = [
- {
- name: __('Activate', 'code-snippets'),
- apply: () => Promise.resolve()
- },
- {
- name: __('Deactivate', 'code-snippets'),
- apply: () => Promise.resolve()
- },
- {
- name: __('Clone', 'code-snippets'),
- apply: () => Promise.resolve()
- },
- {
- name: __('Export', 'code-snippets'),
- apply: () => Promise.resolve()
- },
- {
- name: __('Export code', 'code-snippets'),
- apply: () => Promise.resolve()
- },
- {
- name: __('Trash', 'code-snippets'),
- apply: () => Promise.resolve()
- }
-]
-
const STATUS_LABELS: [SnippetStatus, string][] = [
['all', __('All', 'code-snippets')],
['active', __('Active', 'code-snippets')],
@@ -53,6 +28,59 @@ const STATUS_LABELS: [SnippetStatus, string][] = [
['trashed', __('Trashed', 'code-snippets')]
]
+const BULK_DOWNLOAD_ACTION = 'bulk-download'
+const INDIVIDUAL_DOWNLOAD_DELAY_MS = 200
+
+const appendHiddenField = (form: HTMLFormElement, name: string, value: string) => {
+ const input = document.createElement('input')
+ input.type = 'hidden'
+ input.name = name
+ input.value = value
+ form.appendChild(input)
+}
+
+const submitBulkSnippetDownload = (snippets: readonly Snippet[]): Promise => {
+ if (0 === snippets.length) {
+ return Promise.resolve()
+ }
+
+ const form = document.createElement('form')
+
+ form.method = 'post'
+ form.action = window.location.href
+ form.hidden = true
+
+ appendHiddenField(form, 'code_snippets_action', BULK_DOWNLOAD_ACTION)
+ appendHiddenField(form, 'code_snippets_bulk_download_nonce', window.CODE_SNIPPETS_MANAGE?.bulkDownloadNonce ?? '')
+ appendHiddenField(
+ form,
+ 'snippets',
+ JSON.stringify(snippets.map(({ id, network }) => ({ id, network })))
+ )
+
+ document.body.appendChild(form)
+ form.submit()
+
+ window.setTimeout(() => {
+ form.remove()
+ }, 0)
+
+ return Promise.resolve()
+}
+
+const submitBulkSnippetDownloadsIndividually = (snippets: readonly Snippet[]): Promise =>
+ snippets.reduce(
+ (promise, snippet) =>
+ promise.then(
+ () =>
+ new Promise(resolve => {
+ void submitBulkSnippetDownload([snippet])
+ window.setTimeout(resolve, INDIVIDUAL_DOWNLOAD_DELAY_MS)
+ })
+ ),
+ Promise.resolve()
+ )
+
const SnippetStatusCounts = () => {
const { currentStatus, setCurrentStatus } = useSnippetsFilters()
const { snippetsByStatus } = useFilteredSnippets()
@@ -112,6 +140,41 @@ interface ExtraTableNavProps {
visibleSnippets: Snippet[]
}
+const useManageTableSettings = (): { hiddenColumns: string[], truncateRowValues: boolean } => {
+ const [hiddenColumns, setHiddenColumns] = useState(() => window.CODE_SNIPPETS_MANAGE?.hiddenColumns ?? [])
+ const [truncateRowValues, setTruncateRowValues] = useState(
+ () => 0 !== Number(window.CODE_SNIPPETS_MANAGE?.truncateRowValues ?? 1)
+ )
+
+ useEffect(() => {
+ const screenOptions = document.getElementById('adv-settings')
+
+ if (!screenOptions) {
+ return
+ }
+
+ const updateHiddenColumns = () => {
+ setHiddenColumns(
+ Array.from(screenOptions.querySelectorAll('.hide-column-tog:not(:checked)'))
+ .map(toggle => toggle.value)
+ )
+
+ setTruncateRowValues(
+ screenOptions.querySelector('#snippets-table-truncate-row-values')?.checked ?? true
+ )
+ }
+
+ updateHiddenColumns()
+ screenOptions.addEventListener('change', updateHiddenColumns)
+
+ return () => {
+ screenOptions.removeEventListener('change', updateHiddenColumns)
+ }
+ }, [])
+
+ return { hiddenColumns, truncateRowValues }
+}
+
const FilterByTagControl: React.FC = ({ visibleSnippets }) => {
const { currentTag, setCurrentTag } = useSnippetsFilters()
@@ -172,12 +235,62 @@ const NoItemsMessage = () => {
>
}
+const useBulkActions = (allSnippets: Snippet[]): ListTableBulkAction[] =>
+{
+ return useMemo(
+ () => [
+ {
+ name: __('Activate', 'code-snippets'),
+ apply: () => Promise.resolve()
+ },
+ {
+ name: __('Deactivate', 'code-snippets'),
+ apply: () => Promise.resolve()
+ },
+ {
+ name: __('Clone', 'code-snippets'),
+ apply: () => Promise.resolve()
+ },
+ {
+ name: __('Export', 'code-snippets'),
+ apply: (selected: Set) => {
+ downloadBulkSnippetExportFile(
+ allSnippets.filter(snippet => selected.has(snippet.id))
+ )
+ return Promise.resolve()
+ }
+ },
+ {
+ name: __('Download', 'code-snippets'),
+ apply: (selected: Set) => {
+ const selectedSnippets = allSnippets.filter(snippet => selected.has(snippet.id))
+
+ if (1 < selectedSnippets.length && !window.CODE_SNIPPETS_MANAGE?.supportsZipDownloads) {
+ return submitBulkSnippetDownloadsIndividually(selectedSnippets)
+ }
+
+ return submitBulkSnippetDownload(selectedSnippets)
+ }
+ },
+ {
+ name: __('Trash', 'code-snippets'),
+ apply: () => Promise.resolve()
+ }
+ ],
+ [allSnippets]
+ )
+}
+
export const SnippetsListTable: React.FC = () => {
const { currentStatus, setCurrentStatus } = useSnippetsFilters()
const { snippetsByStatus } = useFilteredSnippets()
+ const { hiddenColumns, truncateRowValues } = useManageTableSettings()
+ const allSnippets = useMemo(() => snippetsByStatus.get('all') ?? [], [snippetsByStatus])
const totalItems = snippetsByStatus.get(currentStatus)?.length ?? 0
const itemsPerPage = window.CODE_SNIPPETS_MANAGE?.snippetsPerPage
+ const columns = useMemo(() => getTableColumns(hiddenColumns), [hiddenColumns])
+ const actions = useBulkActions(allSnippets)
useEffect(() => {
if (INDEX_STATUS !== currentStatus && !snippetsByStatus.has(currentStatus)) {
@@ -193,7 +306,8 @@ export const SnippetsListTable: React.FC = () => {
snippet.id}
- columns={TableColumns}
+ className={classnames({ 'truncate-row-values': truncateRowValues })}
+ columns={columns}
actions={actions}
totalPages={itemsPerPage && Math.ceil(totalItems / itemsPerPage)}
extraTableNav={which =>
diff --git a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx
index 6735ccb9..ff4335ac 100644
--- a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx
+++ b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx
@@ -204,7 +204,7 @@ const TagsColumn: React.FC = ({ snippet }) =>
const DateColumn: React.FC = ({ snippet }) =>
snippet.modified
- ?
+ ?
@@ -248,7 +248,15 @@ const PriorityColumn: React.FC = ({ snippet }) => {
)
}
-export const TableColumns: ListTableColumn[] = [
+const withHiddenState = (
+ column: ListTableColumn,
+ hiddenColumns: readonly string[]
+): ListTableColumn => ({
+ ...column,
+ isHidden: hiddenColumns.includes(column.id.toString())
+})
+
+const baseTableColumns: ListTableColumn[] = [
{
id: 'activate',
render: snippet =>
@@ -269,7 +277,7 @@ export const TableColumns: ListTableColumn[] = [
{
id: 'desc',
title: __('Description', 'code-snippets'),
- render: snippet => {snippet.desc}
+ render: snippet => {snippet.desc}
},
{
id: 'tags',
@@ -289,3 +297,6 @@ export const TableColumns: ListTableColumn[] = [
render: snippet =>
}
]
+
+export const getTableColumns = (hiddenColumns: readonly string[] = []): ListTableColumn[] =>
+ baseTableColumns.map(column => withHiddenState(column, hiddenColumns))
diff --git a/src/js/components/common/ListTable/ListTable.tsx b/src/js/components/common/ListTable/ListTable.tsx
index edbd8fcd..d7b7621f 100644
--- a/src/js/components/common/ListTable/ListTable.tsx
+++ b/src/js/components/common/ListTable/ListTable.tsx
@@ -94,6 +94,54 @@ const pageItems = (
}
}
+const getVisibleSelected = (visibleItems: T[], getKey: (item: T) => K, selected: Set): Set =>
+ new Set(visibleItems.map(getKey).filter(key => selected.has(key)))
+
+interface ListTableMarkupProps {
+ className?: string
+ fixed?: boolean
+ striped?: boolean
+ getKey: (item: T) => K
+ columns: ListTableColumn[]
+ noItems?: ReactNode
+ rowClassName?: (item: T) => string
+ tableNavProps: Omit, 'which'>
+ tableHeadingsProps: Omit, 'which'>
+ visibleItems: T[]
+}
+
+const ListTableMarkup = ({
+ fixed,
+ striped,
+ getKey,
+ columns,
+ noItems,
+ className,
+ rowClassName,
+ tableNavProps,
+ tableHeadingsProps,
+ visibleItems
+}: ListTableMarkupProps) => (
+ <>
+
+
+
+ >
+)
+
export const ListTable = ({
items,
fixed,
@@ -117,28 +165,33 @@ export const ListTable = ({
const visibleItems: T[] = useMemo(
() => pageItems(sortItems(items, sortColumn, sortDirection), { currentPage, totalPages }),
[items, sortColumn, sortDirection, currentPage, totalPages])
-
- const tableNavProps: Omit, 'which'> =
- { totalItems: items.length, actions, extraTableNav, selected, disabled, currentPage, totalPages, setCurrentPage, useQueryVars }
+ const tableNavProps = {
+ totalItems: items.length,
+ actions,
+ extraTableNav,
+ selected: getVisibleSelected(visibleItems, getKey, selected),
+ disabled,
+ currentPage,
+ totalPages,
+ setCurrentPage,
+ useQueryVars
+ }
const tableHeadingsProps: Omit, 'which'> =
{ items: visibleItems, setSelected, columns, getKey, sortColumn, setSortColumn, sortDirection, setSortDirection }
- return (
- <>
-
-
-
- >
- )
+ return
}
diff --git a/src/js/components/common/ListTable/TableHeadings.tsx b/src/js/components/common/ListTable/TableHeadings.tsx
index 55cbf909..3930dc86 100644
--- a/src/js/components/common/ListTable/TableHeadings.tsx
+++ b/src/js/components/common/ListTable/TableHeadings.tsx
@@ -4,51 +4,6 @@ import { __ } from '@wordpress/i18n'
import type { ListTableColumn, ListTableProps, ListTableSortDirection } from './ListTable'
import type { Dispatch, Key, SetStateAction, ThHTMLAttributes } from 'react'
-interface SortableHeadingProps {
- column: ListTableColumn
- cellProps: ThHTMLAttributes
- sortColumn: ListTableColumn | undefined
- sortDirection: ListTableSortDirection
- setSortColumn: Dispatch | undefined>>
- setSortDirection: Dispatch>
-}
-
-const SortableHeading = ({
- column,
- cellProps,
- sortColumn,
- setSortColumn,
- sortDirection,
- setSortDirection
-}: SortableHeadingProps) => {
- const isCurrent = column.id === sortColumn?.id
-
- const newSortDirection = isCurrent
- ? 'asc' === sortDirection ? 'desc' : 'asc'
- : column.defaultSortDirection ?? 'asc'
-
- return (
-
- {
- event.preventDefault()
- setSortColumn(column)
- setSortDirection(newSortDirection)
- }}>
- {column.title}
-
-
-
-
- {isCurrent ? null
- :
- {/* translators: Hidden accessibility text. */}
- {'asc' === newSortDirection ? __('Sort ascending.', 'code-snippets') : __('Sort descending.', 'code-snippets')}
- }
-
- |
- )
-}
-
export interface TableHeadingsProps extends Pick, 'columns' | 'getKey' | 'items'> {
which: 'head' | 'foot'
sortColumn: ListTableColumn | undefined
@@ -76,7 +31,7 @@ export const TableHeadings = ({
type="checkbox"
name="checked[]"
onChange={event => {
- setSelected(new Set(event.target.checked ? items.map(getKey) : null))
+ setSelected(new Set(event.target.checked ? items.map(getKey) : []))
}}
/>
-
+
{
event.preventDefault()
@@ -74,7 +87,6 @@ const BulkActions = ({ which, actions, applyAction, disabled }: B
applyAction(selectedAction)
.catch(handleUnknownError)
.finally(() => {
- setSelectedAction(undefined)
setIsPerformingAction(false)
})
}
@@ -93,7 +105,7 @@ export interface TableNavProps extends ListTableNavProps, Omit
totalPages: number | undefined
}
-export const TableNav = ({
+export const TableNav = ({
which,
actions,
selected,
diff --git a/src/js/hooks/useSubmitSnippet.ts b/src/js/hooks/useSubmitSnippet.tsx
similarity index 80%
rename from src/js/hooks/useSubmitSnippet.ts
rename to src/js/hooks/useSubmitSnippet.tsx
index e8f56976..10570d60 100644
--- a/src/js/hooks/useSubmitSnippet.ts
+++ b/src/js/hooks/useSubmitSnippet.tsx
@@ -1,13 +1,14 @@
import { __ } from '@wordpress/i18n'
import { isAxiosError } from 'axios'
-import { useCallback } from 'react'
+import React, { useCallback } from 'react'
import { useSnippetForm } from '../components/EditMenu/SnippetForm/WithSnippetFormContext'
import { createSnippetObject, isCondition } from '../utils/snippets/snippets'
import { buildUrl } from '../utils/urls'
import { useSnippetsAPI } from './useSnippetsAPI'
import type { Snippet } from '../types/Snippet'
+import type { ScreenNotice } from '../types/ScreenNotice'
-const snippetMessages = {
+const snippetMessages = {
addNew: __('Create New Snippet', 'code-snippets'),
edit: __('Edit Snippet', 'code-snippets'),
created: __('Snippet created.', 'code-snippets'),
@@ -18,7 +19,7 @@ const snippetMessages = {
updatedExecuted: __('Snippet updated and executed.', 'code-snippets'),
failedCreate: __('Could not create snippet.', 'code-snippets'),
failedUpdate: __('Could not update snippet.', 'code-snippets')
-}
+} as const
const conditionCreated = __('Condition created.', 'code-snippets')
const conditionUpdated = __('Condition updated.', 'code-snippets')
@@ -72,6 +73,17 @@ const getSuccessNotice = (
}
}
+const getActivationErrorNotice = (snippet: Snippet): ScreenNotice => [
+ 'error',
+ __('Snippet could not be activated.', 'code-snippets'),
+
+ {__('The snippet was saved, but remains inactive due to this error:', 'code-snippets')}
+ {' '}
+ {snippet.code_error?.[0] ?? ''}
+ ,
+ snippet.code_error_trace ?? undefined
+]
+
export interface UseSubmitSnippet {
submitSnippet: (snippet: Partial & Pick, action?: SubmitSnippetAction) => Promise
}
@@ -84,13 +96,21 @@ export const useSubmitSnippet = (): UseSubmitSnippet => {
setCurrentNotice(undefined)
setIsWorking(true)
+ const request = { ...snippet }
+
+ if (SubmitSnippetAction.SAVE_AND_ACTIVATE === action) {
+ request.active = true
+ } else if (SubmitSnippetAction.SAVE_AND_DEACTIVATE === action) {
+ request.active = false
+ }
+
const result = await (async (): Promise => {
try {
- const { id } = snippet
+ const { id } = request
const response = await (undefined === id || 0 === id
- ? api.create(snippet)
- : api.update({ ...snippet, id }))
+ ? api.create(request)
+ : api.update({ ...request, id }))
return response.id ? createSnippetObject(response) : undefined
} catch (error) {
@@ -104,7 +124,7 @@ export const useSubmitSnippet = (): UseSubmitSnippet => {
if (undefined === result || 'string' === typeof result) {
const message = [
- snippet.id ? messages.failedUpdate : messages.failedCreate,
+ request.id ? messages.failedUpdate : messages.failedCreate,
result ?? __('The server did not send a valid response.', 'code-snippets')
]
@@ -114,16 +134,13 @@ export const useSubmitSnippet = (): UseSubmitSnippet => {
setSnippet(result)
- if (result.code_error) {
- setCurrentNotice([
- 'error',
- __('Snippet could not be activated because the code contains an error. See details below.', 'code-snippets')
- ])
+ if (result.code_error && SubmitSnippetAction.SAVE_AND_ACTIVATE === action) {
+ setCurrentNotice(getActivationErrorNotice(result))
} else {
setCurrentNotice(['updated', getSuccessNotice(snippet, result, action)])
}
- if (snippet.id && result.id) {
+ if (request.id && result.id) {
window.document.title = window.document.title.replace(snippetMessages.addNew, messages.edit)
window.history.replaceState({}, '', buildUrl(window.CODE_SNIPPETS?.urls.edit, { id: result.id }))
}
diff --git a/src/js/types/ScreenNotice.ts b/src/js/types/ScreenNotice.ts
index 9b2c52a2..9c10ce8e 100644
--- a/src/js/types/ScreenNotice.ts
+++ b/src/js/types/ScreenNotice.ts
@@ -1 +1,3 @@
-export type ScreenNotice = ['error' | 'updated', string]
+import type { ReactNode } from 'react'
+
+export type ScreenNotice = ['error' | 'updated', ReactNode, ReactNode?, string?]
diff --git a/src/js/types/Snippet.ts b/src/js/types/Snippet.ts
index 806069a9..711a525e 100644
--- a/src/js/types/Snippet.ts
+++ b/src/js/types/Snippet.ts
@@ -15,6 +15,7 @@ export interface Snippet {
readonly conditionId: number
readonly lastActive?: number
readonly code_error?: readonly [string, number] | null
+ readonly code_error_trace?: string | null
}
export const SNIPPET_TYPES = ['php', 'html', 'css', 'js', 'cond']
diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts
index a591aef9..9770cd30 100644
--- a/src/js/types/Window.ts
+++ b/src/js/types/Window.ts
@@ -57,8 +57,13 @@ declare global {
readonly CODE_SNIPPETS_MANAGE?: {
snippetsList: Snippet[]
hasNetworkCap: boolean
+ hiddenColumns: string[]
+ truncateRowValues: number | string
snippetsPerPage: number
+ cloudSearchPerPage: number
isSafeModeActive: boolean
+ bulkDownloadNonce: string
+ supportsZipDownloads: boolean
}
readonly CODE_SNIPPETS_EDIT?: {
snippet: Snippet
diff --git a/src/js/types/schema/SnippetSchema.ts b/src/js/types/schema/SnippetSchema.ts
index 89bea458..bdc6bc67 100644
--- a/src/js/types/schema/SnippetSchema.ts
+++ b/src/js/types/schema/SnippetSchema.ts
@@ -20,4 +20,5 @@ export interface SnippetSchema extends Readonly>
readonly modified: string
readonly last_active?: number
readonly code_error?: readonly [string, number] | null
+ readonly code_error_trace?: string | null
}
diff --git a/src/js/types/schema/SnippetsExport.ts b/src/js/types/schema/SnippetsExport.ts
index 8cb8239f..b8543c38 100644
--- a/src/js/types/schema/SnippetsExport.ts
+++ b/src/js/types/schema/SnippetsExport.ts
@@ -1,7 +1,7 @@
-import type { Snippet } from '../Snippet'
+import type { SnippetSchema } from './SnippetSchema'
export interface SnippetsExport {
generator: string
date_created: string
- snippets: Snippet[]
+ snippets: Partial[]
}
diff --git a/src/js/utils/files.ts b/src/js/utils/files.ts
index f87d4b66..69b8d041 100644
--- a/src/js/utils/files.ts
+++ b/src/js/utils/files.ts
@@ -5,6 +5,10 @@ import type { Snippet } from '../types/Snippet'
const SECOND_IN_MS = 1000
const TIMEOUT_SECONDS = 40
const JSON_INDENT_SPACES = 2
+const EXPORT_FILENAME = 'snippets'
+const EXPORT_GENERATOR = 'Code Snippets'
+const DEFAULT_PRIORITY = 10
+const EXPORT_DATE_LENGTH = 16
const MIME_INFO = {
php: ['php', 'text/php'],
@@ -50,3 +54,92 @@ export const downloadSnippetExportFile = (
downloadAsFile(JSON.stringify(content, undefined, JSON_INDENT_SPACES), filename, 'application/json')
}
}
+
+type DefaultExportValueCheck = (value: unknown) => boolean
+
+const isNullish = (value: unknown): value is undefined | null => undefined === value || null === value
+
+const DEFAULT_EXPORT_VALUE_CHECKS: Record = {
+ id: value => 0 === value,
+ desc: value => '' === value,
+ name: value => '' === value,
+ code: value => '' === value,
+ tags: value => Array.isArray(value) && 0 === value.length,
+ scope: value => 'global' === value,
+ condition_id: value => 0 === value,
+ active: value => false === value,
+ locked: value => false === value,
+ trashed: value => false === value,
+ priority: value => DEFAULT_PRIORITY === value,
+ modified: value => '' === value || isNullish(value),
+ last_active: value => 0 === value || isNullish(value),
+ network: value => null === value || false === value,
+ shared_network: value => null === value || false === value,
+ code_error: value => null === value || false === value,
+ code_error_trace: value => null === value || false === value
+}
+
+const isDefaultExportValue = (field: string, value: unknown): boolean =>
+ (DEFAULT_EXPORT_VALUE_CHECKS[field] ?? isNullish)(value)
+
+const buildExportSnippet = ({
+ id,
+ name,
+ desc,
+ code,
+ tags,
+ scope,
+ active,
+ locked,
+ trashed,
+ network,
+ shared_network,
+ modified,
+ priority,
+ conditionId,
+ lastActive,
+ code_error,
+ code_error_trace
+}: Snippet): SnippetsExport['snippets'][number] => {
+ const exportSnippet: SnippetsExport['snippets'][number] = Object.fromEntries(
+ Object.entries({
+ id,
+ name,
+ desc,
+ code,
+ tags,
+ scope,
+ active,
+ locked,
+ trashed,
+ network,
+ shared_network,
+ modified,
+ priority,
+ condition_id: conditionId,
+ last_active: lastActive,
+ code_error,
+ code_error_trace
+ }).filter(([field, value]) => !isDefaultExportValue(field, value))
+ )
+
+ return exportSnippet
+}
+
+export const downloadBulkSnippetExportFile = (snippets: readonly Snippet[]) => {
+ if (0 === snippets.length) {
+ return
+ }
+
+ const content: SnippetsExport = {
+ generator: EXPORT_GENERATOR,
+ date_created: new Date().toISOString().slice(0, EXPORT_DATE_LENGTH).replace('T', ' '),
+ snippets: snippets.map(buildExportSnippet)
+ }
+
+ downloadAsFile(
+ JSON.stringify(content, undefined, JSON_INDENT_SPACES),
+ `${EXPORT_FILENAME}.code-snippets.json`,
+ 'application/json'
+ )
+}
diff --git a/src/js/utils/snippets/objects.ts b/src/js/utils/snippets/objects.ts
index eb27c215..3d5439fa 100644
--- a/src/js/utils/snippets/objects.ts
+++ b/src/js/utils/snippets/objects.ts
@@ -18,7 +18,8 @@ const defaults: Omit = {
shared_network: null,
priority: 10,
conditionId: 0,
- code_error: null
+ code_error: null,
+ code_error_trace: null
}
const isAbsInt = (value: unknown): value is number =>
@@ -59,6 +60,8 @@ export const parseSnippetObject = (fields: unknown): Snippet => {
...'priority' in fields && 'number' === typeof fields.priority && { priority: fields.priority },
...'condition_id' in fields && isAbsInt(fields.condition_id) && { conditionId: fields.condition_id },
...'code_error' in fields && isCodeError(fields.code_error) && { code_error: fields.code_error },
+ ...'code_error_trace' in fields && ('string' === typeof fields.code_error_trace || null === fields.code_error_trace) &&
+ { code_error_trace: fields.code_error_trace },
...'last_active' in fields && { lastActive: Number(fields.last_active) }
}
}
diff --git a/src/php/Admin/Menus/Edit_Menu.php b/src/php/Admin/Menus/Edit_Menu.php
index 38224c1f..59591e36 100644
--- a/src/php/Admin/Menus/Edit_Menu.php
+++ b/src/php/Admin/Menus/Edit_Menu.php
@@ -4,6 +4,7 @@
use Code_Snippets\Admin\Contextual_Help;
use Code_Snippets\Model\Snippet;
+use WP_Screen;
use function Code_Snippets\code_snippets;
use function Code_Snippets\get_all_snippet_tags;
use function Code_Snippets\get_snippet;
@@ -48,6 +49,8 @@ public function __construct() {
__( 'Edit Snippet', 'code-snippets' )
);
+ add_action( 'current_screen', array( $this, 'maybe_hide_menu_item' ) );
+
$this->remove_debug_bar_codemirror();
}
@@ -59,12 +62,6 @@ public function __construct() {
public function register() {
parent::register();
- // Only preserve the edit menu if we are currently editing a snippet.
- // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- if ( ! isset( $_REQUEST['page'] ) || $_REQUEST['page'] !== $this->slug ) {
- remove_submenu_page( $this->base_slug, $this->slug );
- }
-
// Create New Snippet menu.
$this->add_menu(
code_snippets()->get_menu_slug( 'add' ),
@@ -73,6 +70,26 @@ public function register() {
);
}
+ /**
+ * Hide the static Edit Snippet menu item unless a specific snippet is being edited.
+ *
+ * @param WP_Screen $screen Current admin screen.
+ *
+ * @return void
+ */
+ public function maybe_hide_menu_item( WP_Screen $screen ) {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin menu context.
+ $current_id = isset( $_GET['id'] ) ? absint( $_GET['id'] ) : 0;
+ $edit_hook = get_plugin_page_hookname( $this->slug, $this->base_slug );
+ $edit_hook .= $screen->in_admin( 'network' ) ? '-network' : '';
+
+ if ( ( $screen->id === $edit_hook || $screen->base === $edit_hook ) && 0 < $current_id ) {
+ return;
+ }
+
+ remove_submenu_page( $this->base_slug, $this->slug );
+ }
+
/**
* Executed when the menu is loaded.
*
diff --git a/src/php/Admin/Menus/Manage_Menu.php b/src/php/Admin/Menus/Manage_Menu.php
index 3feea5b2..a42c41fd 100644
--- a/src/php/Admin/Menus/Manage_Menu.php
+++ b/src/php/Admin/Menus/Manage_Menu.php
@@ -3,8 +3,11 @@
namespace Code_Snippets\Admin\Menus;
use Code_Snippets\Admin\Contextual_Help;
+use Code_Snippets\Migration\Export\Download_Code;
use Code_Snippets\Utils\Code_Highlighter;
+use WP_Error;
use function Code_Snippets\code_snippets;
+use function Code_Snippets\get_snippet;
use function Code_Snippets\get_snippets;
use function Code_Snippets\Settings\get_setting;
use const Code_Snippets\PLUGIN_FILE;
@@ -28,6 +31,11 @@ class Manage_Menu extends Admin_Menu {
*/
public const CSS_HANDLE = 'code-snippets-manage';
+ /**
+ * Default number of snippets shown per page in the manage table.
+ */
+ public const DEFAULT_SNIPPETS_PER_PAGE = 100;
+
/**
* Class constructor
*/
@@ -44,8 +52,10 @@ public function __construct() {
}
add_action( 'admin_menu', array( $this, 'register_upgrade_menu' ), 500 );
+ add_action( 'admin_init', array( $this, 'handle_bulk_download_request' ) );
+ add_action( 'admin_init', array( $this, 'save_truncation_preference' ) );
add_filter( 'set-screen-option', array( $this, 'save_screen_option' ), 10, 3 );
- add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_menu_css' ] );
+ add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_menu_css' ) );
add_action( 'wp_ajax_update_code_snippet', array( $this, 'ajax_callback' ) );
}
@@ -162,17 +172,24 @@ public function register_compact_menu() {
public function load() {
parent::load();
- $contextual_help = new Contextual_Help( 'edit' );
- $contextual_help->load();
+ $screen = get_current_screen();
+
+ if ( $screen && ! $this->is_cloud_community_view() ) {
+ add_filter( "manage_{$screen->id}_columns", array( $this, 'get_screen_columns' ) );
+ add_filter( 'screen_settings', array( $this, 'render_screen_settings' ), 10, 2 );
+ }
add_screen_option(
'per_page',
array(
'label' => __( 'Snippets per page', 'code-snippets' ),
- 'default' => 999,
+ 'default' => $this->get_default_snippets_per_page(),
'option' => 'snippets_per_page',
)
);
+
+ $contextual_help = new Contextual_Help( 'edit' );
+ $contextual_help->load();
}
/**
@@ -205,10 +222,15 @@ public function enqueue_assets() {
self::JS_HANDLE,
'CODE_SNIPPETS_MANAGE',
[
- 'hasNetworkCap' => current_user_can( code_snippets()->get_network_cap_name() ),
- 'snippetsPerPage' => $this->get_snippets_per_page(),
- 'isSafeModeActive' => code_snippets()->evaluate_functions->is_safe_mode_active(),
- 'snippetsList' => array_map(
+ 'hasNetworkCap' => current_user_can( code_snippets()->get_network_cap_name() ),
+ 'hiddenColumns' => $this->get_hidden_manage_columns(),
+ 'truncateRowValues' => (int) $this->truncate_row_values(),
+ 'snippetsPerPage' => $this->get_snippets_per_page(),
+ 'cloudSearchPerPage' => $this->get_cloud_search_per_page(),
+ 'isSafeModeActive' => code_snippets()->evaluate_functions->is_safe_mode_active(),
+ 'bulkDownloadNonce' => wp_create_nonce( 'code_snippets_bulk_download' ),
+ 'supportsZipDownloads' => class_exists( 'ZipArchive' ),
+ 'snippetsList' => array_map(
function ( $snippet ) {
return $snippet->get_fields();
},
@@ -227,12 +249,35 @@ protected function get_snippets_per_page(): int {
$per_page = (int) get_user_option( 'snippets_per_page' );
if ( empty( $per_page ) || $per_page < 1 ) {
- $per_page = 999;
+ $per_page = $this->get_default_snippets_per_page();
}
return (int) apply_filters( 'snippets_per_page', $per_page );
}
+ /**
+ * Get the default number of snippets to show per page.
+ *
+ * @return int
+ */
+ protected function get_default_snippets_per_page(): int {
+ $default = (int) apply_filters( 'code_snippets/snippets_per_page_default', self::DEFAULT_SNIPPETS_PER_PAGE );
+
+ return max( 1, $default );
+ }
+
+ /**
+ * Get the number of snippets to show per page in the cloud search.
+ *
+ * The value defaults to the user's local snippets-per-page preference but can
+ * be overridden independently via the `code_snippets/cloud_search/per_page` filter.
+ *
+ * @return int
+ */
+ protected function get_cloud_search_per_page(): int {
+ return (int) apply_filters( 'code_snippets/cloud_search/per_page', $this->get_snippets_per_page() );
+ }
+
/**
* Render the snippets table interface.
*
@@ -242,6 +287,135 @@ public function render() {
echo '';
}
+ /**
+ * Return the columns available in Screen Options for the snippets table.
+ *
+ * @param string[] $columns Existing columns.
+ *
+ * @return string[]
+ */
+ public function get_screen_columns( array $columns = array() ): array {
+ return array_merge(
+ $columns,
+ array(
+ '_title' => __( 'Columns', 'code-snippets' ),
+ 'activate' => __( 'Active', 'code-snippets' ),
+ 'name' => __( 'Name', 'code-snippets' ),
+ 'type' => __( 'Type', 'code-snippets' ),
+ 'desc' => __( 'Description', 'code-snippets' ),
+ 'tags' => __( 'Tags', 'code-snippets' ),
+ 'date' => __( 'Modified', 'code-snippets' ),
+ 'priority' => __( 'Priority', 'code-snippets' ),
+ )
+ );
+ }
+
+ /**
+ * Get the list of columns hidden for the current user on the snippets screen.
+ *
+ * @return string[]
+ */
+ protected function get_hidden_manage_columns(): array {
+ $screen = get_current_screen();
+
+ return $screen ? get_hidden_columns( $screen ) : array();
+ }
+
+ /**
+ * Whether to truncate long row values in the snippets table.
+ *
+ * @return bool
+ */
+ protected function truncate_row_values(): bool {
+ $setting = get_user_option( 'snippets_table_truncate_row_values' );
+
+ return false === $setting ? true : (bool) $setting;
+ }
+
+ /**
+ * Whether the current manage subpage is the Community Cloud view.
+ *
+ * @return bool
+ */
+ protected function is_cloud_community_view(): bool {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing parameter.
+ $subpage = isset( $_REQUEST['subpage'] ) ? sanitize_key( wp_unslash( $_REQUEST['subpage'] ) ) : '';
+
+ return 'cloud-community' === $subpage;
+ }
+
+ /**
+ * Render extra Screen Options controls for the snippets table.
+ *
+ * @param string $screen_settings Existing screen settings HTML.
+ * @param \WP_Screen $screen Current screen object.
+ *
+ * @return string
+ */
+ public function render_screen_settings( string $screen_settings, \WP_Screen $screen ): string {
+ if ( $this->is_cloud_community_view() ) {
+ return $screen_settings;
+ }
+
+ ob_start();
+ ?>
+
+ get_menu_slug() !== $page || ! current_user_can( code_snippets()->get_cap() ) ) {
+ return;
+ }
+
+ if ( $this->is_cloud_community_view() ) {
+ return;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified here before persisting the option.
+ $nonce = isset( $_POST['screenoptionnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['screenoptionnonce'] ) ) : '';
+
+ if ( ! wp_verify_nonce( $nonce, 'screen-options-nonce' ) ) {
+ return;
+ }
+
+ update_user_option(
+ get_current_user_id(),
+ 'snippets_table_truncate_row_values',
+ // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified above before reading the checkbox state.
+ isset( $_POST['snippets_table_truncate_row_values'] ) ? 1 : 0
+ );
+ }
+
/**
* Handles saving the user's snippets per page preference
*
@@ -254,4 +428,153 @@ public function render() {
public function save_screen_option( $status, string $option, $value ) {
return 'snippets_per_page' === $option ? $value : $status;
}
+
+ /**
+ * Handle bulk snippet code downloads from the manage screen.
+ *
+ * @return void
+ */
+ public function handle_bulk_download_request(): void {
+ if ( ! $this->is_bulk_download_request() ) {
+ return;
+ }
+
+ if ( ! current_user_can( code_snippets()->get_cap() ) ) {
+ $this->send_download_error( __( 'You are not allowed to download these snippets.', 'code-snippets' ), 403 );
+ return;
+ }
+
+ $nonce = filter_input( INPUT_POST, 'code_snippets_bulk_download_nonce', FILTER_SANITIZE_FULL_SPECIAL_CHARS ) ?? '';
+
+ if ( ! wp_verify_nonce( $nonce, 'code_snippets_bulk_download' ) ) {
+ $this->send_download_error( __( 'The download request is no longer valid. Please refresh and try again.', 'code-snippets' ), 403 );
+ return;
+ }
+
+ $snippets = $this->get_requested_download_snippets();
+
+ if ( $snippets instanceof WP_Error ) {
+ $status = $snippets->get_error_data( 'status' );
+ $this->send_download_error( $snippets->get_error_message(), is_numeric( $status ) ? (int) $status : 403 );
+ return;
+ }
+
+ if ( empty( $snippets ) ) {
+ $this->send_download_error( __( 'No snippets were selected for download.', 'code-snippets' ) );
+ return;
+ }
+
+ $download = 1 === count( $snippets )
+ ? Download_Code::build_snippet_download( $snippets[0] )
+ : Download_Code::build_archive_download( $snippets );
+
+ if ( $download instanceof WP_Error ) {
+ $status = $download->get_error_data( 'status' );
+ $this->send_download_error( $download->get_error_message(), is_numeric( $status ) ? (int) $status : 500 );
+ return;
+ }
+
+ $this->send_download_response( $download );
+ }
+
+ /**
+ * Determine whether the current request is a bulk download request.
+ *
+ * @return bool
+ */
+ private function is_bulk_download_request(): bool {
+ $page = sanitize_key( filter_input( INPUT_GET, 'page' ) ?? '' );
+ $action = sanitize_key( filter_input( INPUT_POST, 'code_snippets_action' ) ?? '' );
+
+ return code_snippets()->get_menu_slug() === $page && 'bulk-download' === $action;
+ }
+
+ /**
+ * Resolve the snippets requested for download.
+ *
+ * @return array<\Code_Snippets\Model\Snippet>|WP_Error
+ */
+ private function get_requested_download_snippets() {
+ $snippets_json = wp_unslash( filter_input( INPUT_POST, 'snippets', FILTER_DEFAULT ) ?? '' );
+ $payload = '' === $snippets_json ? [] : json_decode( $snippets_json, true );
+
+ if ( ! is_array( $payload ) ) {
+ return [];
+ }
+
+ $snippets = [];
+
+ foreach ( $payload as $snippet_data ) {
+ if ( ! is_array( $snippet_data ) || empty( $snippet_data['id'] ) ) {
+ continue;
+ }
+
+ if ( ! empty( $snippet_data['network'] ) && ! current_user_can( code_snippets()->get_network_cap_name() ) ) {
+ return new WP_Error(
+ 'code_snippets_forbidden_network_download',
+ __( 'You are not allowed to download network snippets.', 'code-snippets' ),
+ [ 'status' => 403 ]
+ );
+ }
+
+ $snippet = get_snippet(
+ absint( $snippet_data['id'] ),
+ ! empty( $snippet_data['network'] )
+ );
+
+ if ( $snippet->id ) {
+ $snippets[] = $snippet;
+ }
+ }
+
+ return $snippets;
+ }
+
+ /**
+ * Send a download file response and end execution.
+ *
+ * @param array{filename:string, content_type:string, content:string} $download Download data.
+ *
+ * @return void
+ */
+ private function send_download_response( array $download ): void {
+ while ( ob_get_level() ) {
+ ob_end_clean();
+ }
+
+ nocache_headers();
+ send_nosniff_header();
+
+ header( 'Content-Description: File Transfer' );
+ header( 'Content-Type: ' . $download['content_type'] );
+ header( 'Content-Disposition: attachment; filename="' . $download['filename'] . '"' );
+ header( 'Content-Length: ' . strlen( $download['content'] ) );
+ header( 'X-Suggested-Filename: ' . $download['filename'] );
+
+ // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Binary download payload.
+ echo $download['content'];
+ exit;
+ }
+
+ /**
+ * Send a plain text download error response and end execution.
+ *
+ * @param string $message Error message.
+ * @param int $status HTTP status code.
+ *
+ * @return void
+ */
+ private function send_download_error( string $message, int $status = 400 ): void {
+ while ( ob_get_level() ) {
+ ob_end_clean();
+ }
+
+ status_header( $status );
+ nocache_headers();
+ send_nosniff_header();
+ header( 'Content-Type: text/plain; charset=' . get_option( 'blog_charset' ) );
+
+ echo esc_html( $message );
+ exit;
+ }
}
diff --git a/src/php/Client/Cloud_API.php b/src/php/Client/Cloud_API.php
index 2343297d..11fd1fed 100644
--- a/src/php/Client/Cloud_API.php
+++ b/src/php/Client/Cloud_API.php
@@ -19,6 +19,11 @@
*/
class Cloud_API {
+ /**
+ * Maximum number of cloud search results allowed per page.
+ */
+ public const MAX_RESULTS_PER_PAGE = 100;
+
/**
* Key used to access the local-to-cloud map transient data.
*/
@@ -177,15 +182,19 @@ private static function unpack_request_json( $response ): ?array {
* @param string $search_method Search by name of codevault or keyword(s).
* @param string $search Search query.
* @param int $page Search result page to retrieve. Defaults to '1'.
+ * @param int $per_page Number of search results to retrieve per page.
*
* @return Cloud_Snippets Result of search query.
*/
- public static function fetch_search_results( string $search_method, string $search, int $page = 1 ): Cloud_Snippets {
+ public static function fetch_search_results( string $search_method, string $search, int $page = 1, int $per_page = 10 ): Cloud_Snippets {
+ $per_page = min( self::MAX_RESULTS_PER_PAGE, max( 1, $per_page ) );
+
$api_url = add_query_arg(
[
's_method' => $search_method,
's' => $search,
'page' => max( 0, $page - 1 ),
+ 'per_page' => $per_page,
'site_token' => self::get_local_token(),
'site_host' => wp_parse_url( get_site_url(), PHP_URL_HOST ),
],
diff --git a/src/php/Flat_Files/Snippet_Files.php b/src/php/Flat_Files/Snippet_Files.php
index 050e1588..13726f30 100644
--- a/src/php/Flat_Files/Snippet_Files.php
+++ b/src/php/Flat_Files/Snippet_Files.php
@@ -349,9 +349,11 @@ private function delete_file( string $file_path ): void {
* @noinspection PhpUnusedParameterInspection
*/
public function sync_active_shared_network_snippets( string $option, $old_value, $value ): void {
- if ( 'active_shared_network_snippets' === $option ) {
- $this->create_active_shared_network_snippets_file( $value );
+ if ( 'active_shared_network_snippets' !== $option ) {
+ return;
}
+
+ $this->create_active_shared_network_snippets_file( $value );
}
/**
@@ -364,8 +366,10 @@ public function sync_active_shared_network_snippets( string $option, $old_value,
*/
public function sync_active_shared_network_snippets_add( $option, $value ): void {
if ( 'active_shared_network_snippets' !== $option ) {
- $this->create_active_shared_network_snippets_file( $value );
+ return;
}
+
+ $this->create_active_shared_network_snippets_file( $value );
}
/**
@@ -400,7 +404,8 @@ private function create_active_shared_network_snippets_file( $value ): void {
* @return string Hashed table name.
*/
public static function get_hashed_table_name( string $table ): string {
- return wp_hash( $table );
+ // wp_hash() is pluggable and may not be available during early bootstrap.
+ return function_exists( 'wp_hash' ) ? \wp_hash( $table ) : md5( $table );
}
/**
diff --git a/src/php/Integration/Admin_Bar.php b/src/php/Integration/Admin_Bar.php
index c231aab7..f2d80878 100644
--- a/src/php/Integration/Admin_Bar.php
+++ b/src/php/Integration/Admin_Bar.php
@@ -403,7 +403,7 @@ static function ( Snippet $a, Snippet $b ): int {
[
'id' => self::ROOT_NODE_ID . '-snippet-' . $snippet->id,
'title' => esc_html( $this->format_snippet_title( $snippet ) ),
- 'href' => esc_url( add_query_arg( 'edit', $snippet->id, $plugin->get_menu_url( 'edit' ) ) ),
+ 'href' => esc_url( add_query_arg( 'id', $snippet->id, $plugin->get_menu_url( 'edit' ) ) ),
'parent' => self::ROOT_NODE_ID . '-active-snippets',
'meta' => [ 'class' => 'code-snippets-snippet-item' ],
]
@@ -436,7 +436,7 @@ static function ( Snippet $a, Snippet $b ): int {
[
'id' => self::ROOT_NODE_ID . '-snippet-' . $snippet->id,
'title' => esc_html( $this->format_snippet_title( $snippet ) ),
- 'href' => esc_url( add_query_arg( 'edit', $snippet->id, $plugin->get_menu_url( 'edit' ) ) ),
+ 'href' => esc_url( add_query_arg( 'id', $snippet->id, $plugin->get_menu_url( 'edit' ) ) ),
'parent' => self::ROOT_NODE_ID . '-inactive-snippets',
'meta' => [ 'class' => 'code-snippets-snippet-item' ],
]
diff --git a/src/php/Migration/Export/Download_Code.php b/src/php/Migration/Export/Download_Code.php
new file mode 100644
index 00000000..20c55cc7
--- /dev/null
+++ b/src/php/Migration/Export/Download_Code.php
@@ -0,0 +1,253 @@
+
+ */
+ private const CONTENT_TYPES = [
+ 'php' => 'text/php',
+ 'html' => 'text/html',
+ 'css' => 'text/css',
+ 'js' => 'text/javascript',
+ 'cond' => 'application/json',
+ ];
+
+ /**
+ * File extensions keyed by snippet type.
+ *
+ * @var array
+ */
+ private const FILE_EXTENSIONS = [
+ 'php' => 'php',
+ 'html' => 'html',
+ 'css' => 'css',
+ 'js' => 'js',
+ 'cond' => 'json',
+ ];
+
+ /**
+ * Build a single downloadable code file.
+ *
+ * @param Snippet $snippet Snippet to export.
+ *
+ * @return array{filename:string, content_type:string, content:string}
+ */
+ public static function build_snippet_download( Snippet $snippet ): array {
+ $export = new Export_Code( [ $snippet->id ], $snippet->network );
+
+ return [
+ 'filename' => self::build_filename( $snippet ),
+ 'content_type' => self::CONTENT_TYPES[ $snippet->type ] ?? 'application/octet-stream',
+ 'content' => self::build_content( $snippet, $export ),
+ ];
+ }
+
+ /**
+ * Build a ZIP archive containing multiple snippet downloads.
+ *
+ * @param Snippet[] $snippets Snippets to export.
+ *
+ * @return array{filename:string, content_type:string, content:string}|WP_Error
+ */
+ public static function build_archive_download( array $snippets ) {
+ $archive_filename = sprintf( self::ARCHIVE_FILENAME_TEMPLATE, time() );
+
+ if ( ! class_exists( ZipArchive::class ) ) {
+ return new WP_Error(
+ 'zip_archive_unavailable',
+ __( 'Multiple snippet downloads require the ZipArchive PHP extension.', 'code-snippets' ),
+ [ 'status' => 501 ]
+ );
+ }
+
+ $temp_file = wp_tempnam( $archive_filename );
+
+ if ( ! is_string( $temp_file ) ) {
+ return new WP_Error(
+ 'zip_archive_temp_file_failed',
+ __( 'The temporary download archive could not be created.', 'code-snippets' ),
+ [ 'status' => 500 ]
+ );
+ }
+
+ $zip = new ZipArchive();
+ $open_result = $zip->open( $temp_file, ZipArchive::CREATE | ZipArchive::OVERWRITE );
+
+ if ( true !== $open_result ) {
+ wp_delete_file( $temp_file );
+
+ return new WP_Error(
+ 'zip_archive_open_failed',
+ __( 'The snippet download archive could not be created.', 'code-snippets' ),
+ [ 'status' => 500 ]
+ );
+ }
+
+ $used_filenames = [];
+
+ foreach ( $snippets as $snippet ) {
+ $download = self::build_snippet_download( $snippet );
+ $filename = self::get_unique_filename( $download['filename'], $snippet, $used_filenames );
+
+ $zip->addFromString( $filename, $download['content'] );
+ $used_filenames[ $filename ] = true;
+ }
+
+ $zip->close();
+
+ // WP_Filesystem_Direct is used intentionally here: the temp file was just created by
+ // wp_tempnam() on the local server filesystem, so the request filesystem context
+ // (FTP, SSH) is irrelevant. Using direct access avoids WP Filesystem bootstrapping
+ // overhead for a file we created and will immediately delete.
+ $filesystem = new WP_Filesystem_Direct( null );
+ $content = $filesystem->get_contents( $temp_file );
+ wp_delete_file( $temp_file );
+
+ if ( ! is_string( $content ) ) {
+ return new WP_Error(
+ 'zip_archive_read_failed',
+ __( 'The snippet download archive could not be read.', 'code-snippets' ),
+ [ 'status' => 500 ]
+ );
+ }
+
+ return [
+ 'filename' => $archive_filename,
+ 'content_type' => 'application/zip',
+ 'content' => $content,
+ ];
+ }
+
+ /**
+ * Build the raw file content for a snippet download.
+ *
+ * @param Snippet $snippet Snippet being exported.
+ * @param Export_Code $export Export helper for PHP-aware formatting.
+ *
+ * @return string
+ */
+ private static function build_content( Snippet $snippet, Export_Code $export ): string {
+ switch ( $snippet->type ) {
+ case 'html':
+ case 'css':
+ case 'js':
+ return self::normalize_content( $snippet->code );
+
+ case 'cond':
+ $decoded = json_decode( $snippet->code, true );
+
+ return JSON_ERROR_NONE === json_last_error()
+ ? self::normalize_content( wp_json_encode( $decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) )
+ : self::normalize_content( $snippet->code );
+
+ case 'php':
+ default:
+ return self::normalize_php_content( $export->generate_export() );
+ }
+ }
+
+ /**
+ * Build a deterministic filename for a snippet download.
+ *
+ * @param Snippet $snippet Snippet to export.
+ *
+ * @return string
+ */
+ private static function build_filename( Snippet $snippet ): string {
+ $title = sanitize_title( $snippet->name );
+
+ if ( '' === $title ) {
+ $title = "snippet-$snippet->id";
+ }
+
+ $extension = self::FILE_EXTENSIONS[ $snippet->type ] ?? 'txt';
+
+ return "$title.code-snippets.$extension";
+ }
+
+ /**
+ * Normalize snippet content with a trailing newline.
+ *
+ * @param string $content Snippet content.
+ *
+ * @return string
+ */
+ private static function normalize_content( string $content ): string {
+ $content = rtrim( $content );
+
+ return '' === $content ? '' : "$content\n";
+ }
+
+ /**
+ * Normalize PHP content to include an opening tag and trailing newline.
+ *
+ * @param string $content Snippet content.
+ *
+ * @return string
+ */
+ private static function normalize_php_content( string $content ): string {
+ $content = ltrim( $content );
+
+ if ( 0 !== strpos( $content, ' $used_filenames Archive filenames already in use.
+ *
+ * @return string
+ */
+ private static function get_unique_filename( string $filename, Snippet $snippet, array $used_filenames ): string {
+ if ( ! isset( $used_filenames[ $filename ] ) ) {
+ return $filename;
+ }
+
+ $extension = self::FILE_EXTENSIONS[ $snippet->type ] ?? 'txt';
+ $title = sanitize_title( $snippet->name );
+
+ if ( '' === $title ) {
+ $title = 'snippet';
+ }
+
+ $counter = 2;
+ $unique_filename = sprintf( '%s-%d.code-snippets.%s', $title, $snippet->id, $extension );
+
+ while ( isset( $used_filenames[ $unique_filename ] ) ) {
+ $unique_filename = sprintf( '%s-%d-%d.code-snippets.%s', $title, $snippet->id, $counter, $extension );
+ ++$counter;
+ }
+
+ return $unique_filename;
+ }
+}
diff --git a/src/php/Model/Model.php b/src/php/Model/Model.php
index 1124f71b..cfe3e5a6 100644
--- a/src/php/Model/Model.php
+++ b/src/php/Model/Model.php
@@ -105,7 +105,7 @@ function ( $value, $field ) {
* @return string The resolved field name.
*/
protected static function resolve_field_name( string $field ): string {
- return self::$field_aliases[ $field ] ?? $field;
+ return static::$field_aliases[ $field ] ?? $field;
}
/**
@@ -199,7 +199,7 @@ abstract protected function prepare_field( $value, string $field );
* @return array List of field names.
*/
public function get_allowed_fields(): array {
- return array_keys( $this->fields ) + array_keys( static::$field_aliases );
+ return array_merge( array_keys( $this->fields ), array_keys( static::$field_aliases ) );
}
/**
diff --git a/src/php/Model/Snippet.php b/src/php/Model/Snippet.php
index 259fb385..1a37b714 100644
--- a/src/php/Model/Snippet.php
+++ b/src/php/Model/Snippet.php
@@ -29,6 +29,7 @@
* @property bool $shared_network Whether the snippet is a shared network snippet.
* @property string $modified The date and time when the snippet data was most recently saved to the database.
* @property array{string,int}|null $code_error Code error encountered when last testing snippet code.
+ * @property string|null $code_error_trace Stack trace captured when last testing snippet code.
* @property int $revision Revision or version number of snippet.
* @property string $cloud_id Cloud ID and ownership status of snippet.
*
@@ -76,6 +77,7 @@ class Snippet extends Model {
'shared_network' => null,
'modified' => null,
'code_error' => null,
+ 'code_error_trace' => null,
'revision' => 1,
'cloud_id' => '',
];
diff --git a/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php b/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php
index 11f8746d..ff155218 100644
--- a/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php
+++ b/src/php/REST_API/Cloud/Cloud_Snippets_REST_Controller.php
@@ -46,6 +46,9 @@ public function __construct( Cloud_API $api ) {
* Register REST routes.
*/
public function register_routes() {
+ $collection_args = $this->get_collection_params();
+ $collection_args['per_page']['default'] = $this->get_snippets_per_page();
+
register_rest_route(
$this->namespace,
$this->rest_base,
@@ -54,23 +57,26 @@ public function register_routes() {
'methods' => WP_REST_Server::READABLE,
'callback' => [ $this, 'get_items' ],
'permission_callback' => [ $this, 'get_items_permissions_check' ],
- 'args' => [
- 'query' => [
- 'description' => esc_html__( 'Search query.', 'code-snippets' ),
- 'type' => 'string',
- 'required' => true,
- ],
- 'searchByCodevault' => [
- 'description' => esc_html__( 'Treat the search query as the name of a CodeVault instead of a search term.', 'code-snippets' ),
- 'type' => 'boolean',
- 'default' => false,
- ],
- 'page' => [
- 'description' => esc_html__( 'Page number.', 'code-snippets' ),
- 'type' => 'integer',
- 'default' => 1,
- ],
- ],
+ 'args' => array_merge(
+ $collection_args,
+ [
+ 'query' => [
+ 'description' => esc_html__( 'Search query.', 'code-snippets' ),
+ 'type' => 'string',
+ 'required' => true,
+ ],
+ 'searchByCodevault' => [
+ 'description' => esc_html__( 'Treat the search query as the name of a CodeVault instead of a search term.', 'code-snippets' ),
+ 'type' => 'boolean',
+ 'default' => false,
+ ],
+ 'page' => [
+ 'description' => esc_html__( 'Page number.', 'code-snippets' ),
+ 'type' => 'integer',
+ 'default' => 1,
+ ],
+ ]
+ ),
],
'schema' => [ $this, 'get_item_schema' ],
]
@@ -106,9 +112,13 @@ public function register_routes() {
public function get_items( $request ): WP_REST_Response {
$method = $request->get_param( 'searchByCodevault' ) ? 'codevault' : 'term';
$query = $request->get_param( 'query' );
- $page = $request->get_param( 'page' );
+ $query_params = $request->get_query_params();
+ $page = max( 1, (int) $request->get_param( 'page' ) );
+ $per_page = isset( $query_params['per_page'] )
+ ? min( Cloud_API::MAX_RESULTS_PER_PAGE, max( 1, (int) $request->get_param( 'per_page' ) ) )
+ : $this->get_snippets_per_page();
- $cloud_snippets = Cloud_API::fetch_search_results( $method, $query, $page );
+ $cloud_snippets = Cloud_API::fetch_search_results( $method, $query, $page, $per_page );
$results = [];
@@ -124,6 +134,17 @@ public function get_items( $request ): WP_REST_Response {
return $response;
}
+ /**
+ * Get the user's snippets per-page preference for Screen Options pagination.
+ *
+ * @return int
+ */
+ private function get_snippets_per_page(): int {
+ $per_page = (int) get_user_option( 'snippets_per_page' );
+
+ return $per_page > 0 ? min( Cloud_API::MAX_RESULTS_PER_PAGE, $per_page ) : 10;
+ }
+
/**
* Retrieve cloud snippets using a search query.
*
diff --git a/src/php/REST_API/Snippets/Snippets_REST_Controller.php b/src/php/REST_API/Snippets/Snippets_REST_Controller.php
index d4cfdeb3..838abd83 100644
--- a/src/php/REST_API/Snippets/Snippets_REST_Controller.php
+++ b/src/php/REST_API/Snippets/Snippets_REST_Controller.php
@@ -664,7 +664,15 @@ public function get_item_schema(): array {
],
'code_error' => [
'description' => esc_html__( 'Error message if the snippet code could not be parsed.', 'code-snippets' ),
- 'type' => 'string',
+ 'type' => [ 'array', 'null' ],
+ 'items' => [
+ 'type' => [ 'string', 'integer' ],
+ ],
+ 'readonly' => true,
+ ],
+ 'code_error_trace' => [
+ 'description' => esc_html__( 'Stack trace for the most recent snippet code error.', 'code-snippets' ),
+ 'type' => [ 'string', 'null' ],
'readonly' => true,
],
],
diff --git a/src/php/Utils/Validator.php b/src/php/Utils/Validator.php
index 0014a21a..d034f1c9 100644
--- a/src/php/Utils/Validator.php
+++ b/src/php/Utils/Validator.php
@@ -101,20 +101,25 @@ private function next() {
* @return bool true if the identifier is not unique.
*/
private function check_duplicate_identifier( string $type, string $identifier ): bool {
+ $identifier = strtolower( ltrim( $identifier, '\\' ) );
+ $namespaced_identifier = 'code_snippets\\' . $identifier;
if ( ! isset( $this->defined_identifiers[ $type ] ) ) {
switch ( $type ) {
case T_FUNCTION:
$defined_functions = get_defined_functions();
- $this->defined_identifiers[ T_FUNCTION ] = array_merge( $defined_functions['internal'], $defined_functions['user'] );
+ $this->defined_identifiers[ T_FUNCTION ] = array_map(
+ 'strtolower',
+ array_merge( $defined_functions['internal'], $defined_functions['user'] )
+ );
break;
case T_CLASS:
- $this->defined_identifiers[ T_CLASS ] = get_declared_classes();
+ $this->defined_identifiers[ T_CLASS ] = array_map( 'strtolower', get_declared_classes() );
break;
case T_INTERFACE:
- $this->defined_identifiers[ T_INTERFACE ] = get_declared_interfaces();
+ $this->defined_identifiers[ T_INTERFACE ] = array_map( 'strtolower', get_declared_interfaces() );
break;
default:
@@ -122,10 +127,15 @@ private function check_duplicate_identifier( string $type, string $identifier ):
}
}
- $duplicate = in_array( $identifier, $this->defined_identifiers[ $type ], true );
+ $duplicate_identifier = in_array( $identifier, $this->defined_identifiers[ $type ], true );
+ $duplicate_namespaced = in_array( $namespaced_identifier, $this->defined_identifiers[ $type ], true );
+ $exceptions = $this->exceptions[ $type ] ?? [];
+ $exception_identifier = in_array( $identifier, $exceptions, true );
+ $exception_namespaced = in_array( $namespaced_identifier, $exceptions, true );
+
array_unshift( $this->defined_identifiers[ $type ], $identifier );
- return $duplicate && ! ( isset( $this->exceptions[ $type ] ) && in_array( $identifier, $this->exceptions[ $type ], true ) );
+ return ( $duplicate_identifier && ! $exception_identifier ) || ( $duplicate_namespaced && ! $exception_namespaced );
}
/**
@@ -154,8 +164,12 @@ public function validate() {
}
// Add the identifier to the list of exceptions.
- $this->exceptions[ $type ] = $this->exceptions[ $type ] ?? [];
- $this->exceptions[ $type ][] = trim( $token[1], '\'"' );
+ $identifier = strtolower( ltrim( trim( $token[1], '\'"' ), '\\' ) );
+
+ if ( '' !== $identifier ) {
+ $this->exceptions[ $type ] = $this->exceptions[ $type ] ?? [];
+ $this->exceptions[ $type ][] = $identifier;
+ }
continue;
}
diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php
index 0ca0848f..cc2e4f46 100644
--- a/src/php/snippet-ops.php
+++ b/src/php/snippet-ops.php
@@ -8,7 +8,7 @@
namespace Code_Snippets;
use Code_Snippets\Core\DB;
-use ParseError;
+use Exception;
use Code_Snippets\Model\Snippet;
use Code_Snippets\Utils\Validator;
use Throwable;
@@ -584,6 +584,7 @@ function restore_snippet( int $id, ?bool $network = null ): bool {
*/
function test_snippet_code( Snippet $snippet ) {
$snippet->code_error = null;
+ $snippet->code_error_trace = null;
if ( 'php' !== $snippet->type ) {
return;
@@ -594,16 +595,18 @@ function test_snippet_code( Snippet $snippet ) {
if ( $result ) {
$snippet->code_error = [ $result['message'], $result['line'] ];
+ $snippet->code_error_trace = ( new Exception() )->getTraceAsString();
}
if ( ! $snippet->code_error && 'single-use' !== $snippet->scope ) {
$result = execute_snippet( $snippet->code, $snippet->id, true );
- if ( $result instanceof ParseError ) {
+ if ( $result instanceof Throwable ) {
$snippet->code_error = [
ucfirst( rtrim( $result->getMessage(), '.' ) ) . '.',
$result->getLine(),
];
+ $snippet->code_error_trace = $result->getTraceAsString();
}
}
}
@@ -688,6 +691,8 @@ function save_snippet( $snippet ): ?Snippet {
$snippet->id = $wpdb->insert_id;
$updated = get_snippet( $snippet->id );
+ $updated->code_error = $snippet->code_error;
+ $updated->code_error_trace = $snippet->code_error_trace;
do_action( 'code_snippets/create_snippet', $updated, $table );
if ( $updated->id > 0 ) {
@@ -701,6 +706,8 @@ function save_snippet( $snippet ): ?Snippet {
$wpdb->update( $table, $data, [ 'id' => $snippet->id ], null, [ '%d' ] );
$updated = get_snippet( $snippet->id, $snippet->network );
+ $updated->code_error = $snippet->code_error;
+ $updated->code_error_trace = $snippet->code_error_trace;
do_action( 'code_snippets/update_snippet', $updated, $table, $existing, $snippet );
@@ -732,7 +739,7 @@ function save_snippet( $snippet ): ?Snippet {
* @param int $id Snippet ID.
* @param bool $force Force snippet execution, even if save mode is active.
*
- * @return ParseError|mixed Code error if encountered during execution, or result of snippet execution otherwise.
+ * @return Throwable|mixed Code error if encountered during execution, or result of snippet execution otherwise.
*
* @since 2.0.0
* @noinspection PhpUndefinedConstantInspection
@@ -753,8 +760,8 @@ function execute_snippet( string $code, int $id = 0, bool $force = false ) {
try {
$result = eval( $code );
- } catch ( ParseError $parse_error ) {
- $result = $parse_error;
+ } catch ( Throwable $throwable ) {
+ $result = $throwable;
}
ob_end_clean();
@@ -884,8 +891,6 @@ function execute_snippet_from_flat_file( string $code, string $file, int $id = 0
try {
require_once $file;
$result = null;
- } catch ( ParseError $parse_error ) {
- $result = $parse_error;
} catch ( Throwable $throwable ) {
$result = $throwable;
}
diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts
index d8dc103b..c04bab30 100644
--- a/tests/e2e/code-snippets-edit.spec.ts
+++ b/tests/e2e/code-snippets-edit.spec.ts
@@ -1,6 +1,6 @@
-import { test } from '@playwright/test'
+import { expect, test } from '@playwright/test'
import { SnippetsTestHelper } from './helpers/SnippetsTestHelper'
-import { MESSAGES, SELECTORS } from './helpers/constants'
+import { MESSAGES, SELECTORS, TIMEOUTS } from './helpers/constants'
test.describe('Code Snippets Admin', () => {
let helper: SnippetsTestHelper
@@ -40,6 +40,80 @@ test.describe('Code Snippets Admin', () => {
await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED)
})
+ test('Can activate a new snippet on the first save attempt', async ({ page }) => {
+ const snippetName = SnippetsTestHelper.makeUniqueSnippetName()
+ await helper.clickAddNewSnippet()
+ await helper.fillSnippetForm({
+ name: snippetName,
+ code: "add_filter('show_admin_bar', '__return_false');"
+ })
+
+ await helper.saveSnippet('save_and_activate')
+ await helper.expectSuccessMessage(MESSAGES.SNIPPET_CREATED_AND_ACTIVATED)
+
+ await helper.navigateToSnippetsAdmin()
+
+ const snippetRow = page
+ .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`)
+ .first()
+
+ await expect(snippetRow).toBeVisible({ timeout: TIMEOUTS.DEFAULT })
+ await expect(snippetRow.locator(SELECTORS.SNIPPET_TOGGLE).first()).toBeChecked({ timeout: TIMEOUTS.DEFAULT })
+
+ await helper.cleanupSnippet(snippetName)
+ })
+
+ test('Edit menu shortcut keeps operable button semantics', async ({ page }) => {
+ const snippetName = SnippetsTestHelper.makeUniqueSnippetName()
+ await helper.createSnippet({
+ name: snippetName,
+ code: "add_filter('show_admin_bar', '__return_false');"
+ })
+
+ await helper.openSnippet(snippetName)
+
+ const editMenuLink = page.locator('#adminmenu a.code-snippets-edit-menu-link').first()
+
+ await expect(editMenuLink).toBeVisible({ timeout: TIMEOUTS.DEFAULT })
+ await expect(editMenuLink).toHaveAttribute('role', 'button')
+ await expect(editMenuLink).toHaveAttribute('tabindex', '0')
+ await expect(editMenuLink).not.toHaveAttribute('aria-disabled', /true/)
+
+ await helper.cleanupSnippet(snippetName)
+ })
+
+ test('Shows an error notice when activation fails after saving', async ({ page }) => {
+ const snippetName = SnippetsTestHelper.makeUniqueSnippetName()
+ await helper.clickAddNewSnippet()
+ await helper.fillSnippetForm({
+ name: snippetName,
+ code: 'missing_runtime_function_call();'
+ })
+
+ await helper.saveSnippet('save_and_activate')
+
+ const errorNotice = page.locator('.wrap > .notice.error').first()
+ await expect(errorNotice).toBeVisible({ timeout: TIMEOUTS.DEFAULT })
+ await expect(errorNotice).toContainText('Snippet could not be activated.')
+ await expect(errorNotice).toContainText('Call to undefined function missing_runtime_function_call()')
+ await expect(errorNotice).toContainText('The snippet was saved, but remains inactive due to this error:')
+
+ const traceDetails = errorNotice.locator('details').first()
+ await expect(traceDetails).toBeVisible({ timeout: TIMEOUTS.DEFAULT })
+ await expect(traceDetails.locator('summary')).toContainText('View stack trace')
+
+ await helper.navigateToSnippetsAdmin()
+
+ const snippetRow = page
+ .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`)
+ .first()
+
+ await expect(snippetRow).toBeVisible({ timeout: TIMEOUTS.DEFAULT })
+ await expect(snippetRow.locator(SELECTORS.SNIPPET_TOGGLE).first()).not.toBeChecked({ timeout: TIMEOUTS.DEFAULT })
+
+ await helper.cleanupSnippet(snippetName)
+ })
+
test('Can delete a snippet', async () => {
const snippetName = SnippetsTestHelper.makeUniqueSnippetName()
await helper.createSnippet({
diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts
index d5eb1a8a..3e76b51c 100644
--- a/tests/e2e/code-snippets-list.spec.ts
+++ b/tests/e2e/code-snippets-list.spec.ts
@@ -1,6 +1,8 @@
+import { readFileSync } from 'fs'
import { expect, test } from '@playwright/test'
import { SnippetsTestHelper } from './helpers/SnippetsTestHelper'
import { SELECTORS } from './helpers/constants'
+import type { Page } from '@playwright/test'
test.describe('Code Snippets List Page Actions', () => {
let helper: SnippetsTestHelper
@@ -132,4 +134,252 @@ test.describe('Code Snippets List Page Actions', () => {
expect(download.suggestedFilename()).toMatch(/\.json$/)
})
+
+ test('Can export multiple snippets from bulk actions', async ({ page }) => {
+ test.setTimeout(EXPORT_TEST_TIMEOUT_MS)
+ const secondSnippetName = SnippetsTestHelper.makeUniqueSnippetName()
+
+ await helper.createAndActivateSnippet({
+ name: secondSnippetName,
+ code: "add_filter('show_admin_bar', '__return_false');"
+ })
+ await helper.navigateToSnippetsAdmin()
+
+ const firstRow = page
+ .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`)
+ .first()
+ const secondRow = page
+ .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${secondSnippetName}"))`)
+ .first()
+
+ await firstRow.locator('input[name="checked[]"]').check({ force: true })
+ await secondRow.locator('input[name="checked[]"]').check({ force: true })
+ await page.locator('select[name="action"]').first().selectOption({ label: 'Export' })
+
+ const download = await Promise.all([
+ page.waitForEvent('download'),
+ page.locator('#doaction').click()
+ ]).then(([downloadEvent]) => downloadEvent)
+
+ expect(download.suggestedFilename()).toBe('snippets.code-snippets.json')
+
+ await helper.cleanupSnippet(secondSnippetName)
+ })
+
+ test('Can download a single snippet from bulk actions', async ({ page }) => {
+ test.setTimeout(EXPORT_TEST_TIMEOUT_MS)
+ const snippetRow = page
+ .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`)
+ .first()
+
+ await snippetRow.locator('input[name="checked[]"]').check({ force: true })
+ await page.locator('select[name="action"]').first().selectOption({ label: 'Download' })
+
+ const download = await Promise.all([
+ page.waitForEvent('download'),
+ page.locator('#doaction').click()
+ ]).then(([downloadEvent]) => downloadEvent)
+
+ expect(download.suggestedFilename()).toMatch(/\.code-snippets\.php$/)
+ })
+
+ test('Can download multiple snippets from bulk actions as a zip archive', async ({ page }) => {
+ test.setTimeout(EXPORT_TEST_TIMEOUT_MS)
+ const secondSnippetName = SnippetsTestHelper.makeUniqueSnippetName('E2E Download CSS')
+
+ await SnippetsTestHelper.createSnippetViaCli({
+ name: secondSnippetName,
+ active: false,
+ type: 'css'
+ })
+ await helper.navigateToSnippetsAdmin()
+
+ const firstRow = page
+ .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`)
+ .first()
+ const secondRow = page
+ .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${secondSnippetName}"))`)
+ .first()
+
+ await firstRow.locator('input[name="checked[]"]').check({ force: true })
+ await secondRow.locator('input[name="checked[]"]').check({ force: true })
+ await page.locator('select[name="action"]').first().selectOption({ label: 'Download' })
+
+ const download = await Promise.all([
+ page.waitForEvent('download'),
+ page.locator('#doaction').click()
+ ]).then(([downloadEvent]) => downloadEvent)
+
+ expect(download.suggestedFilename()).toMatch(/^code-snippets-\d+\.zip$/)
+
+ await helper.cleanupSnippet(secondSnippetName)
+ })
+
+ test('Bulk download stays scoped to the current page selection', async ({ page }) => {
+ test.setTimeout(EXPORT_TEST_TIMEOUT_MS)
+ const bulkScopeBaseName = 'E2E Bulk Scope'
+ const firstScopedSnippetName = SnippetsTestHelper.makeUniqueSnippetName(bulkScopeBaseName)
+ const secondScopedSnippetName = SnippetsTestHelper.makeUniqueSnippetName(bulkScopeBaseName)
+
+ await SnippetsTestHelper.setSnippetsPerPage(1)
+
+ try {
+ await helper.createAndActivateSnippet({
+ name: firstScopedSnippetName,
+ code: "add_filter('show_admin_bar', '__return_false');"
+ })
+ await helper.createAndActivateSnippet({
+ name: secondScopedSnippetName,
+ code: "add_filter('show_admin_bar', '__return_false');"
+ })
+ await helper.navigateToSnippetsAdmin()
+
+ await page.locator('#snippets_search').fill(bulkScopeBaseName)
+
+ const firstPageRow = page.locator(SELECTORS.SNIPPET_ROW).first()
+ await expect(firstPageRow).toBeVisible()
+ await firstPageRow.locator('input[name="checked[]"]').check({ force: true })
+
+ await page.locator('.next-page').first().click()
+
+ const secondPageRow = page.locator(SELECTORS.SNIPPET_ROW).first()
+ await expect(secondPageRow).toBeVisible()
+ await secondPageRow.locator('input[name="checked[]"]').check({ force: true })
+ await page.locator('select[name="action"]').first().selectOption({ label: 'Download' })
+
+ const download = await Promise.all([
+ page.waitForEvent('download'),
+ page.locator('#doaction').click()
+ ]).then(([downloadEvent]) => downloadEvent)
+
+ expect(download.suggestedFilename()).toMatch(/\.code-snippets\.php$/)
+ } finally {
+ await SnippetsTestHelper.resetSnippetsPerPage()
+ await helper.cleanupSnippet(firstScopedSnippetName)
+ await helper.cleanupSnippet(secondScopedSnippetName)
+ }
+ })
+
+ test('Bulk export stays scoped to the current page selection', async ({ page }) => {
+ test.setTimeout(EXPORT_TEST_TIMEOUT_MS)
+ const bulkScopeBaseName = 'E2E Bulk Scope Export'
+ const firstScopedSnippetName = SnippetsTestHelper.makeUniqueSnippetName(bulkScopeBaseName)
+ const secondScopedSnippetName = SnippetsTestHelper.makeUniqueSnippetName(bulkScopeBaseName)
+
+ await SnippetsTestHelper.setSnippetsPerPage(1)
+
+ try {
+ await helper.createAndActivateSnippet({
+ name: firstScopedSnippetName,
+ code: "add_filter('show_admin_bar', '__return_false');"
+ })
+ await helper.createAndActivateSnippet({
+ name: secondScopedSnippetName,
+ code: "add_filter('show_admin_bar', '__return_false');"
+ })
+ await helper.navigateToSnippetsAdmin()
+
+ await page.locator('#snippets_search').fill(bulkScopeBaseName)
+
+ // Select a row on page 1.
+ const firstPageRow = page.locator(SELECTORS.SNIPPET_ROW).first()
+ await expect(firstPageRow).toBeVisible()
+ await firstPageRow.locator('input[name="checked[]"]').check({ force: true })
+
+ // Navigate to page 2 - the page-1 selection should be cleared.
+ await page.locator('.next-page').first().click()
+
+ // Select the row on page 2 and export.
+ const secondPageRow = page.locator(SELECTORS.SNIPPET_ROW).first()
+ await expect(secondPageRow).toBeVisible()
+ await secondPageRow.locator('input[name="checked[]"]').check({ force: true })
+ await page.locator('select[name="action"]').first().selectOption({ label: 'Export' })
+
+ const download = await Promise.all([
+ page.waitForEvent('download'),
+ page.locator('#doaction').click()
+ ]).then(([downloadEvent]) => downloadEvent)
+
+ // A single-snippet export (not a multi-snippet archive) confirms only the page-2
+ // snippet — not both — was included in the selection.
+ 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 }[] }>JSON.parse(readFileSync(downloadPath, 'utf-8'))
+ expect(parsed.snippets).toHaveLength(1)
+ } finally {
+ await SnippetsTestHelper.resetSnippetsPerPage()
+ await helper.cleanupSnippet(firstScopedSnippetName)
+ await helper.cleanupSnippet(secondScopedSnippetName)
+ }
+ })
+})
+
+test.describe('Manage table Screen Options', () => {
+ let helper: SnippetsTestHelper
+ let snippetName: string
+
+ test.beforeEach(async ({ page }) => {
+ helper = new SnippetsTestHelper(page)
+ snippetName = SnippetsTestHelper.makeUniqueSnippetName('E2E Screen Options')
+ await helper.createAndActivateSnippet({
+ name: snippetName,
+ code: "add_filter('show_admin_bar', '__return_false');"
+ })
+ await helper.navigateToSnippetsAdmin()
+ })
+
+ test.afterEach(async () => {
+ await helper.cleanupSnippet(snippetName)
+ })
+
+ const openScreenOptions = async (page: Page) => {
+ const panel = page.locator('#adv-settings')
+ const isVisible = await panel.isVisible().catch(() => false)
+
+ if (!isVisible) {
+ await page.locator('#show-settings-link').click()
+ await expect(panel).toBeVisible()
+ }
+ }
+
+ test('Column visibility toggle hides and shows columns in real time', async ({ page }) => {
+ await openScreenOptions(page)
+
+ const descToggle = page.locator('#adv-settings input.hide-column-tog[value="desc"]')
+ await expect(descToggle).toBeVisible()
+
+ // Ensure Description column is initially visible.
+ await descToggle.check()
+ await expect(page.locator('.wp-list-table th.column-desc').first()).not.toHaveClass(/\bhidden\b/)
+
+ // Uncheck — column should disappear in real time.
+ await descToggle.uncheck()
+ await expect(page.locator('.wp-list-table th.column-desc').first()).toHaveClass(/\bhidden\b/)
+
+ // Re-check — column should reappear in real time.
+ await descToggle.check()
+ await expect(page.locator('.wp-list-table th.column-desc').first()).not.toHaveClass(/\bhidden\b/)
+ })
+
+ test('Truncation toggle applies and removes the truncation class in real time', async ({ page }) => {
+ await openScreenOptions(page)
+
+ const truncationToggle = page.locator('#snippets-table-truncate-row-values')
+ await expect(truncationToggle).toBeVisible()
+
+ // Enable truncation and verify the CSS class is applied.
+ await truncationToggle.check()
+ await expect(page.locator('.wp-list-table.truncate-row-values')).toBeVisible()
+
+ // Disable truncation and verify the CSS class is removed.
+ await truncationToggle.uncheck()
+ await expect(page.locator('.wp-list-table.truncate-row-values')).toBeHidden()
+
+ // Re-enable and verify restoration.
+ await truncationToggle.check()
+ await expect(page.locator('.wp-list-table.truncate-row-values')).toBeVisible()
+ })
})
diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts
index daca1952..7daae5ff 100644
--- a/tests/e2e/helpers/SnippetsTestHelper.ts
+++ b/tests/e2e/helpers/SnippetsTestHelper.ts
@@ -59,6 +59,26 @@ export class SnippetsTestHelper {
await wpCli(['eval', php])
}
+ static async setSnippetsPerPage(perPage: number): Promise {
+ const php = `
+ $user = get_user_by('login', 'admin');
+ $user_id = $user ? $user->ID : 1;
+ update_user_option($user_id, 'snippets_per_page', ${perPage});
+ `
+
+ await wpCli(['eval', php])
+ }
+
+ static async resetSnippetsPerPage(): Promise {
+ const php = `
+ $user = get_user_by('login', 'admin');
+ $user_id = $user ? $user->ID : 1;
+ delete_user_option($user_id, 'snippets_per_page');
+ `
+
+ await wpCli(['eval', php])
+ }
+
static async createSnippetViaCli(options: CreateSnippetCliOptions): Promise {
const type = options.type ?? 'php'
let scope = 'global'
@@ -210,6 +230,13 @@ export class SnippetsTestHelper {
await this.page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT })
}
+ /**
+ * Filter the snippets table to a specific snippet name.
+ */
+ async filterSnippetsByName(snippetName: string): Promise {
+ await this.page.fill(SELECTORS.SNIPPET_SEARCH_INPUT, snippetName)
+ }
+
/**
* Navigate to frontend
*/
@@ -305,6 +332,7 @@ export class SnippetsTestHelper {
async openSnippet(snippetName: string): Promise {
await this.page.goto(URLS.SNIPPETS_ADMIN)
await this.page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT })
+ await this.filterSnippetsByName(snippetName)
const row = this.page.locator(SELECTORS.SNIPPET_ROW).filter({ hasText: snippetName }).first()
await expect(row).toBeVisible({ timeout: TIMEOUTS.DEFAULT })
@@ -344,6 +372,7 @@ export class SnippetsTestHelper {
*/
async deleteSnippetFromList(snippetName: string): Promise {
await this.navigateToSnippetsAdmin()
+ await this.filterSnippetsByName(snippetName)
const row = this.page
.locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${snippetName}"))`)
@@ -479,6 +508,7 @@ export class SnippetsTestHelper {
// Ensure activation is actually persisted by toggling from the list screen.
await this.navigateToSnippetsAdmin()
+ await this.filterSnippetsByName(options.name)
const row = this.page
.locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${options.name}"))`)
.first()
diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts
index fdcabe07..604a5a17 100644
--- a/tests/e2e/helpers/constants.ts
+++ b/tests/e2e/helpers/constants.ts
@@ -10,6 +10,7 @@ export const SELECTORS = {
SNIPPET_ROW: '.wp-list-table tbody tr',
SNIPPET_TOGGLE: 'input.switch',
SNIPPET_NAME_LINK: '.snippet-name',
+ SNIPPET_SEARCH_INPUT: '#snippets_search',
CLONE_ACTION: '.row-actions button:has-text("Clone")',
DELETE_ACTION: '.row-actions button:has-text("Trash")',
diff --git a/tests/phpunit/test-admin-bar.php b/tests/phpunit/test-admin-bar.php
index a0609aab..6abe71b9 100644
--- a/tests/phpunit/test-admin-bar.php
+++ b/tests/phpunit/test-admin-bar.php
@@ -321,6 +321,30 @@ public function test_manage_quick_links_are_registered(): void {
$this->assertNotNull( $wp_admin_bar->get_node( 'code-snippets-status-inactive' ) );
}
+ /**
+ * Snippet listing links use the edit screen ID query arg.
+ *
+ * @return void
+ */
+ public function test_snippet_listing_links_use_id_query_arg(): void {
+ $active = $this->create_snippet( 'QuickNav Edit Link Active', true );
+ $inactive = $this->create_snippet( 'QuickNav Edit Link Inactive', false );
+
+ $wp_admin_bar = $this->build_admin_bar();
+ $admin_bar = new Admin_Bar();
+ $admin_bar->register_nodes( $wp_admin_bar );
+
+ $active_node = $wp_admin_bar->get_node( 'code-snippets-snippet-' . $active->id );
+ $inactive_node = $wp_admin_bar->get_node( 'code-snippets-snippet-' . $inactive->id );
+
+ $this->assertNotNull( $active_node );
+ $this->assertNotNull( $inactive_node );
+ $this->assertStringContainsString( 'id=' . $active->id, $active_node->href );
+ $this->assertStringNotContainsString( 'edit=' . $active->id, $active_node->href );
+ $this->assertStringContainsString( 'id=' . $inactive->id, $inactive_node->href );
+ $this->assertStringNotContainsString( 'edit=' . $inactive->id, $inactive_node->href );
+ }
+
/**
* Safe mode documentation link is registered under the Snippets root node.
*
diff --git a/tests/phpunit/test-download-code.php b/tests/phpunit/test-download-code.php
new file mode 100644
index 00000000..a02b2e29
--- /dev/null
+++ b/tests/phpunit/test-download-code.php
@@ -0,0 +1,138 @@
+clear_all_snippets();
+ }
+
+ /**
+ * Clear all snippets from the database.
+ *
+ * @return void
+ */
+ private function clear_all_snippets(): void {
+ global $wpdb;
+
+ $table_name = code_snippets()->db->get_table_name();
+ $wpdb->query( "TRUNCATE TABLE {$table_name}" );
+ }
+
+ /**
+ * Downloading a single snippet uses the expected file extension and content type.
+ *
+ * @return void
+ */
+ public function test_build_snippet_download_uses_type_based_filename(): void {
+ $snippet = save_snippet(
+ new Snippet(
+ [
+ 'name' => 'My HTML Snippet',
+ 'code' => 'Hello world
',
+ 'scope' => 'content',
+ ]
+ )
+ );
+
+ $download = Download_Code::build_snippet_download( $snippet );
+
+ $this->assertSame( 'my-html-snippet.code-snippets.html', $download['filename'] );
+ $this->assertSame( 'text/html', $download['content_type'] );
+ $this->assertSame( "Hello world
\n", $download['content'] );
+ }
+
+ /**
+ * Downloading a single PHP snippet adds a PHP opening tag.
+ *
+ * @return void
+ */
+ public function test_build_snippet_download_wraps_php_snippets(): void {
+ $snippet = save_snippet(
+ new Snippet(
+ [
+ 'name' => 'My PHP Snippet',
+ 'code' => 'echo "Hello world";',
+ 'scope' => 'global',
+ ]
+ )
+ );
+
+ $download = Download_Code::build_snippet_download( $snippet );
+
+ $this->assertSame( 'my-php-snippet.code-snippets.php', $download['filename'] );
+ $this->assertSame( 'text/php', $download['content_type'] );
+ $this->assertStringStartsWith( "assertStringContainsString( 'echo "Hello world";', $download['content'] );
+ }
+
+ /**
+ * Downloading multiple snippets builds a ZIP archive when ZipArchive is available.
+ *
+ * @return void
+ */
+ public function test_build_archive_download_returns_zip_file(): void {
+ $php_snippet = save_snippet(
+ new Snippet(
+ [
+ 'name' => 'Bulk PHP Snippet',
+ 'code' => 'echo "One";',
+ 'scope' => 'global',
+ ]
+ )
+ );
+
+ $css_snippet = save_snippet(
+ new Snippet(
+ [
+ 'name' => 'Bulk CSS Snippet',
+ 'code' => 'body { color: red; }',
+ 'scope' => 'site-css',
+ ]
+ )
+ );
+
+ $download = Download_Code::build_archive_download( [ $php_snippet, $css_snippet ] );
+
+ if ( ! class_exists( ZipArchive::class ) ) {
+ $this->assertInstanceOf( WP_Error::class, $download );
+ $this->assertSame( 'zip_archive_unavailable', $download->get_error_code() );
+
+ return;
+ }
+
+ $this->assertIsArray( $download );
+ $this->assertMatchesRegularExpression( '/^code-snippets-\d+\.zip$/', $download['filename'] );
+ $this->assertSame( 'application/zip', $download['content_type'] );
+
+ $upload = wp_upload_bits( $download['filename'], null, $download['content'] );
+ $temp_file = $upload['file'];
+
+ $zip = new ZipArchive();
+ $this->assertTrue( $zip->open( $temp_file ) );
+ $this->assertStringContainsString( 'echo "One";', $zip->getFromName( 'bulk-php-snippet.code-snippets.php' ) );
+ $this->assertStringContainsString( 'body { color: red; }', $zip->getFromName( 'bulk-css-snippet.code-snippets.css' ) );
+ $zip->close();
+
+ wp_delete_file( $temp_file );
+ }
+}
diff --git a/tests/phpunit/test-edit-menu.php b/tests/phpunit/test-edit-menu.php
new file mode 100644
index 00000000..f2d4a5fd
--- /dev/null
+++ b/tests/phpunit/test-edit-menu.php
@@ -0,0 +1,134 @@
+user->create(
+ [
+ 'role' => 'administrator',
+ ]
+ );
+ }
+
+ /**
+ * Set up before each test.
+ *
+ * @return void
+ */
+ public function set_up() {
+ parent::set_up();
+
+ wp_set_current_user( self::$admin_user_id );
+ set_current_screen( 'toplevel_page_' . code_snippets()->get_menu_slug() );
+ unset( $GLOBALS['submenu'][ code_snippets()->get_menu_slug() ] );
+ unset( $_GET['id'] );
+ }
+
+ /**
+ * The edit submenu item remains registered so the page stays directly accessible.
+ *
+ * @return void
+ */
+ public function test_register_keeps_edit_submenu_item(): void {
+ $menu = new Edit_Menu();
+ $menu->register();
+
+ $submenu = $GLOBALS['submenu'][ code_snippets()->get_menu_slug() ] ?? [];
+ $submenu_slugs = array_column( $submenu, 2 );
+
+ $this->assertContains( code_snippets()->get_menu_slug( 'edit' ), $submenu_slugs );
+ $this->assertContains( code_snippets()->get_menu_slug( 'add' ), $submenu_slugs );
+ }
+
+ /**
+ * Hide the edit submenu item outside the snippet edit screen.
+ *
+ * @return void
+ */
+ public function test_maybe_hide_menu_item_removes_edit_submenu_outside_edit_screen(): void {
+ $menu = new Edit_Menu();
+ $menu->register();
+
+ $menu->maybe_hide_menu_item( get_current_screen() );
+
+ $submenu = $GLOBALS['submenu'][ code_snippets()->get_menu_slug() ] ?? [];
+ $submenu_slugs = array_column( $submenu, 2 );
+
+ $this->assertNotContains( code_snippets()->get_menu_slug( 'edit' ), $submenu_slugs );
+ $this->assertContains( code_snippets()->get_menu_slug( 'add' ), $submenu_slugs );
+ }
+
+ /**
+ * Keep the edit submenu item visible while editing a specific snippet.
+ *
+ * @return void
+ */
+ public function test_maybe_hide_menu_item_keeps_edit_submenu_on_edit_screen(): void {
+ $menu = new Edit_Menu();
+ $menu->register();
+
+ $screen = get_current_screen();
+ $hook = get_plugin_page_hookname(
+ code_snippets()->get_menu_slug( 'edit' ),
+ code_snippets()->get_menu_slug()
+ );
+
+ $screen->id = $hook;
+ $screen->base = $hook;
+ $_GET['id'] = '11';
+
+ $menu->maybe_hide_menu_item( $screen );
+
+ $submenu = $GLOBALS['submenu'][ code_snippets()->get_menu_slug() ] ?? [];
+ $submenu_slugs = array_column( $submenu, 2 );
+
+ $this->assertContains( code_snippets()->get_menu_slug( 'edit' ), $submenu_slugs );
+ }
+
+ /**
+ * The edit menu no longer injects inline JavaScript in the admin footer.
+ *
+ * @return void
+ */
+ public function test_edit_menu_does_not_use_footer_inline_script(): void {
+ $menu = new Edit_Menu();
+
+ $this->assertFalse( has_action( 'admin_print_footer_scripts', [ $menu, 'disable_menu_link' ] ) );
+ $this->assertFalse( has_action( 'network_admin_print_footer_scripts', [ $menu, 'disable_menu_link' ] ) );
+ }
+
+ /**
+ * The edit menu hides its submenu item using the current_screen hook.
+ *
+ * @return void
+ */
+ public function test_edit_menu_uses_current_screen_to_hide_menu_item(): void {
+ $menu = new Edit_Menu();
+
+ $this->assertNotFalse( has_action( 'current_screen', [ $menu, 'maybe_hide_menu_item' ] ) );
+ }
+}
diff --git a/tests/phpunit/test-flat-files-hooks.php b/tests/phpunit/test-flat-files-hooks.php
index abc35b09..e85084b2 100644
--- a/tests/phpunit/test-flat-files-hooks.php
+++ b/tests/phpunit/test-flat-files-hooks.php
@@ -2,6 +2,10 @@
namespace Code_Snippets\Tests;
+use Code_Snippets\Flat_Files\Handler_Registry;
+use Code_Snippets\Flat_Files\Interfaces\Filesystem_Adapter;
+use Code_Snippets\Flat_Files\Interfaces\Snippet_Config_Repository;
+use Code_Snippets\Flat_Files\Snippet_Files;
use Code_Snippets\Model\Snippet;
use function Code_Snippets\save_snippet;
use function Code_Snippets\update_snippet_fields;
@@ -12,6 +16,60 @@
* @group flat-files
*/
class Flat_Files_Hooks_Test extends TestCase {
+
+ /**
+ * Build a Snippet_Files instance with a stub filesystem adapter.
+ *
+ * @param Filesystem_Adapter $fs Filesystem adapter.
+ *
+ * @return Snippet_Files
+ */
+ private function build_snippet_files( Filesystem_Adapter $fs ): Snippet_Files {
+ $config_repo = new class() implements Snippet_Config_Repository {
+
+ /**
+ * Load a list of active snippets.
+ *
+ * @param string $base_dir Base directory.
+ *
+ * @return array[]
+ */
+ public function load( string $base_dir ): array {
+ return [];
+ }
+
+ /**
+ * Save the active snippets list.
+ *
+ * @param string $base_dir Base directory.
+ * @param array $active_snippets Active snippets.
+ *
+ * @return void
+ */
+ public function save( string $base_dir, array $active_snippets ): void {
+ }
+
+ /**
+ * Update a snippet entry in the active config.
+ *
+ * @param string $base_dir Base directory.
+ * @param Snippet $snippet Snippet.
+ * @param bool|null $remove Whether to remove the snippet.
+ *
+ * @return void
+ */
+ public function update( string $base_dir, Snippet $snippet, ?bool $remove = false ): void {
+ }
+ };
+
+ return new Snippet_Files( new Handler_Registry( [] ), $fs, $config_repo );
+ }
+
+ /**
+ * Ensure update_snippet_fields triggers the update action with a Snippet object.
+ *
+ * @return void
+ */
public function test_update_snippet_fields_triggers_update_action_with_snippet_object() {
$snippet = new Snippet(
[
@@ -41,4 +99,147 @@ public function test_update_snippet_fields_triggers_update_action_with_snippet_o
$this->assertInstanceOf( Snippet::class, $observed );
$this->assertSame( $saved->id, $observed->id );
}
+
+ /**
+ * Ensure add_option sync only runs for the active_shared_network_snippets option.
+ *
+ * @return void
+ */
+ public function test_add_option_sync_only_runs_for_active_shared_network_snippets(): void {
+ $writes = [];
+ $fs = new class( $writes ) implements Filesystem_Adapter {
+
+ /**
+ * Paths written via put_contents().
+ *
+ * @var array
+ */
+ private array $writes;
+
+ /**
+ * Constructor.
+ *
+ * @param array $writes Write sink.
+ */
+ public function __construct( array &$writes ) {
+ $this->writes = &$writes;
+ }
+
+ /**
+ * Write file contents.
+ *
+ * @param string $path Path.
+ * @param string $contents Contents.
+ * @param mixed $chmod Chmod mode.
+ *
+ * @return bool
+ */
+ public function put_contents( string $path, string $contents, $chmod ): bool {
+ $this->writes[] = $path;
+ return true;
+ }
+
+ /**
+ * Whether a path exists.
+ *
+ * @param string $path Path.
+ *
+ * @return bool
+ */
+ public function exists( string $path ): bool {
+ return false;
+ }
+
+ /**
+ * Delete a path.
+ *
+ * @param string $file File path.
+ * @param bool $recursive Recursive delete.
+ * @param mixed $type Delete type.
+ *
+ * @return bool
+ */
+ public function delete( string $file, bool $recursive = false, $type = false ): bool {
+ return true;
+ }
+
+ /**
+ * Whether a path is a directory.
+ *
+ * @param string $path Path.
+ *
+ * @return bool
+ */
+ public function is_dir( string $path ): bool {
+ return true;
+ }
+
+ /**
+ * Create a directory.
+ *
+ * @param string $path Path.
+ * @param mixed $chmod Chmod mode.
+ *
+ * @return bool
+ */
+ public function mkdir( string $path, $chmod ): bool {
+ return true;
+ }
+
+ /**
+ * Remove a directory.
+ *
+ * @param string $path Path.
+ * @param bool $recursive Recursive remove.
+ *
+ * @return bool
+ */
+ public function rmdir( string $path, bool $recursive = false ): bool {
+ return true;
+ }
+
+ /**
+ * Change permissions.
+ *
+ * @param string $path Path.
+ * @param mixed $chmod Chmod mode.
+ *
+ * @return bool
+ */
+ public function chmod( string $path, $chmod ): bool {
+ return true;
+ }
+
+ /**
+ * Whether a path is writable.
+ *
+ * @param string $path Path.
+ *
+ * @return bool
+ */
+ public function is_writable( string $path ): bool {
+ return true;
+ }
+ };
+
+ $snippet_files = $this->build_snippet_files( $fs );
+ $snippet_files->sync_active_shared_network_snippets_add( 'litespeed.some_option', [ 1 ] );
+
+ $this->assertSame( [], $writes );
+
+ $snippet_files->sync_active_shared_network_snippets_add( 'active_shared_network_snippets', [ 1 ] );
+
+ $this->assertNotEmpty( $writes );
+ }
+
+ /**
+ * Ensure get_hashed_table_name uses WordPress hashing when available.
+ *
+ * @return void
+ */
+ public function test_get_hashed_table_name_uses_wordpress_hash_when_available(): void {
+ $table_name = 'wp_code_snippets';
+
+ $this->assertSame( wp_hash( $table_name ), Snippet_Files::get_hashed_table_name( $table_name ) );
+ }
}
diff --git a/tests/phpunit/test-manage-menu.php b/tests/phpunit/test-manage-menu.php
new file mode 100644
index 00000000..0463aff3
--- /dev/null
+++ b/tests/phpunit/test-manage-menu.php
@@ -0,0 +1,250 @@
+user->create(
+ array(
+ 'role' => 'administrator',
+ )
+ );
+ }
+
+ /**
+ * Set up before each test.
+ *
+ * @return void
+ */
+ public function set_up() {
+ parent::set_up();
+
+ wp_set_current_user( self::$admin_user_id );
+ set_current_screen( 'toplevel_page_' . code_snippets()->get_menu_slug() );
+ remove_all_filters( 'manage_' . get_current_screen()->id . '_columns' );
+ remove_all_filters( 'screen_settings' );
+ delete_user_option( self::$admin_user_id, 'snippets_table_truncate_row_values' );
+ unset(
+ $_POST['wp_screen_options'],
+ $_POST['code_snippets_action'],
+ $_POST['code_snippets_bulk_download_nonce'],
+ $_POST['snippets'],
+ $_POST['screenoptionnonce'],
+ $_POST['snippets_table_truncate_row_values'],
+ $_REQUEST['page'],
+ $_REQUEST['subpage']
+ );
+ }
+
+ /**
+ * The manage screen registers a Columns section in Screen Options.
+ *
+ * @return void
+ */
+ public function test_load_registers_screen_option_columns(): void {
+ $menu = new Manage_Menu();
+ $menu->load();
+
+ $columns = get_column_headers( get_current_screen() );
+
+ $this->assertSame( 'Columns', $columns['_title'] );
+ $this->assertSame( 'Description', $columns['desc'] );
+ $this->assertSame( 'Modified', $columns['date'] );
+ }
+
+ /**
+ * Hidden columns are localized for the manage table app.
+ *
+ * @return void
+ */
+ public function test_enqueue_assets_localizes_hidden_columns(): void {
+ $screen = get_current_screen();
+ update_user_option( self::$admin_user_id, 'manage' . $screen->id . 'columnshidden', array( 'desc', 'date' ) );
+ update_user_option( self::$admin_user_id, 'snippets_table_truncate_row_values', 0 );
+
+ $menu = new Manage_Menu();
+ $menu->enqueue_assets();
+
+ $data = wp_scripts()->get_data( Manage_Menu::JS_HANDLE, 'data' );
+
+ $this->assertIsString( $data );
+ $this->assertStringContainsString( '"hiddenColumns":["desc","date"]', $data );
+ $this->assertStringContainsString( '"truncateRowValues":"0"', $data );
+ $this->assertStringContainsString( '"bulkDownloadNonce":"', $data );
+ $this->assertStringContainsString( '"supportsZipDownloads":', $data );
+ }
+
+ /**
+ * The manage screen renders a truncation toggle in Screen Options.
+ *
+ * @return void
+ */
+ public function test_render_screen_settings_adds_truncation_toggle(): void {
+ $menu = new Manage_Menu();
+
+ $output = $menu->render_screen_settings( '', get_current_screen() );
+
+ $this->assertStringContainsString( 'snippets-table-truncate-row-values', $output );
+ $this->assertStringContainsString( 'Truncate long snippet names and descriptions', $output );
+ }
+
+ /**
+ * The Community Cloud view does not render snippet-only Screen Options controls.
+ *
+ * @return void
+ */
+ public function test_render_screen_settings_skips_truncation_toggle_on_cloud_community_view(): void {
+ $_REQUEST['subpage'] = 'cloud-community';
+
+ $menu = new Manage_Menu();
+ $output = $menu->render_screen_settings( '', get_current_screen() );
+
+ $this->assertSame( '', $output );
+ }
+
+ /**
+ * The Community Cloud view does not register snippet table columns in Screen Options.
+ *
+ * @return void
+ */
+ public function test_load_skips_screen_option_columns_on_cloud_community_view(): void {
+ $_REQUEST['subpage'] = 'cloud-community';
+
+ $menu = new Manage_Menu();
+ $menu->load();
+
+ $screen = get_current_screen();
+
+ $this->assertFalse( has_filter( "manage_{$screen->id}_columns", array( $menu, 'get_screen_columns' ) ) );
+ $this->assertFalse( has_filter( 'screen_settings', array( $menu, 'render_screen_settings' ) ) );
+ }
+
+ /**
+ * The Community Cloud view still registers the shared pagination Screen Option.
+ *
+ * @return void
+ */
+ public function test_load_registers_per_page_screen_option_on_cloud_community_view(): void {
+ $_REQUEST['subpage'] = 'cloud-community';
+
+ $menu = new Manage_Menu();
+ $menu->load();
+
+ $screen = get_current_screen();
+
+ $this->assertSame( 'snippets_per_page', $screen->get_option( 'per_page', 'option' ) );
+ $this->assertSame( 100, $screen->get_option( 'per_page', 'default' ) );
+ }
+
+ /**
+ * The truncation preference is saved from the Screen Options form.
+ *
+ * @return void
+ */
+ public function test_save_truncation_preference_updates_user_option(): void {
+ $_REQUEST['page'] = code_snippets()->get_menu_slug();
+ $_POST['wp_screen_options'] = array(
+ 'option' => 'snippets_per_page',
+ 'value' => '20',
+ );
+ $_POST['screenoptionnonce'] = wp_create_nonce( 'screen-options-nonce' );
+
+ $menu = new Manage_Menu();
+ $menu->save_truncation_preference();
+
+ $this->assertFalse( (bool) get_user_option( 'snippets_table_truncate_row_values', self::$admin_user_id ) );
+
+ $_POST['snippets_table_truncate_row_values'] = '1';
+ $menu->save_truncation_preference();
+
+ $this->assertTrue( (bool) get_user_option( 'snippets_table_truncate_row_values', self::$admin_user_id ) );
+ }
+
+ /**
+ * The Community Cloud screen does not overwrite the snippets-table truncation preference.
+ *
+ * @return void
+ */
+ public function test_save_truncation_preference_ignores_cloud_community_view(): void {
+ update_user_option( self::$admin_user_id, 'snippets_table_truncate_row_values', 1 );
+
+ $_REQUEST['page'] = code_snippets()->get_menu_slug();
+ $_REQUEST['subpage'] = 'cloud-community';
+ $_POST['wp_screen_options'] = array(
+ 'option' => 'snippets_per_page',
+ 'value' => '20',
+ );
+ $_POST['screenoptionnonce'] = wp_create_nonce( 'screen-options-nonce' );
+
+ $menu = new Manage_Menu();
+ $menu->save_truncation_preference();
+
+ $this->assertTrue( (bool) get_user_option( 'snippets_table_truncate_row_values', self::$admin_user_id ) );
+ }
+
+ /**
+ * Subsite admins cannot request downloads from the network snippets table.
+ *
+ * @return void
+ */
+ public function test_network_bulk_download_requires_network_cap(): void {
+ if ( ! is_multisite() ) {
+ $this->markTestSkipped( 'Network snippet downloads only apply on multisite.' );
+ }
+
+ $snippet = save_snippet(
+ new Snippet(
+ array(
+ 'name' => 'Network Download Fixture',
+ 'code' => ' 'global',
+ 'network' => true,
+ )
+ )
+ );
+
+ $_POST['snippets'] = wp_json_encode(
+ array(
+ array(
+ 'id' => $snippet->id,
+ 'network' => true,
+ ),
+ )
+ );
+
+ $menu = new Manage_Menu();
+ $method = new ReflectionMethod( $menu, 'get_requested_download_snippets' );
+ $method->setAccessible( true );
+ $result = $method->invoke( $menu );
+
+ $this->assertInstanceOf( WP_Error::class, $result );
+ $this->assertSame( 'code_snippets_forbidden_network_download', $result->get_error_code() );
+ }
+}
diff --git a/tests/phpunit/test-rest-api-cloud.php b/tests/phpunit/test-rest-api-cloud.php
new file mode 100644
index 00000000..cac461a3
--- /dev/null
+++ b/tests/phpunit/test-rest-api-cloud.php
@@ -0,0 +1,225 @@
+user->create(
+ [
+ 'role' => 'administrator',
+ ]
+ );
+ }
+
+ /**
+ * Set up before each test.
+ *
+ * @return void
+ */
+ public function set_up() {
+ parent::set_up();
+
+ wp_set_current_user( self::$admin_user_id );
+ $this->requested_url = '';
+ delete_user_option( self::$admin_user_id, 'snippets_per_page' );
+ add_filter( 'pre_http_request', [ $this, 'mock_cloud_search_request' ], 10, 3 );
+ }
+
+ /**
+ * Tear down after each test.
+ *
+ * @return void
+ */
+ public function tear_down() {
+ remove_filter( 'pre_http_request', [ $this, 'mock_cloud_search_request' ], 10 );
+ delete_user_option( self::$admin_user_id, 'snippets_per_page' );
+
+ parent::tear_down();
+ }
+
+ /**
+ * Mock the outbound cloud search request.
+ *
+ * @param mixed $preempt Existing preempted value.
+ * @param array $parsed_args Parsed HTTP request arguments.
+ * @param string $url Requested URL.
+ *
+ * @return mixed
+ */
+ public function mock_cloud_search_request( $preempt, array $parsed_args, string $url ) {
+ if ( false === strpos( $url, 'public/search' ) ) {
+ return $preempt;
+ }
+
+ $this->requested_url = $url;
+
+ parse_str( (string) wp_parse_url( $url, PHP_URL_QUERY ), $query_args );
+
+ $per_page = isset( $query_args['per_page'] ) ? (int) $query_args['per_page'] : self::DEFAULT_PER_PAGE;
+ $page = isset( $query_args['page'] ) ? (int) $query_args['page'] : 0;
+ $total_items = 12;
+ $total_pages = (int) ceil( $total_items / max( 1, $per_page ) );
+ $items_to_return = min( $per_page, $total_items );
+
+ $snippets = [];
+
+ for ( $index = 0; $index < $items_to_return; $index++ ) {
+ $snippets[] = [
+ 'id' => ( $page * $per_page ) + $index + 1,
+ 'name' => 'Cloud Snippet ' . ( $index + 1 ),
+ 'description' => 'Test description',
+ 'code' => ' [],
+ 'scope' => 'global',
+ 'status' => 4,
+ 'codevault' => 'General',
+ 'vote_count' => '0',
+ 'updated' => '2026-03-10 12:00:00',
+ ];
+ }
+
+ return [
+ 'headers' => [],
+ 'body' => wp_json_encode(
+ [
+ 'data' => $snippets,
+ 'meta' => [
+ 'total' => $total_items,
+ 'total_pages' => $total_pages,
+ 'page' => $page + 1,
+ ],
+ ]
+ ),
+ 'response' => [
+ 'code' => 200,
+ 'message' => 'OK',
+ ],
+ 'cookies' => [],
+ ];
+ }
+
+ /**
+ * Make a REST API request to the cloud endpoint.
+ *
+ * @param array $params Request params.
+ *
+ * @return \WP_REST_Response
+ */
+ private function make_request( array $params ) {
+ $request = new WP_REST_Request( 'GET', $this->endpoint );
+
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+
+ return rest_do_request( $request );
+ }
+
+ /**
+ * The cloud REST endpoint uses the snippets Screen Options value when per_page is omitted.
+ *
+ * @return void
+ */
+ public function test_get_items_uses_snippets_per_page_user_option(): void {
+ update_user_option( self::$admin_user_id, 'snippets_per_page', 7 );
+
+ $response = $this->make_request(
+ [
+ 'query' => 'test',
+ 'page' => 2,
+ ]
+ );
+
+ parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( '7', $query_args['per_page'] ?? null );
+ $this->assertSame( '1', $query_args['page'] ?? null );
+ }
+
+ /**
+ * Screen Options values above the cloud API limit are capped before the request is sent.
+ *
+ * @return void
+ */
+ public function test_get_items_caps_snippets_per_page_user_option_at_one_hundred(): void {
+ update_user_option( self::$admin_user_id, 'snippets_per_page', 250 );
+
+ $response = $this->make_request(
+ [
+ 'query' => 'test',
+ 'page' => 2,
+ ]
+ );
+
+ parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( '100', $query_args['per_page'] ?? null );
+ $this->assertSame( '1', $query_args['page'] ?? null );
+ }
+
+ /**
+ * Explicit per_page requests override the snippets Screen Options value.
+ *
+ * @return void
+ */
+ public function test_get_items_respects_explicit_per_page_request(): void {
+ update_user_option( self::$admin_user_id, 'snippets_per_page', 7 );
+
+ $response = $this->make_request(
+ [
+ 'query' => 'test',
+ 'page' => 2,
+ 'per_page' => 3,
+ ]
+ );
+
+ parse_str( (string) wp_parse_url( $this->requested_url, PHP_URL_QUERY ), $query_args );
+
+ $this->assertSame( 200, $response->get_status() );
+ $this->assertSame( '3', $query_args['per_page'] ?? null );
+ $this->assertSame( '1', $query_args['page'] ?? null );
+ }
+}
diff --git a/tests/phpunit/test-rest-api-snippets.php b/tests/phpunit/test-rest-api-snippets.php
index a8c574ff..3a845ec4 100644
--- a/tests/phpunit/test-rest-api-snippets.php
+++ b/tests/phpunit/test-rest-api-snippets.php
@@ -5,6 +5,7 @@
use Code_Snippets\Model\Snippet;
use WP_REST_Request;
use function Code_Snippets\code_snippets;
+use function Code_Snippets\get_snippet;
use function Code_Snippets\save_snippet;
/**
@@ -108,6 +109,26 @@ protected function make_request( $endpoint, $params = [] ) {
return rest_get_server()->response_to_data( $response, false );
}
+ /**
+ * Helper method to make a writable REST API request.
+ *
+ * @param string $method HTTP method.
+ * @param string $endpoint Endpoint to request.
+ * @param array $params Request params.
+ *
+ * @return array
+ */
+ protected function make_mutating_request( string $method, string $endpoint, array $params ): array {
+ $request = new WP_REST_Request( $method, $endpoint );
+
+ foreach ( $params as $key => $value ) {
+ $request->set_param( $key, $value );
+ }
+
+ $response = rest_do_request( $request );
+ return rest_get_server()->response_to_data( $response, false );
+ }
+
/**
* Test that we can retrieve all snippets without pagination.
*/
@@ -319,4 +340,54 @@ public function test_snippet_data_structure() {
$this->assertIsBool( $snippet['active'] );
$this->assertIsArray( $snippet['tags'] );
}
+
+ /**
+ * Test that the snippet description is loaded from the database.
+ */
+ public function test_snippet_description_is_loaded_from_database() {
+ $snippet = new Snippet(
+ [
+ 'name' => 'Description Fixture',
+ 'desc' => 'Persisted description text',
+ 'code' => '// Description fixture',
+ 'scope' => 'global',
+ 'active' => false,
+ ]
+ );
+
+ $saved = save_snippet( $snippet );
+
+ $this->assertInstanceOf( Snippet::class, $saved );
+ $this->assertGreaterThan( 0, $saved->id );
+
+ $loaded = get_snippet( $saved->id );
+
+ $this->assertSame( 'Persisted description text', $loaded->desc );
+ }
+
+ /**
+ * Test that activation failures return the PHP error and stack trace while keeping the snippet saved.
+ */
+ public function test_create_active_snippet_returns_runtime_error_details() {
+ $response = $this->make_mutating_request(
+ 'POST',
+ "/{$this->namespace}/{$this->base_route}",
+ [
+ 'name' => 'Activation Error Fixture',
+ 'code' => 'function code_snippets_build_tags_array() {}',
+ 'scope' => 'global',
+ 'active' => true,
+ 'network' => false,
+ ]
+ );
+
+ $this->assertArrayHasKey( 'id', $response );
+ $this->assertGreaterThan( 0, $response['id'] );
+ $this->assertFalse( $response['active'] );
+ $this->assertIsArray( $response['code_error'] );
+ $this->assertStringContainsString( 'Cannot redeclare', $response['code_error'][0] );
+ $this->assertArrayHasKey( 'code_error_trace', $response );
+ $this->assertIsString( $response['code_error_trace'] );
+ $this->assertNotSame( '', $response['code_error_trace'] );
+ }
}