From 7ad52a2f0a125f50ecb5c1ebf9d45200683bb560 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 12:21:54 +0200 Subject: [PATCH 01/80] fix: restore snippet description hydration --- src/php/Model/Model.php | 4 ++-- tests/phpunit/test-rest-api-snippets.php | 25 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) 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/tests/phpunit/test-rest-api-snippets.php b/tests/phpunit/test-rest-api-snippets.php index a8c574ff..27e2e797 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; /** @@ -319,4 +320,28 @@ 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 ); + } } From 25c28e9de62e19a7eb9fe0ca741ca6d96ff154a2 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 12:25:34 +0200 Subject: [PATCH 02/80] fix: activate new snippets on first save --- src/js/hooks/useSubmitSnippet.ts | 18 +++++++++++++----- tests/e2e/code-snippets-edit.spec.ts | 27 +++++++++++++++++++++++++-- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/src/js/hooks/useSubmitSnippet.ts b/src/js/hooks/useSubmitSnippet.ts index e8f56976..4f64a00c 100644 --- a/src/js/hooks/useSubmitSnippet.ts +++ b/src/js/hooks/useSubmitSnippet.ts @@ -84,13 +84,21 @@ export const useSubmitSnippet = (): UseSubmitSnippet => { setCurrentNotice(undefined) setIsWorking(true) + const request = { ...snippet } + + if (SubmitSnippetAction.SAVE_AND_ACTIVATE === action) { + request.active = true + } else if (SubmitSnippetAction.SAVE_AND_DEACTIVATE === action) { + request.active = false + } + const result = await (async (): Promise => { try { - const { id } = snippet + const { id } = request const response = await (undefined === id || 0 === id - ? api.create(snippet) - : api.update({ ...snippet, id })) + ? api.create(request) + : api.update({ ...request, id })) return response.id ? createSnippetObject(response) : undefined } catch (error) { @@ -104,7 +112,7 @@ export const useSubmitSnippet = (): UseSubmitSnippet => { if (undefined === result || 'string' === typeof result) { const message = [ - snippet.id ? messages.failedUpdate : messages.failedCreate, + request.id ? messages.failedUpdate : messages.failedCreate, result ?? __('The server did not send a valid response.', 'code-snippets') ] @@ -123,7 +131,7 @@ export const useSubmitSnippet = (): UseSubmitSnippet => { setCurrentNotice(['updated', getSuccessNotice(snippet, result, action)]) } - if (snippet.id && result.id) { + if (request.id && result.id) { window.document.title = window.document.title.replace(snippetMessages.addNew, messages.edit) window.history.replaceState({}, '', buildUrl(window.CODE_SNIPPETS?.urls.edit, { id: result.id })) } diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index d8dc103b..8cc5c818 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,29 @@ 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('Can delete a snippet', async () => { const snippetName = SnippetsTestHelper.makeUniqueSnippetName() await helper.createSnippet({ From 3bbb18efbb31f5e6ac37a9a58fda9443becdd742 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 12:33:32 +0200 Subject: [PATCH 03/80] fix: surface activation errors after save --- .../EditMenu/SnippetForm/page/Notices.tsx | 16 ++++++- src/js/hooks/useSubmitSnippet.ts | 9 ++-- src/js/types/ScreenNotice.ts | 2 +- src/js/types/Snippet.ts | 1 + src/js/types/schema/SnippetSchema.ts | 1 + src/js/utils/snippets/objects.ts | 5 +- src/php/Model/Snippet.php | 2 + .../Snippets/Snippets_REST_Controller.php | 10 +++- src/php/Utils/Validator.php | 14 ++++-- src/php/snippet-ops.php | 14 +++++- tests/e2e/code-snippets-edit.spec.ts | 32 +++++++++++++ tests/phpunit/test-rest-api-snippets.php | 46 +++++++++++++++++++ 12 files changed, 138 insertions(+), 14 deletions(-) diff --git a/src/js/components/EditMenu/SnippetForm/page/Notices.tsx b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx index 3b2e3bc5..41ae6994 100644 --- a/src/js/components/EditMenu/SnippetForm/page/Notices.tsx +++ b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx @@ -4,6 +4,9 @@ import { __, sprintf } from '@wordpress/i18n' import { useSnippetForm } from '../WithSnippetFormContext' import { DismissibleNotice } from '../../../common/Notice' +const DESCRIPTION_INDEX = 2 +const DETAILS_INDEX = 3 + export const Notices: React.FC = () => { const { currentNotice, setCurrentNotice, snippet, setSnippet } = useSnippetForm() @@ -11,13 +14,22 @@ export const Notices: React.FC = () => { {currentNotice ? setCurrentNotice(undefined)}>

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

+ {currentNotice[DESCRIPTION_INDEX] + ?

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

+ : null} + {currentNotice[DETAILS_INDEX] + ?
+ {__('View stack trace', 'code-snippets')} +
{currentNotice[DETAILS_INDEX]}
+
+ : null}
: null} - {snippet.code_error + {!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/hooks/useSubmitSnippet.ts b/src/js/hooks/useSubmitSnippet.ts index 4f64a00c..f3686ea9 100644 --- a/src/js/hooks/useSubmitSnippet.ts +++ b/src/js/hooks/useSubmitSnippet.ts @@ -1,4 +1,4 @@ -import { __ } from '@wordpress/i18n' +import { __, sprintf } from '@wordpress/i18n' import { isAxiosError } from 'axios' import { useCallback } from 'react' import { useSnippetForm } from '../components/EditMenu/SnippetForm/WithSnippetFormContext' @@ -122,10 +122,13 @@ export const useSubmitSnippet = (): UseSubmitSnippet => { setSnippet(result) - if (result.code_error) { + if (result.code_error && SubmitSnippetAction.SAVE_AND_ACTIVATE === action) { setCurrentNotice([ 'error', - __('Snippet could not be activated because the code contains an error. See details below.', 'code-snippets') + // translators: %s: single-line PHP error message. + sprintf(__('Snippet could not be activated: %s', 'code-snippets'), result.code_error[0]), + __('The snippet was saved, but remains inactive.', 'code-snippets'), + result.code_error_trace ?? undefined ]) } else { setCurrentNotice(['updated', getSuccessNotice(snippet, result, action)]) diff --git a/src/js/types/ScreenNotice.ts b/src/js/types/ScreenNotice.ts index 9b2c52a2..dbd1bdef 100644 --- a/src/js/types/ScreenNotice.ts +++ b/src/js/types/ScreenNotice.ts @@ -1 +1 @@ -export type ScreenNotice = ['error' | 'updated', string] +export type ScreenNotice = ['error' | 'updated', string, string?, string?] diff --git a/src/js/types/Snippet.ts b/src/js/types/Snippet.ts index 806069a9..711a525e 100644 --- a/src/js/types/Snippet.ts +++ b/src/js/types/Snippet.ts @@ -15,6 +15,7 @@ export interface Snippet { readonly conditionId: number readonly lastActive?: number readonly code_error?: readonly [string, number] | null + readonly code_error_trace?: string | null } export const SNIPPET_TYPES = ['php', 'html', 'css', 'js', 'cond'] diff --git a/src/js/types/schema/SnippetSchema.ts b/src/js/types/schema/SnippetSchema.ts index 89bea458..bdc6bc67 100644 --- a/src/js/types/schema/SnippetSchema.ts +++ b/src/js/types/schema/SnippetSchema.ts @@ -20,4 +20,5 @@ export interface SnippetSchema extends Readonly> readonly modified: string readonly last_active?: number readonly code_error?: readonly [string, number] | null + readonly code_error_trace?: string | null } diff --git a/src/js/utils/snippets/objects.ts b/src/js/utils/snippets/objects.ts index eb27c215..3d5439fa 100644 --- a/src/js/utils/snippets/objects.ts +++ b/src/js/utils/snippets/objects.ts @@ -18,7 +18,8 @@ const defaults: Omit = { shared_network: null, priority: 10, conditionId: 0, - code_error: null + code_error: null, + code_error_trace: null } const isAbsInt = (value: unknown): value is number => @@ -59,6 +60,8 @@ export const parseSnippetObject = (fields: unknown): Snippet => { ...'priority' in fields && 'number' === typeof fields.priority && { priority: fields.priority }, ...'condition_id' in fields && isAbsInt(fields.condition_id) && { conditionId: fields.condition_id }, ...'code_error' in fields && isCodeError(fields.code_error) && { code_error: fields.code_error }, + ...'code_error_trace' in fields && ('string' === typeof fields.code_error_trace || null === fields.code_error_trace) && + { code_error_trace: fields.code_error_trace }, ...'last_active' in fields && { lastActive: Number(fields.last_active) } } } diff --git a/src/php/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/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..8691d085 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( $identifier ); + $namespaced_identifier = 'code_snippets\\' . ltrim( $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,7 +127,8 @@ private function check_duplicate_identifier( string $type, string $identifier ): } } - $duplicate = in_array( $identifier, $this->defined_identifiers[ $type ], true ); + $duplicate = in_array( $identifier, $this->defined_identifiers[ $type ], true ) || + in_array( $namespaced_identifier, $this->defined_identifiers[ $type ], true ); array_unshift( $this->defined_identifiers[ $type ], $identifier ); return $duplicate && ! ( isset( $this->exceptions[ $type ] ) && in_array( $identifier, $this->exceptions[ $type ], true ) ); diff --git a/src/php/snippet-ops.php b/src/php/snippet-ops.php index 0ca0848f..df024628 100644 --- a/src/php/snippet-ops.php +++ b/src/php/snippet-ops.php @@ -8,6 +8,7 @@ namespace Code_Snippets; use Code_Snippets\Core\DB; +use Exception; use ParseError; use Code_Snippets\Model\Snippet; use Code_Snippets\Utils\Validator; @@ -584,6 +585,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 +596,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 +692,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 +707,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 +740,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 @@ -755,6 +763,8 @@ function execute_snippet( string $code, int $id = 0, bool $force = false ) { $result = eval( $code ); } catch ( ParseError $parse_error ) { $result = $parse_error; + } catch ( Throwable $throwable ) { + $result = $throwable; } ob_end_clean(); diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index 8cc5c818..64512bbe 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -63,6 +63,38 @@ test.describe('Code Snippets Admin', () => { 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('.snippet-editor-sidebar .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.') + + 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/phpunit/test-rest-api-snippets.php b/tests/phpunit/test-rest-api-snippets.php index 27e2e797..3a845ec4 100644 --- a/tests/phpunit/test-rest-api-snippets.php +++ b/tests/phpunit/test-rest-api-snippets.php @@ -109,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. */ @@ -344,4 +364,30 @@ public function test_snippet_description_is_loaded_from_database() { $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'] ); + } } From 9c936213a6b7607abf18ff6ffdeb5d8d7ea6517d Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 12:37:05 +0200 Subject: [PATCH 04/80] fix: restore bulk snippet export --- .../SnippetsTable/SnippetsListTable.tsx | 63 ++++++------ src/js/utils/files.ts | 95 +++++++++++++++++++ tests/e2e/code-snippets-list.spec.ts | 31 ++++++ 3 files changed, 162 insertions(+), 27 deletions(-) diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx index 0d1b4760..39c32a84 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx @@ -4,6 +4,7 @@ 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' @@ -16,33 +17,6 @@ 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')], @@ -176,8 +150,43 @@ export const SnippetsListTable: React.FC = () => { const { currentStatus, setCurrentStatus } = useSnippetsFilters() const { snippetsByStatus } = useFilteredSnippets() + const allSnippets = useMemo(() => snippetsByStatus.get('all') ?? [], [snippetsByStatus]) const totalItems = snippetsByStatus.get(currentStatus)?.length ?? 0 const itemsPerPage = window.CODE_SNIPPETS_MANAGE?.snippetsPerPage + const actions: ListTableBulkAction[] = 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: __('Export code', 'code-snippets'), + apply: () => Promise.resolve() + }, + { + name: __('Trash', 'code-snippets'), + apply: () => Promise.resolve() + } + ], + [allSnippets] + ) useEffect(() => { if (INDEX_STATUS !== currentStatus && !snippetsByStatus.has(currentStatus)) { diff --git a/src/js/utils/files.ts b/src/js/utils/files.ts index f87d4b66..b9abf8b0 100644 --- a/src/js/utils/files.ts +++ b/src/js/utils/files.ts @@ -5,6 +5,10 @@ import type { Snippet } from '../types/Snippet' const SECOND_IN_MS = 1000 const TIMEOUT_SECONDS = 40 const JSON_INDENT_SPACES = 2 +const EXPORT_FILENAME = 'snippets' +const EXPORT_GENERATOR = 'Code Snippets' +const DEFAULT_PRIORITY = 10 +const EXPORT_DATE_LENGTH = 16 const MIME_INFO = { php: ['php', 'text/php'], @@ -50,3 +54,94 @@ export const downloadSnippetExportFile = ( downloadAsFile(JSON.stringify(content, undefined, JSON_INDENT_SPACES), filename, 'application/json') } } + +const isDefaultExportValue = (field: string, value: unknown): boolean => { + switch (field) { + case 'desc': + case 'name': + case 'code': + return '' === value + + case 'tags': + return Array.isArray(value) && 0 === value.length + + case 'scope': + return 'global' === value + + case 'condition_id': + return 0 === value + + case 'active': + case 'locked': + case 'trashed': + return false === value + + case 'priority': + return DEFAULT_PRIORITY === value + + case 'network': + case 'shared_network': + case 'code_error': + case 'code_error_trace': + return null === value || false === value + + default: + return undefined === value || null === value + } +} + +const buildExportSnippet = ({ + name, + desc, + code, + tags, + scope, + active, + locked, + trashed, + network, + shared_network, + priority, + conditionId, + code_error, + code_error_trace +}: Snippet): SnippetsExport['snippets'][number] => { + const exportSnippet: SnippetsExport['snippets'][number] = Object.fromEntries( + Object.entries({ + name, + desc, + code, + tags, + scope, + active, + locked, + trashed, + network, + shared_network, + priority, + condition_id: conditionId, + code_error, + code_error_trace + }).filter(([field, value]) => !isDefaultExportValue(field, value)) + ) + + return exportSnippet +} + +export const downloadBulkSnippetExportFile = (snippets: readonly Snippet[]) => { + if (0 === snippets.length) { + return + } + + const content: SnippetsExport = { + generator: EXPORT_GENERATOR, + date_created: new Date().toISOString().slice(0, EXPORT_DATE_LENGTH).replace('T', ' '), + snippets: snippets.map(buildExportSnippet) + } + + downloadAsFile( + JSON.stringify(content, undefined, JSON_INDENT_SPACES), + `${EXPORT_FILENAME}.code-snippets.json`, + 'application/json' + ) +} diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index d5eb1a8a..55c6110b 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -132,4 +132,35 @@ 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) + }) }) From 0aec73fb8ae8aaabeed1e78cb234e2cd75347538 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 12:38:24 +0200 Subject: [PATCH 05/80] fix: correct admin bar edit links --- src/php/Integration/Admin_Bar.php | 4 ++-- tests/phpunit/test-admin-bar.php | 24 ++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) 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/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. * From 5501b7c8364c220dace7fadf7362593e167986bc Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 12:39:58 +0200 Subject: [PATCH 06/80] fix: remove the edit snippet submenu --- src/php/Admin/Menus/Edit_Menu.php | 7 +--- tests/phpunit/test-edit-menu.php | 64 +++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/phpunit/test-edit-menu.php diff --git a/src/php/Admin/Menus/Edit_Menu.php b/src/php/Admin/Menus/Edit_Menu.php index 38224c1f..c1aa8973 100644 --- a/src/php/Admin/Menus/Edit_Menu.php +++ b/src/php/Admin/Menus/Edit_Menu.php @@ -58,12 +58,7 @@ public function __construct() { */ public function register() { parent::register(); - - // Only preserve the edit menu if we are currently editing a snippet. - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - if ( ! isset( $_REQUEST['page'] ) || $_REQUEST['page'] !== $this->slug ) { - remove_submenu_page( $this->base_slug, $this->slug ); - } + remove_submenu_page( $this->base_slug, $this->slug ); // Create New Snippet menu. $this->add_menu( diff --git a/tests/phpunit/test-edit-menu.php b/tests/phpunit/test-edit-menu.php new file mode 100644 index 00000000..9cabda92 --- /dev/null +++ b/tests/phpunit/test-edit-menu.php @@ -0,0 +1,64 @@ +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 ); + unset( $GLOBALS['submenu'][ code_snippets()->get_menu_slug() ] ); + } + + /** + * The edit submenu item is removed after registration. + * + * @return void + */ + public function test_register_hides_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->assertNotContains( code_snippets()->get_menu_slug( 'edit' ), $submenu_slugs ); + $this->assertContains( code_snippets()->get_menu_slug( 'add' ), $submenu_slugs ); + } +} From 09282f73832ddaa93b1a3b0820be5e1db3f4a8d5 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 12:41:36 +0200 Subject: [PATCH 07/80] fix: avoid flat files option hook fatals --- src/php/Flat_Files/Snippet_Files.php | 4 +- tests/phpunit/test-flat-files-hooks.php | 79 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/php/Flat_Files/Snippet_Files.php b/src/php/Flat_Files/Snippet_Files.php index 050e1588..f801af2b 100644 --- a/src/php/Flat_Files/Snippet_Files.php +++ b/src/php/Flat_Files/Snippet_Files.php @@ -363,7 +363,7 @@ public function sync_active_shared_network_snippets( string $option, $old_value, * @return void */ public function sync_active_shared_network_snippets_add( $option, $value ): void { - if ( 'active_shared_network_snippets' !== $option ) { + if ( 'active_shared_network_snippets' === $option ) { $this->create_active_shared_network_snippets_file( $value ); } } @@ -400,7 +400,7 @@ 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 ); + return function_exists( 'wp_hash' ) ? wp_hash( $table ) : md5( $table ); } /** diff --git a/tests/phpunit/test-flat-files-hooks.php b/tests/phpunit/test-flat-files-hooks.php index abc35b09..27790894 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,22 @@ * @group flat-files */ class Flat_Files_Hooks_Test extends TestCase { + private function build_snippet_files( Filesystem_Adapter $fs ): Snippet_Files { + $config_repo = new class() implements Snippet_Config_Repository { + public function load( string $base_dir ): array { + return []; + } + + public function save( string $base_dir, array $active_snippets ): void { + } + + public function update( string $base_dir, Snippet $snippet, ?bool $remove = false ): void { + } + }; + + return new Snippet_Files( new Handler_Registry( [] ), $fs, $config_repo ); + } + public function test_update_snippet_fields_triggers_update_action_with_snippet_object() { $snippet = new Snippet( [ @@ -41,4 +61,63 @@ public function test_update_snippet_fields_triggers_update_action_with_snippet_o $this->assertInstanceOf( Snippet::class, $observed ); $this->assertSame( $saved->id, $observed->id ); } + + public function test_add_option_sync_only_runs_for_active_shared_network_snippets(): void { + $writes = []; + $fs = new class( $writes ) implements Filesystem_Adapter { + private array $writes; + + public function __construct( array &$writes ) { + $this->writes = &$writes; + } + + public function put_contents( string $path, string $contents, $chmod ): bool { + $this->writes[] = $path; + return true; + } + + public function exists( string $path ): bool { + return false; + } + + public function delete( string $file, bool $recursive = false, $type = false ): bool { + return true; + } + + public function is_dir( string $path ): bool { + return true; + } + + public function mkdir( string $path, $chmod ): bool { + return true; + } + + public function rmdir( string $path, bool $recursive = false ): bool { + return true; + } + + public function chmod( string $path, $chmod ): bool { + return true; + } + + 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 ); + } + + 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 ) ); + } } From 98db56c6f8ea6b2d7f21d04b7be8d85d32d4a8ca Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 12:51:48 +0200 Subject: [PATCH 08/80] fix: focus editor from edit menu item --- .../SnippetForm/fields/CodeEditor.tsx | 24 ++++++++++++++ src/php/Admin/Menus/Edit_Menu.php | 31 ++++++++++++++++++- tests/phpunit/test-edit-menu.php | 24 ++++++++++++-- 3 files changed, 75 insertions(+), 4 deletions(-) diff --git a/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx index 566a13b8..f9ec2dce 100644 --- a/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx +++ b/src/js/components/EditMenu/SnippetForm/fields/CodeEditor.tsx @@ -14,6 +14,28 @@ interface EditorTextareaProps { textareaRef: RefObject } +const useFocusEditorShortcut = ( + codeEditorInstance: ReturnType['codeEditorInstance'], + textareaRef: RefObject +) => { + 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 +99,8 @@ export const CodeEditor: React.FC = ({ isExpanded, setIsExpande } }, [submitSnippet, codeEditorInstance, snippet]) + useFocusEditorShortcut(codeEditorInstance, textareaRef) + return (

diff --git a/src/php/Admin/Menus/Edit_Menu.php b/src/php/Admin/Menus/Edit_Menu.php index c1aa8973..eb7f83d6 100644 --- a/src/php/Admin/Menus/Edit_Menu.php +++ b/src/php/Admin/Menus/Edit_Menu.php @@ -48,6 +48,9 @@ public function __construct() { __( 'Edit Snippet', 'code-snippets' ) ); + add_action( 'admin_print_footer_scripts', array( $this, 'disable_menu_link' ) ); + add_action( 'network_admin_print_footer_scripts', array( $this, 'disable_menu_link' ) ); + $this->remove_debug_bar_codemirror(); } @@ -58,7 +61,6 @@ public function __construct() { */ public function register() { parent::register(); - remove_submenu_page( $this->base_slug, $this->slug ); // Create New Snippet menu. $this->add_menu( @@ -115,6 +117,33 @@ public function render() { echo '
'; } + /** + * Prevent the static Edit Snippet menu item from navigating away from the current editor state. + * + * @return void + */ + public function disable_menu_link() { + ?> + + register(); $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( 'edit' ), $submenu_slugs ); $this->assertContains( code_snippets()->get_menu_slug( 'add' ), $submenu_slugs ); } + + /** + * The admin footer script disables the static Edit Snippet menu link. + * + * @return void + */ + public function test_disable_menu_link_outputs_inline_script(): void { + $menu = new Edit_Menu(); + + ob_start(); + $menu->disable_menu_link(); + $output = ob_get_clean(); + + $this->assertStringContainsString( 'aria-disabled', $output ); + $this->assertStringContainsString( code_snippets()->get_menu_slug( 'edit' ), $output ); + $this->assertStringContainsString( "removeAttribute( 'href' )", $output ); + $this->assertStringContainsString( 'code_snippets_focus_editor', $output ); + } } From df40f2854d9a68642c863ec977363bca2735db6a Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 12:55:23 +0200 Subject: [PATCH 09/80] fix: restore pointer cursor on edit menu item --- src/php/Admin/Menus/Edit_Menu.php | 1 + tests/phpunit/test-edit-menu.php | 1 + 2 files changed, 2 insertions(+) diff --git a/src/php/Admin/Menus/Edit_Menu.php b/src/php/Admin/Menus/Edit_Menu.php index eb7f83d6..0bdd224a 100644 --- a/src/php/Admin/Menus/Edit_Menu.php +++ b/src/php/Admin/Menus/Edit_Menu.php @@ -134,6 +134,7 @@ public function disable_menu_link() { menuLink.dataset.codeSnippetsDisabled = 'true'; menuLink.setAttribute( 'aria-disabled', 'true' ); + menuLink.style.cursor = 'pointer'; menuLink.removeAttribute( 'href' ); menuLink.addEventListener( 'click', ( event ) => { event.preventDefault(); diff --git a/tests/phpunit/test-edit-menu.php b/tests/phpunit/test-edit-menu.php index dc7dc255..95629548 100644 --- a/tests/phpunit/test-edit-menu.php +++ b/tests/phpunit/test-edit-menu.php @@ -76,6 +76,7 @@ public function test_disable_menu_link_outputs_inline_script(): void { $this->assertStringContainsString( 'aria-disabled', $output ); $this->assertStringContainsString( code_snippets()->get_menu_slug( 'edit' ), $output ); + $this->assertStringContainsString( "style.cursor = 'pointer'", $output ); $this->assertStringContainsString( "removeAttribute( 'href' )", $output ); $this->assertStringContainsString( 'code_snippets_focus_editor', $output ); } From 044f710df1be88d80ca68c2609f4c7014ea787bb Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 13:01:11 +0200 Subject: [PATCH 10/80] fix: move stack trace notices above snippet form --- .../EditMenu/EditorSidebar/EditorSidebar.tsx | 2 +- .../components/EditMenu/SnippetForm/SnippetForm.tsx | 2 ++ .../EditMenu/SnippetForm/page/Notices.tsx | 13 ++++++++++--- tests/e2e/code-snippets-edit.spec.ts | 2 +- 4 files changed, 14 insertions(+), 5 deletions(-) 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..d1055a23 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' @@ -158,6 +159,7 @@ const EditFormWrap: React.FC = () => {

+
diff --git a/src/js/components/EditMenu/SnippetForm/page/Notices.tsx b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx index 41ae6994..ef82a36b 100644 --- a/src/js/components/EditMenu/SnippetForm/page/Notices.tsx +++ b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx @@ -6,12 +6,19 @@ import { DismissibleNotice } from '../../../common/Notice' const DESCRIPTION_INDEX = 2 const DETAILS_INDEX = 3 +const hasStackTrace = (notice?: readonly unknown[]): boolean => Boolean(notice?.[DETAILS_INDEX]) -export const Notices: React.FC = () => { +interface NoticesProps { + placement: 'above-form' | 'sidebar' +} + +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 return <> - {currentNotice + {showCurrentNotice && currentNotice ? setCurrentNotice(undefined)}>

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

{currentNotice[DESCRIPTION_INDEX] @@ -26,7 +33,7 @@ export const Notices: React.FC = () => {
: null} - {!currentNotice && snippet.code_error + {showCodeErrorNotice && !currentNotice && snippet.code_error ? setSnippet(previous => ({ ...previous, code_error: null, code_error_trace: null }))} diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index 64512bbe..e5d0cfba 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -73,7 +73,7 @@ test.describe('Code Snippets Admin', () => { await helper.saveSnippet('save_and_activate') - const errorNotice = page.locator('.snippet-editor-sidebar .notice.error').first() + 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()') From f346bf4556f8c48a743e34fc4a84aff3794ce290 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 14:31:23 +0200 Subject: [PATCH 11/80] fix: polish traced activation notices --- src/css/edit.scss | 10 +++++++++- .../components/EditMenu/SnippetForm/page/Notices.tsx | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/css/edit.scss b/src/css/edit.scss index e47bfd31..4291c506 100644 --- a/src/css/edit.scss +++ b/src/css/edit.scss @@ -105,4 +105,12 @@ form.condition-snippet .snippet-code-container { color: #2271b1; margin-inline-end: 3px; } -} \ No newline at end of file +} + +.code-snippets-notice { + details pre { + max-inline-size: 100%; + overflow: auto hidden; + white-space: pre; + } +} diff --git a/src/js/components/EditMenu/SnippetForm/page/Notices.tsx b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx index ef82a36b..cf197dcd 100644 --- a/src/js/components/EditMenu/SnippetForm/page/Notices.tsx +++ b/src/js/components/EditMenu/SnippetForm/page/Notices.tsx @@ -15,11 +15,11 @@ interface NoticesProps { 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 + const showCodeErrorNotice = 'sidebar' === placement && !snippet.code_error_trace return <> {showCurrentNotice && currentNotice - ? setCurrentNotice(undefined)}> + ? setCurrentNotice(undefined)}>

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

{currentNotice[DESCRIPTION_INDEX] ?

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

From a407445e39f599ee47deee48322edc6c7fcbdca4 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 14:42:31 +0200 Subject: [PATCH 12/80] chore: centralize notice styling --- src/css/common/_notices.scss | 11 +++++++++++ src/css/edit.scss | 9 +-------- src/css/manage.scss | 5 +---- 3 files changed, 13 insertions(+), 12 deletions(-) create mode 100644 src/css/common/_notices.scss diff --git a/src/css/common/_notices.scss b/src/css/common/_notices.scss new file mode 100644 index 00000000..abe27764 --- /dev/null +++ b/src/css/common/_notices.scss @@ -0,0 +1,11 @@ +.code-snippets-notice { + a.notice-dismiss { + text-decoration: none; + } + + details pre { + max-inline-size: 100%; + overflow: auto hidden; + white-space: pre; + } +} diff --git a/src/css/edit.scss b/src/css/edit.scss index 4291c506..37ce7f01 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'; @@ -106,11 +107,3 @@ form.condition-snippet .snippet-code-container { margin-inline-end: 3px; } } - -.code-snippets-notice { - details pre { - max-inline-size: 100%; - overflow: auto hidden; - white-space: pre; - } -} 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; From 7d3ec1026e7c20a06caab60209fdec6bb3d21cfd Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:14:45 +0200 Subject: [PATCH 13/80] fix: update notice styling and add horizontal scroll hint --- src/css/common/_notices.scss | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/css/common/_notices.scss b/src/css/common/_notices.scss index abe27764..eebd7304 100644 --- a/src/css/common/_notices.scss +++ b/src/css/common/_notices.scss @@ -1,11 +1,29 @@ .code-snippets-notice { - a.notice-dismiss { - text-decoration: none; + .notice-dismiss { + position: absolute; + transform: initial; + inset-inline-end: 0; + inset-block-start: 0; } - details pre { - max-inline-size: 100%; - overflow: auto hidden; - white-space: pre; + details[open]::after{ + content: "Scroll horizontally if the trace is cut off"; + font-style: italic; + opacity: 0.5; + } + + details { + margin: .5em 0; + padding: 2px; + + summary { + cursor: pointer; + } + + pre { + max-inline-size: 100%; + overflow: auto hidden; + white-space: pre; + } } } From 556081485a985463f706b5104e4c5f16afd949fa Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:15:45 +0200 Subject: [PATCH 14/80] fix: emphasize activation failure message --- src/js/hooks/useSubmitSnippet.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/js/hooks/useSubmitSnippet.ts b/src/js/hooks/useSubmitSnippet.ts index f3686ea9..e9cad166 100644 --- a/src/js/hooks/useSubmitSnippet.ts +++ b/src/js/hooks/useSubmitSnippet.ts @@ -126,7 +126,7 @@ export const useSubmitSnippet = (): UseSubmitSnippet => { setCurrentNotice([ 'error', // translators: %s: single-line PHP error message. - sprintf(__('Snippet could not be activated: %s', 'code-snippets'), result.code_error[0]), + sprintf(__('Snippet could not be activated: %s', 'code-snippets'), result.code_error[0]), __('The snippet was saved, but remains inactive.', 'code-snippets'), result.code_error_trace ?? undefined ]) From ae1f96a3f1890ffd1b6a1b7fa4bfbc2bb59b6651 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:18:40 +0200 Subject: [PATCH 15/80] fix: remove editor back link --- src/css/edit.scss | 10 ---------- src/js/components/EditMenu/SnippetForm/SnippetForm.tsx | 10 ---------- 2 files changed, 20 deletions(-) diff --git a/src/css/edit.scss b/src/css/edit.scss index 37ce7f01..336a3e71 100644 --- a/src/css/edit.scss +++ b/src/css/edit.scss @@ -97,13 +97,3 @@ form.condition-snippet .snippet-code-container { display: none; } - -.cs-back { - cursor: pointer; - - &::before { - content: '<'; - color: #2271b1; - margin-inline-end: 3px; - } -} diff --git a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx index d1055a23..e9c702cc 100644 --- a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx +++ b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx @@ -148,16 +148,6 @@ const EditFormWrap: React.FC = () => { return (
-

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

- From 09e69d13096e9fd305c687be0f8a35d76b52c9be Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:31:24 +0200 Subject: [PATCH 16/80] feat: add snippet table column screen options --- src/css/manage/_snippets-table.scss | 5 ++ .../SnippetsTable/SnippetsListTable.tsx | 55 ++++++++++--- .../ManageMenu/SnippetsTable/TableColumns.tsx | 15 +++- .../common/ListTable/TableItems.tsx | 4 +- src/js/types/Window.ts | 1 + src/php/Admin/Menus/Manage_Menu.php | 41 ++++++++++ tests/phpunit/test-manage-menu.php | 82 +++++++++++++++++++ 7 files changed, 189 insertions(+), 14 deletions(-) create mode 100644 tests/phpunit/test-manage-menu.php diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss index c071b821..72a3b4ca 100644 --- a/src/css/manage/_snippets-table.scss +++ b/src/css/manage/_snippets-table.scss @@ -170,6 +170,11 @@ overflow: hidden; text-overflow: ellipsis; } + + td.column-date .modified-column-content { + display: block; + text-align: end; + } } .wp-core-ui .button.clear-filters { diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx index 39c32a84..5e24eeea 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx @@ -1,5 +1,5 @@ import { __, _x, sprintf } from '@wordpress/i18n' -import React, { Fragment, useEffect, useMemo } from 'react' +import React, { Fragment, useEffect, useMemo, useState } from 'react' import { createInterpolateElement } from '@wordpress/element' import { useRestAPI } from '../../../hooks/useRestAPI' import { useSnippetsList } from '../../../hooks/useSnippetsList' @@ -12,7 +12,7 @@ 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' @@ -86,6 +86,34 @@ interface ExtraTableNavProps { visibleSnippets: Snippet[] } +const useHiddenColumns = (): string[] => { + const [hiddenColumns, setHiddenColumns] = useState(() => window.CODE_SNIPPETS_MANAGE?.hiddenColumns ?? []) + + 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) + ) + } + + updateHiddenColumns() + screenOptions.addEventListener('change', updateHiddenColumns) + + return () => { + screenOptions.removeEventListener('change', updateHiddenColumns) + } + }, []) + + return hiddenColumns +} + const FilterByTagControl: React.FC = ({ visibleSnippets }) => { const { currentTag, setCurrentTag } = useSnippetsFilters() @@ -146,14 +174,8 @@ const NoItemsMessage = () => { } -export const SnippetsListTable: React.FC = () => { - const { currentStatus, setCurrentStatus } = useSnippetsFilters() - const { snippetsByStatus } = useFilteredSnippets() - - const allSnippets = useMemo(() => snippetsByStatus.get('all') ?? [], [snippetsByStatus]) - const totalItems = snippetsByStatus.get(currentStatus)?.length ?? 0 - const itemsPerPage = window.CODE_SNIPPETS_MANAGE?.snippetsPerPage - const actions: ListTableBulkAction[] = useMemo( +const useBulkActions = (allSnippets: Snippet[]): ListTableBulkAction[] => + useMemo( () => [ { name: __('Activate', 'code-snippets'), @@ -188,6 +210,17 @@ export const SnippetsListTable: React.FC = () => { [allSnippets] ) +export const SnippetsListTable: React.FC = () => { + const { currentStatus, setCurrentStatus } = useSnippetsFilters() + const { snippetsByStatus } = useFilteredSnippets() + + const hiddenColumns = useHiddenColumns() + 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)) { setCurrentStatus(INDEX_STATUS) @@ -202,7 +235,7 @@ export const SnippetsListTable: React.FC = () => { snippet.id} - columns={TableColumns} + 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..b1d44be5 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 => @@ -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/TableItems.tsx b/src/js/components/common/ListTable/TableItems.tsx index 7fd3ea00..904923f4 100644 --- a/src/js/components/common/ListTable/TableItems.tsx +++ b/src/js/components/common/ListTable/TableItems.tsx @@ -34,7 +34,9 @@ interface TableCellProps { } const TableCell = ({ item, column }: TableCellProps) => { - const className = `${column.id}-column column-${column.id}` + const className = [ `${column.id}-column`, `column-${column.id}`, column.isHidden ? 'hidden' : '' ] + .filter(Boolean) + .join(' ') return column.isHeading ? {column.render(item)} diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index a591aef9..4058ee74 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -57,6 +57,7 @@ declare global { readonly CODE_SNIPPETS_MANAGE?: { snippetsList: Snippet[] hasNetworkCap: boolean + hiddenColumns: string[] snippetsPerPage: number isSafeModeActive: boolean } diff --git a/src/php/Admin/Menus/Manage_Menu.php b/src/php/Admin/Menus/Manage_Menu.php index 3feea5b2..7310b44e 100644 --- a/src/php/Admin/Menus/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage_Menu.php @@ -162,6 +162,12 @@ public function register_compact_menu() { public function load() { parent::load(); + $screen = get_current_screen(); + + if ( $screen ) { + add_filter( "manage_{$screen->id}_columns", array( $this, 'get_screen_columns' ) ); + } + $contextual_help = new Contextual_Help( 'edit' ); $contextual_help->load(); @@ -206,6 +212,7 @@ public function enqueue_assets() { 'CODE_SNIPPETS_MANAGE', [ 'hasNetworkCap' => current_user_can( code_snippets()->get_network_cap_name() ), + 'hiddenColumns' => $this->get_hidden_manage_columns(), 'snippetsPerPage' => $this->get_snippets_per_page(), 'isSafeModeActive' => code_snippets()->evaluate_functions->is_safe_mode_active(), 'snippetsList' => array_map( @@ -242,6 +249,40 @@ public function render() { echo '
'; } + /** + * Return the columns available in Screen Options for the snippets table. + * + * @param string[] $columns Existing columns. + * + * @return string[] + */ + public function get_screen_columns( array $columns = array() ): array { + return array_merge( + $columns, + array( + '_title' => __( 'Columns', 'code-snippets' ), + 'activate' => __( 'Active', 'code-snippets' ), + 'name' => __( 'Name', 'code-snippets' ), + 'type' => __( 'Type', 'code-snippets' ), + 'desc' => __( 'Description', 'code-snippets' ), + 'tags' => __( 'Tags', 'code-snippets' ), + 'date' => __( 'Modified', 'code-snippets' ), + 'priority' => __( 'Priority', 'code-snippets' ), + ) + ); + } + + /** + * Get the list of columns hidden for the current user on the snippets screen. + * + * @return string[] + */ + protected function get_hidden_manage_columns(): array { + $screen = get_current_screen(); + + return $screen ? get_hidden_columns( $screen ) : array(); + } + /** * Handles saving the user's snippets per page preference * diff --git a/tests/phpunit/test-manage-menu.php b/tests/phpunit/test-manage-menu.php new file mode 100644 index 00000000..bb1f301e --- /dev/null +++ b/tests/phpunit/test-manage-menu.php @@ -0,0 +1,82 @@ +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() ); + } + + /** + * 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' ) ); + + $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 ); + } +} From 746ecea2bc57f4e58f908cde205a3616b3304915 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:34:46 +0200 Subject: [PATCH 17/80] fix: left align modified column content --- src/css/manage/_snippets-table.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss index 72a3b4ca..1e25b50e 100644 --- a/src/css/manage/_snippets-table.scss +++ b/src/css/manage/_snippets-table.scss @@ -173,7 +173,7 @@ td.column-date .modified-column-content { display: block; - text-align: end; + text-align: start; } } From c25dde96159b9c028e366f924f34d8cee531d327 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:41:15 +0200 Subject: [PATCH 18/80] fix: match wordpress sorted header classes --- .../components/common/ListTable/TableHeadings.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/js/components/common/ListTable/TableHeadings.tsx b/src/js/components/common/ListTable/TableHeadings.tsx index 55cbf909..71046c03 100644 --- a/src/js/components/common/ListTable/TableHeadings.tsx +++ b/src/js/components/common/ListTable/TableHeadings.tsx @@ -23,16 +23,22 @@ const SortableHeading = ({ }: SortableHeadingProps) => { const isCurrent = column.id === sortColumn?.id - const newSortDirection = isCurrent + const nextSortDirection = isCurrent ? 'asc' === sortDirection ? 'desc' : 'asc' : column.defaultSortDirection ?? 'asc' + const classDirection = isCurrent ? sortDirection : 'asc' === nextSortDirection ? 'desc' : 'asc' + const ariaSort = isCurrent ? 'asc' === sortDirection ? 'ascending' : 'descending' : undefined return ( - + { event.preventDefault() setSortColumn(column) - setSortDirection(newSortDirection) + setSortDirection(nextSortDirection) }}> {column.title} @@ -42,7 +48,7 @@ const SortableHeading = ({ {isCurrent ? null : {/* translators: Hidden accessibility text. */} - {'asc' === newSortDirection ? __('Sort ascending.', 'code-snippets') : __('Sort descending.', 'code-snippets')} + {'asc' === nextSortDirection ? __('Sort ascending.', 'code-snippets') : __('Sort descending.', 'code-snippets')} } From a57c81d11ed34f455f78ce81021b36aba1f62d08 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:48:44 +0200 Subject: [PATCH 19/80] feat: add snippets table truncation screen option --- src/css/manage/_snippets-table.scss | 16 ++++ .../SnippetsTable/SnippetsListTable.tsx | 15 +++- .../ManageMenu/SnippetsTable/TableColumns.tsx | 2 +- src/js/types/Window.ts | 1 + src/php/Admin/Menus/Manage_Menu.php | 83 ++++++++++++++++++- tests/phpunit/test-manage-menu.php | 42 ++++++++++ 6 files changed, 154 insertions(+), 5 deletions(-) diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss index 1e25b50e..18606633 100644 --- a/src/css/manage/_snippets-table.scss +++ b/src/css/manage/_snippets-table.scss @@ -177,6 +177,22 @@ } } +.wp-list-table.truncate-row-values { + table-layout: fixed; + + td.column-desc { + overflow: hidden; + } + + #wpbody-content td.column-name > .snippet-name, + td.column-desc .snippet-description-content { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + .wp-core-ui .button.clear-filters { vertical-align: baseline; } diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx index 5e24eeea..ee11cd13 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsListTable.tsx @@ -1,5 +1,6 @@ import { __, _x, sprintf } from '@wordpress/i18n' 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' @@ -86,8 +87,11 @@ interface ExtraTableNavProps { visibleSnippets: Snippet[] } -const useHiddenColumns = (): string[] => { +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') @@ -101,6 +105,10 @@ const useHiddenColumns = (): string[] => { Array.from(screenOptions.querySelectorAll('.hide-column-tog:not(:checked)')) .map(toggle => toggle.value) ) + + setTruncateRowValues( + screenOptions.querySelector('#snippets-table-truncate-row-values')?.checked ?? true + ) } updateHiddenColumns() @@ -111,7 +119,7 @@ const useHiddenColumns = (): string[] => { } }, []) - return hiddenColumns + return { hiddenColumns, truncateRowValues } } const FilterByTagControl: React.FC = ({ visibleSnippets }) => { @@ -214,7 +222,7 @@ export const SnippetsListTable: React.FC = () => { const { currentStatus, setCurrentStatus } = useSnippetsFilters() const { snippetsByStatus } = useFilteredSnippets() - const hiddenColumns = useHiddenColumns() + 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 @@ -235,6 +243,7 @@ export const SnippetsListTable: React.FC = () => { snippet.id} + className={classnames({ 'truncate-row-values': truncateRowValues })} columns={columns} actions={actions} totalPages={itemsPerPage && Math.ceil(totalItems / itemsPerPage)} diff --git a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx index b1d44be5..ff4335ac 100644 --- a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx @@ -277,7 +277,7 @@ const baseTableColumns: ListTableColumn[] = [ { id: 'desc', title: __('Description', 'code-snippets'), - render: snippet => {snippet.desc} + render: snippet =>
{snippet.desc}
}, { id: 'tags', diff --git a/src/js/types/Window.ts b/src/js/types/Window.ts index 4058ee74..0b4b14ef 100644 --- a/src/js/types/Window.ts +++ b/src/js/types/Window.ts @@ -58,6 +58,7 @@ declare global { snippetsList: Snippet[] hasNetworkCap: boolean hiddenColumns: string[] + truncateRowValues: number | string snippetsPerPage: number isSafeModeActive: boolean } diff --git a/src/php/Admin/Menus/Manage_Menu.php b/src/php/Admin/Menus/Manage_Menu.php index 7310b44e..7abc8fda 100644 --- a/src/php/Admin/Menus/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage_Menu.php @@ -44,6 +44,7 @@ public function __construct() { } add_action( 'admin_menu', array( $this, 'register_upgrade_menu' ), 500 ); + add_action( 'admin_init', array( $this, 'save_truncation_preference' ) ); add_filter( 'set-screen-option', array( $this, 'save_screen_option' ), 10, 3 ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_menu_css' ] ); add_action( 'wp_ajax_update_code_snippet', array( $this, 'ajax_callback' ) ); @@ -166,6 +167,7 @@ public function load() { if ( $screen ) { add_filter( "manage_{$screen->id}_columns", array( $this, 'get_screen_columns' ) ); + add_filter( 'screen_settings', array( $this, 'render_screen_settings' ), 10, 2 ); } $contextual_help = new Contextual_Help( 'edit' ); @@ -212,7 +214,8 @@ public function enqueue_assets() { 'CODE_SNIPPETS_MANAGE', [ 'hasNetworkCap' => current_user_can( code_snippets()->get_network_cap_name() ), - 'hiddenColumns' => $this->get_hidden_manage_columns(), + 'hiddenColumns' => $this->get_hidden_manage_columns(), + 'truncateRowValues' => (int) $this->truncate_row_values(), 'snippetsPerPage' => $this->get_snippets_per_page(), 'isSafeModeActive' => code_snippets()->evaluate_functions->is_safe_mode_active(), 'snippetsList' => array_map( @@ -283,6 +286,84 @@ protected function get_hidden_manage_columns(): array { return $screen ? get_hidden_columns( $screen ) : array(); } + /** + * Whether to truncate long row values in the snippets table. + * + * @return bool + */ + protected function truncate_row_values(): bool { + $setting = get_user_option( 'snippets_table_truncate_row_values' ); + + return false === $setting ? true : (bool) $setting; + } + + /** + * Render extra Screen Options controls for the snippets table. + * + * @param string $screen_settings Existing screen settings HTML. + * @param \WP_Screen $screen Current screen object. + * + * @return string + */ + public function render_screen_settings( string $screen_settings, \WP_Screen $screen ): string { + ob_start(); + ?> +
+ +
+ +

+ +

+
+
+ get_menu_slug() !== $page || ! current_user_can( code_snippets()->get_cap() ) ) { + 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 * diff --git a/tests/phpunit/test-manage-menu.php b/tests/phpunit/test-manage-menu.php index bb1f301e..001d9224 100644 --- a/tests/phpunit/test-manage-menu.php +++ b/tests/phpunit/test-manage-menu.php @@ -44,6 +44,8 @@ public function set_up() { wp_set_current_user( self::$admin_user_id ); set_current_screen( 'toplevel_page_' . code_snippets()->get_menu_slug() ); + delete_user_option( self::$admin_user_id, 'snippets_table_truncate_row_values' ); + unset( $_POST['wp_screen_options'], $_POST['screenoptionnonce'], $_POST['snippets_table_truncate_row_values'], $_REQUEST['page'] ); } /** @@ -70,6 +72,7 @@ public function test_load_registers_screen_option_columns(): 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(); @@ -78,5 +81,44 @@ public function test_enqueue_assets_localizes_hidden_columns(): void { $this->assertIsString( $data ); $this->assertStringContainsString( '"hiddenColumns":["desc","date"]', $data ); + $this->assertStringContainsString( '"truncateRowValues":"0"', $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 row values', $output ); + } + + /** + * 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 ) ); } } From fb7d64cc52bd203e3a2a1324055f645e3ce8f25c Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:52:11 +0200 Subject: [PATCH 20/80] fix: limit snippets table truncation to content columns --- src/css/manage/_cloud-community.scss | 36 ++++++++++++++-------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/css/manage/_cloud-community.scss b/src/css/manage/_cloud-community.scss index a702a5c2..4ff8b0cb 100644 --- a/src/css/manage/_cloud-community.scss +++ b/src/css/manage/_cloud-community.scss @@ -7,6 +7,24 @@ .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 { @@ -130,21 +148,3 @@ } } } - -.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; - } -} From 9e35d193f8c958ca40e1371974b3623541bf9f26 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:52:47 +0200 Subject: [PATCH 21/80] fix: scope snippets table truncation to name and description --- src/css/manage/_snippets-table.scss | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss index 18606633..74010fd2 100644 --- a/src/css/manage/_snippets-table.scss +++ b/src/css/manage/_snippets-table.scss @@ -178,12 +178,6 @@ } .wp-list-table.truncate-row-values { - table-layout: fixed; - - td.column-desc { - overflow: hidden; - } - #wpbody-content td.column-name > .snippet-name, td.column-desc .snippet-description-content { display: block; @@ -191,6 +185,14 @@ text-overflow: ellipsis; white-space: nowrap; } + + #wpbody-content td.column-name > .snippet-name { + max-inline-size: min(24rem, 30vw); + } + + td.column-desc .snippet-description-content { + max-inline-size: min(36rem, 45vw); + } } .wp-core-ui .button.clear-filters { From 9b67988a808dc0d23ae8d41d47e36df6d599afdc Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 15:59:46 +0200 Subject: [PATCH 22/80] fix: correct snippets table name truncation --- src/css/manage/_snippets-table.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss index 74010fd2..5243132a 100644 --- a/src/css/manage/_snippets-table.scss +++ b/src/css/manage/_snippets-table.scss @@ -178,7 +178,7 @@ } .wp-list-table.truncate-row-values { - #wpbody-content td.column-name > .snippet-name, + td.column-name > .snippet-name, td.column-desc .snippet-description-content { display: block; overflow: hidden; @@ -186,7 +186,7 @@ white-space: nowrap; } - #wpbody-content td.column-name > .snippet-name { + td.column-name > .snippet-name { max-inline-size: min(24rem, 30vw); } From e57ed0eff6c7ed1d86d47d985c082525cc57a982 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 16:27:02 +0200 Subject: [PATCH 23/80] fix: refactor cloud search form styles and remove redundant code --- src/css/manage/_cloud-community.scss | 100 +++++++++++++-------------- 1 file changed, 50 insertions(+), 50 deletions(-) diff --git a/src/css/manage/_cloud-community.scss b/src/css/manage/_cloud-community.scss index 4ff8b0cb..09e6e4b3 100644 --- a/src/css/manage/_cloud-community.scss +++ b/src/css/manage/_cloud-community.scss @@ -1,6 +1,37 @@ @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; @@ -8,23 +39,23 @@ 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; - } - } + .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 { @@ -97,10 +128,6 @@ padding-block: 12px; align-items: center; - .components-spinner { - margin: 0; - } - .dashicons-warning { color: #b32d2e; } @@ -118,33 +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%; - } - } +.cloud-search-results .cloud-search-result footer > .components-spinner { + margin: 0; } From 78b870ed5df926c0ec0e35a018575a16eb7fe69a Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 16:27:09 +0200 Subject: [PATCH 24/80] fix: adjust max-inline-size for snippet name and description columns --- src/css/manage/_snippets-table.scss | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss index 5243132a..04e75969 100644 --- a/src/css/manage/_snippets-table.scss +++ b/src/css/manage/_snippets-table.scss @@ -166,7 +166,6 @@ min-inline-size: 130px; max-inline-size: 130px; text-align: end; - padding-inline: 8px; overflow: hidden; text-overflow: ellipsis; } @@ -183,15 +182,14 @@ display: block; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; } td.column-name > .snippet-name { - max-inline-size: min(24rem, 30vw); + max-inline-size: min(15rem, 30vw); } td.column-desc .snippet-description-content { - max-inline-size: min(36rem, 45vw); + max-inline-size: min(25rem, 45vw); } } From bd0515050d6985223f19eb036dbb606d56bd1d6d Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 10 Mar 2026 16:29:00 +0200 Subject: [PATCH 25/80] fix: remove description for truncation option in manage menu --- src/php/Admin/Menus/Manage_Menu.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/php/Admin/Menus/Manage_Menu.php b/src/php/Admin/Menus/Manage_Menu.php index 7abc8fda..087bd690 100644 --- a/src/php/Admin/Menus/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage_Menu.php @@ -309,7 +309,7 @@ public function render_screen_settings( string $screen_settings, \WP_Screen $scr ob_start(); ?>
- +
-

- -

Date: Tue, 10 Mar 2026 16:30:09 +0200 Subject: [PATCH 26/80] fix: update legend text case in table options fieldset --- src/php/Admin/Menus/Manage_Menu.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/php/Admin/Menus/Manage_Menu.php b/src/php/Admin/Menus/Manage_Menu.php index 087bd690..e94efbb6 100644 --- a/src/php/Admin/Menus/Manage_Menu.php +++ b/src/php/Admin/Menus/Manage_Menu.php @@ -309,7 +309,7 @@ public function render_screen_settings( string $screen_settings, \WP_Screen $scr ob_start(); ?>
- +