diff --git a/.github/instructions/code-review.instructions.md b/.github/instructions/code-review.instructions.md index 33dfc55f..b18bf225 100644 --- a/.github/instructions/code-review.instructions.md +++ b/.github/instructions/code-review.instructions.md @@ -15,6 +15,20 @@ Severity labels used in this file: - **MUST** — Flag as a blocking issue; must be resolved before merge. - **SHOULD** — Flag as a recommendation; resolve before merge unless a risk-aware rationale is provided. +## Practical Reviewer Checks + +- Don't silently widen types or drop generics when refactoring. If typing gets weaker, require a clear reason. [MUST] +- In namespaced PHP, call WordPress globals with `\function_name()` and do not assume pluggable/core functions exist on very early execution paths. If a callback can run during early bootstrap, guard availability appropriately. [MUST] +- Ensure hook callbacks that respond to options/actions are tightly gated to the intended option/action. Be suspicious of inverted or overly broad conditionals that can be triggered by other plugins. [MUST] +- Avoid no-op abstractions (pass-through helpers, one-liner wrappers) unless they materially improve readability, reuse, or testability. [SHOULD] +- Inline single-use extractions that do not clarify intent (local functions/variables used once). [SHOULD] +- Prefer `undefined` for absent optional values in TypeScript/React unless `null` has explicit semantics in that API. [SHOULD] +- For conditional class names, prefer the repository `classnames.classnames` helper over manual array filtering and joining. [SHOULD] +- Prefer JSX for React markup. If a hook/util needs to render elements, suggest moving the markup into a `.tsx` component instead of using `createElement` in a `.ts` file. [SHOULD] +- For simple key-to-value parsing/transforms, prefer a literal object/record map over a loop + `switch` when it improves clarity. [SHOULD] +- Do not stack redundant `catch` blocks (e.g., `ParseError` plus `Throwable`) unless the handlers differ. [SHOULD] +- When a screen is React-driven, question heavy PHP view logic. If PHP is used due to WordPress admin primitives (e.g., Screen Options, non-REST file streaming), require a short rationale. [SHOULD] + ## Scope and Diff Hygiene - Flag PRs that mix behavior changes with refactors, renames, or formatting-only edits. Each change should be single-purpose. [MUST] diff --git a/eslint.config.mjs b/eslint.config.mjs index adebe1ba..63e623ca 100755 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,7 +26,11 @@ export default eslintTs.config( rules: reactHooks.configs.recommended.rules, }, { - ignores: ['bundle/*', 'src/dist/*', 'src/vendor/*', 'svn/*', '*.config.mjs', '*.config.js', '.*/*', 'tmp/*'] + ignores: [ + 'bundle/*', 'src/dist/*', 'src/vendor/*', 'svn/*', + '*.config.mjs', '*.config.js', + '.*/*', 'tmp/*', 'playwright-report/*' + ] }, { languageOptions: { diff --git a/src/css/common/_notices.scss b/src/css/common/_notices.scss new file mode 100644 index 00000000..f64cf9d1 --- /dev/null +++ b/src/css/common/_notices.scss @@ -0,0 +1,27 @@ +.code-snippets-notice { + .notice-dismiss { + position: absolute; + transform: initial; + inset-inline-end: 0; + inset-block-start: 0; + } + + details { + margin: .5em 0; + padding: 2px; + + summary { + cursor: pointer; + } + + pre { + max-inline-size: 100%; + overflow: auto hidden; + white-space: pre; + } + + .stack-trace-hint { + opacity: 0.5; + } + } +} diff --git a/src/css/edit.scss b/src/css/edit.scss index e47bfd31..5933ad11 100644 --- a/src/css/edit.scss +++ b/src/css/edit.scss @@ -9,6 +9,7 @@ @use 'common/select'; @use 'common/tooltips'; @use 'common/modal'; +@use 'common/notices'; @use 'common/upsell'; @use 'common/toolbar'; @use 'edit/form'; @@ -28,6 +29,10 @@ margin: 0; } +#adminmenu a.code-snippets-edit-menu-link { + cursor: pointer; +} + .snippet-description-container { .wp-editor-tools { padding-block-start: 5px; @@ -96,13 +101,3 @@ form.condition-snippet .snippet-code-container { display: none; } - -.cs-back { - cursor: pointer; - - &::before { - content: '<'; - color: #2271b1; - margin-inline-end: 3px; - } -} \ No newline at end of file diff --git a/src/css/manage.scss b/src/css/manage.scss index 86b95d37..219cf1b2 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -4,6 +4,7 @@ @use 'common/tooltips'; @use 'common/direction'; @use 'common/select'; +@use 'common/notices'; @use 'common/upsell'; @use 'common/toolbar'; @use 'prism'; @@ -61,10 +62,6 @@ } } -.code-snippets-notice a.notice-dismiss { - text-decoration: none; -} - .refresh-button-container { display: flex; align-items: center; diff --git a/src/css/manage/_cloud-community.scss b/src/css/manage/_cloud-community.scss index a702a5c2..09e6e4b3 100644 --- a/src/css/manage/_cloud-community.scss +++ b/src/css/manage/_cloud-community.scss @@ -1,12 +1,61 @@ @use '../common/theme'; @use '../common/banners'; +.cloud-search-form { + display: flex; + gap: 8px; + margin-block: 31px 47px; + block-size: 54px; + + > select { + flex: 0 0 250px; + } + + .button { + flex: 0 0 165px; + } + + .cloud-search-query { + flex: 1; + position: relative; + + input { + inline-size: 100%; + block-size: 100%; + } + + > .components-spinner { + position: absolute; + inset-inline-end: 1.5em; + inset-block-start: 25%; + } + } +} + .cloud-search { @include banners.banners; .banner { justify-content: center; } + + .tablenav.top { + display: flex; + gap: 20px; + block-size: 40px; + margin-block-end: 31px; + align-items: center; + + select { + inline-size: 245px; + block-size: 100%; + } + + .tablenav-pages { + margin: 0; + margin-inline-start: auto; + } + } } .cloud-search-results { @@ -79,10 +128,6 @@ padding-block: 12px; align-items: center; - .components-spinner { - margin: 0; - } - .dashicons-warning { color: #b32d2e; } @@ -100,51 +145,6 @@ } } -.cloud-search-form { - display: flex; - gap: 8px; - margin-block: 31px 47px; - block-size: 54px; - - select { - flex: 0 0 250px; - } - - .button { - flex: 0 0 165px; - } - - .cloud-search-query { - flex: 1; - position: relative; - - input { - inline-size: 100%; - block-size: 100%; - } - - .components-spinner { - position: absolute; - inset-inline-end: 1.5em; - inset-block-start: 25%; - } - } -} - -.tablenav.top { - display: flex; - gap: 20px; - block-size: 40px; - margin-block-end: 31px; - align-items: center; - - select { - inline-size: 245px; - block-size: 100%; - } - - .tablenav-pages { - margin: 0; - margin-inline-start: auto; - } +.cloud-search-results .cloud-search-result footer > .components-spinner { + margin: 0; } diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss index c071b821..04e75969 100644 --- a/src/css/manage/_snippets-table.scss +++ b/src/css/manage/_snippets-table.scss @@ -166,10 +166,31 @@ min-inline-size: 130px; max-inline-size: 130px; text-align: end; - padding-inline: 8px; overflow: hidden; text-overflow: ellipsis; } + + td.column-date .modified-column-content { + display: block; + text-align: start; + } +} + +.wp-list-table.truncate-row-values { + td.column-name > .snippet-name, + td.column-desc .snippet-description-content { + display: block; + overflow: hidden; + text-overflow: ellipsis; + } + + td.column-name > .snippet-name { + max-inline-size: min(15rem, 30vw); + } + + td.column-desc .snippet-description-content { + max-inline-size: min(25rem, 45vw); + } } .wp-core-ui .button.clear-filters { diff --git a/src/js/components/EditMenu/EditMenu.tsx b/src/js/components/EditMenu/EditMenu.tsx index 2272559a..e657adeb 100644 --- a/src/js/components/EditMenu/EditMenu.tsx +++ b/src/js/components/EditMenu/EditMenu.tsx @@ -1,5 +1,118 @@ -import React from 'react' +import React, { useEffect } from 'react' import { SnippetForm } from './SnippetForm' -export const EditMenu = () => - +const EVENT_NAME = 'code_snippets_focus_editor' + +interface EditMenuLinkBinding { + menuLink: HTMLAnchorElement + originalHref: string | undefined + originalRole: string | undefined + originalTabIndex: string | undefined + handleClick: (event: MouseEvent) => void + handleKeyDown: (event: KeyboardEvent) => void +} + +const focusCodeEditor = () => { + window.dispatchEvent(new CustomEvent(EVENT_NAME)) +} + +const restoreAttribute = (menuLink: HTMLAnchorElement, name: string, value: string | undefined) => { + if (undefined !== value) { + menuLink.setAttribute(name, value) + return + } + + menuLink.removeAttribute(name) +} + +const getEditPage = (): string | undefined => { + const editUrl = window.CODE_SNIPPETS?.urls.edit + + return editUrl ? new URL(editUrl, window.location.origin).searchParams.get('page') ?? undefined : undefined +} + +const bindEditMenuLink = (menuLink: HTMLAnchorElement, page: string): EditMenuLinkBinding | undefined => { + const menuUrl = new URL(menuLink.href, window.location.origin) + + if (page !== menuUrl.searchParams.get('page')) { + return undefined + } + + const handleClick = (event: MouseEvent) => { + event.preventDefault() + focusCodeEditor() + } + + const handleKeyDown = (event: KeyboardEvent) => { + if ('Enter' !== event.key && ' ' !== event.key) { + return + } + + event.preventDefault() + focusCodeEditor() + } + + const binding = { + menuLink, + originalHref: menuLink.getAttribute('href') ?? undefined, + originalRole: menuLink.getAttribute('role') ?? undefined, + originalTabIndex: menuLink.getAttribute('tabindex') ?? undefined, + handleClick, + handleKeyDown + } + + menuLink.dataset.codeSnippetsDisabled = 'true' + menuLink.setAttribute('role', 'button') + menuLink.setAttribute('tabindex', '0') + menuLink.classList.add('code-snippets-edit-menu-link') + menuLink.removeAttribute('href') + menuLink.addEventListener('click', handleClick) + menuLink.addEventListener('keydown', handleKeyDown) + + return binding +} + +const unbindEditMenuLink = ({ + menuLink, + originalHref, + originalRole, + originalTabIndex, + handleClick, + handleKeyDown +}: EditMenuLinkBinding) => { + menuLink.removeEventListener('click', handleClick) + menuLink.removeEventListener('keydown', handleKeyDown) + menuLink.classList.remove('code-snippets-edit-menu-link') + delete menuLink.dataset.codeSnippetsDisabled + + restoreAttribute(menuLink, 'href', originalHref) + restoreAttribute(menuLink, 'role', originalRole) + restoreAttribute(menuLink, 'tabindex', originalTabIndex) +} + +const useEditMenuLinkFocus = () => { + useEffect(() => { + const page = getEditPage() + + if (!page) { + return + } + + const editMenuLinks = Array.from(document.querySelectorAll('#adminmenu a[href]')) + .flatMap(menuLink => { + const binding = bindEditMenuLink(menuLink, page) + + return binding ? [binding] : [] + }) + + return () => { + editMenuLinks.forEach(unbindEditMenuLink) + } + }, []) +} + +export const EditMenu = () => { + useEditMenuLinkFocus() + + return +} diff --git a/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx b/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx index 568aeed6..ba5e5888 100644 --- a/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx +++ b/src/js/components/EditMenu/EditorSidebar/EditorSidebar.tsx @@ -64,7 +64,7 @@ export const EditorSidebar: React.FC = ({ setIsUpgradeDialog {isWorking ? : ''}

- + ) } diff --git a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx index 4dbf71bd..e9c702cc 100644 --- a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx +++ b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx @@ -19,6 +19,7 @@ import { TagsEditor } from './fields/TagsEditor' import { CodeEditor } from './fields/CodeEditor' import { DescriptionEditor } from './fields/DescriptionEditor' import { NameInput } from './fields/NameInput' +import { Notices } from './page/Notices' import { PageHeading } from './page/PageHeading' import type { PropsWithChildren } from 'react' import type { Snippet } from '../../../types/Snippet' @@ -147,17 +148,8 @@ const EditFormWrap: React.FC = () => { return (
-

- {isCondition(snippet) - ? - {__('Back to all conditions', 'code-snippets')} - - : - {__('Back to all snippets', 'code-snippets')} - } -

- +
diff --git a/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx index 566a13b8..19eb19e8 100644 --- a/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx @@ -14,6 +14,29 @@ interface EditorTextareaProps { textareaRef: RefObject } +const useFocusEditorShortcut = ( + textareaRef: RefObject +) => { + const { codeEditorInstance } = useSnippetForm() + + useEffect(() => { + const focusEditor = () => { + if (codeEditorInstance) { + codeEditorInstance.codemirror.focus() + return + } + + textareaRef.current?.focus() + } + + window.addEventListener('code_snippets_focus_editor', focusEditor) + + return () => { + window.removeEventListener('code_snippets_focus_editor', focusEditor) + } + }, [codeEditorInstance, textareaRef]) +} + const EditorTextarea: React.FC = ({ textareaRef }) => { const { snippet, setSnippet } = useSnippetForm() @@ -77,6 +100,8 @@ export const CodeEditor: React.FC = ({ isExpanded, setIsExpande } }, [submitSnippet, codeEditorInstance, snippet]) + useFocusEditorShortcut(textareaRef) + return (
diff --git a/src/js/components/EditMenu/SnippetForm/page/Notices.tsx b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx index 3b2e3bc5..4c29bc79 100644 --- a/src/js/components/EditMenu/SnippetForm/page/Notices.tsx +++ b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx @@ -1,23 +1,81 @@ import { createInterpolateElement } from '@wordpress/element' -import React from 'react' +import React, { useEffect, useRef, useState } from 'react' import { __, sprintf } from '@wordpress/i18n' import { useSnippetForm } from '../WithSnippetFormContext' import { DismissibleNotice } from '../../../common/Notice' +import type { ReactNode } from 'react' -export const Notices: React.FC = () => { +const DESCRIPTION_INDEX = 2 +const DETAILS_INDEX = 3 +const hasStackTrace = (notice?: readonly unknown[]): boolean => Boolean(notice?.[DETAILS_INDEX]) +const renderNoticeLine = (line: ReactNode): ReactNode => + 'string' === typeof line + ? createInterpolateElement(line, { strong: }) + : line + +interface NoticesProps { + placement: 'above-form' | 'sidebar' +} + +const StackTraceDetails: React.FC<{ trace: string }> = ({ trace }) => { + const preRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [showHint, setShowHint] = useState(false) + + useEffect(() => { + if (!isOpen) { + setShowHint(false) + return + } + + const updateHintVisibility = () => { + const pre = preRef.current + setShowHint(Boolean(pre && pre.scrollWidth > pre.clientWidth)) + } + + updateHintVisibility() + window.addEventListener('resize', updateHintVisibility) + + return () => { + window.removeEventListener('resize', updateHintVisibility) + } + }, [isOpen, trace]) + + return ( +
setIsOpen(event.currentTarget.open)}> + {__('View stack trace', 'code-snippets')} +
{trace}
+ {showHint + ?

+ {__('Scroll horizontally if the trace is cut off.', 'code-snippets')} +

+ : null} +
+ ) +} + +export const Notices: React.FC = ({ placement }) => { const { currentNotice, setCurrentNotice, snippet, setSnippet } = useSnippetForm() + const showCurrentNotice = 'above-form' === placement ? hasStackTrace(currentNotice) : !hasStackTrace(currentNotice) + const showCodeErrorNotice = 'sidebar' === placement && !snippet.code_error_trace return <> - {currentNotice - ? setCurrentNotice(undefined)}> -

{createInterpolateElement(currentNotice[1], { strong: })}

+ {showCurrentNotice && currentNotice + ? setCurrentNotice(undefined)}> +

{renderNoticeLine(currentNotice[1])}

+ {currentNotice[DESCRIPTION_INDEX] + ?

{renderNoticeLine(currentNotice[DESCRIPTION_INDEX])}

+ : null} + {currentNotice[DETAILS_INDEX] + ? + : null}
: null} - {snippet.code_error + {showCodeErrorNotice && !currentNotice && snippet.code_error ? 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) : [])) }} /> +
+ + 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'] ); + } }