From 14f592e457f7595e13d158e883d581605d708113 Mon Sep 17 00:00:00 2001 From: TallblokeUK Date: Sat, 29 Aug 2026 08:01:18 +0100 Subject: [PATCH] feat: group settings into task-based tabs General had grown large enough that whole features sat in the same flat list as small preferences, and the tabs people need when something has gone wrong are the ones hidden behind other settings. Regroup the settings into tabs by what someone came to the page to do: Editing, Running, Insights, Library, Interface and Advanced. A tab with nothing to show does not appear, so the free plugin shows four while Pro shows six, from one shared map. Storage is deliberately untouched. Call sites name a section when reading a setting, and the saved option is keyed by section, so moving fields between sections would break every read and orphan every existing install. Settings_Layout maps each tab to a list of [storage section, field] pairs; Setting_Field still receives the original section for its name attribute and value lookup, and only add_settings_field receives the tab. Saving from a new tab writes to the old section. Also here: - Long tabs are broken up by group headings, rendered by a heading-aware stand-in for do_settings_fields(). A heading whose fields are all absent is skipped with them. - Settings whose description named the control without saying what it was for have been reworded. - get_current_section() now falls back to the first registered tab. Naming a section as the default meant the page rendered empty the moment that section stopped existing. - The editor preview refresh is no longer keyed to a hardcoded section name. CodeMirror measures itself as zero-height while hidden, so the preview rendered blank on any tab not literally called 'editor'. Tab identifiers are repeated in the PHP layout, the SCSS $sections list and the JS. Worth consolidating if this is taken further. --- src/css/settings.scss | 18 ++- src/js/services/settings/tabs.ts | 13 +- src/php/Admin/Menus/Settings_Menu.php | 14 +- src/php/Settings/Settings_Layout.php | 219 ++++++++++++++++++++++++++ src/php/Settings/settings.php | 109 ++++++++++--- 5 files changed, 339 insertions(+), 34 deletions(-) create mode 100644 src/php/Settings/Settings_Layout.php diff --git a/src/css/settings.scss b/src/css/settings.scss index 23fa2d1a9..8832ea461 100644 --- a/src/css/settings.scss +++ b/src/css/settings.scss @@ -6,7 +6,8 @@ @use 'common/wp-admin'; @use 'common/page-header'; -$sections: general, editor, debug, version-switch; +$sections: general, editor, debug, version-switch, + editing, running, insights, library, interface, advanced; .wrap.code-snippets-settings { margin-block-start: 0; @@ -307,3 +308,18 @@ body.js { } } } + +// Group headings inside a settings tab. Tabs are long enough that unbroken +// rows are hard to scan, so related fields sit under a label. +.settings-section .form-table .settings-group-heading th { + inline-size: auto; + padding-block: 26px 8px; + border-block-end: 1px solid var(--cs-color-border-subtle); + font-size: 15px; + font-weight: 600; + color: var(--cs-color-text); +} + +.settings-section .form-table .settings-group-heading:first-child th { + padding-block-start: 4px; +} diff --git a/src/js/services/settings/tabs.ts b/src/js/services/settings/tabs.ts index e5514e151..57e2b00bd 100644 --- a/src/js/services/settings/tabs.ts +++ b/src/js/services/settings/tabs.ts @@ -19,11 +19,12 @@ const selectTab = (tabsWrapper: Element, tab: Element, section: string) => { } } -// Refresh the editor preview if we're viewing the editor section. -const refreshEditorPreview = (section: string) => { - if ('editor' === section) { - window.code_snippets_editor_preview?.codemirror.refresh() - } +// Refresh the editor preview whenever tabs change. CodeMirror measures itself +// as zero-height while its container is hidden, so it has to be told to +// remeasure once the section holding it becomes visible. Keying this to a +// specific section name meant the preview rendered blank if that name changed. +const refreshEditorPreview = () => { + window.code_snippets_editor_preview?.codemirror.refresh() } // Update the http referer value so that any redirections lead back to this tab. @@ -76,7 +77,7 @@ export const handleSettingsTabs = () => { if (section) { selectTab(tabsWrapper, tab, section) - refreshEditorPreview(section) + refreshEditorPreview() updateHttpReferer(section) } }) diff --git a/src/php/Admin/Menus/Settings_Menu.php b/src/php/Admin/Menus/Settings_Menu.php index 77bac2db5..07726811f 100644 --- a/src/php/Admin/Menus/Settings_Menu.php +++ b/src/php/Admin/Menus/Settings_Menu.php @@ -175,16 +175,22 @@ private function get_sections(): array { * * @return string */ - public function get_current_section( string $default_section = 'general' ): string { + public function get_current_section( string $default_section = '' ): string { $sections = $this->get_sections(); if ( ! $sections ) { return $default_section; } + // Fall back to the first registered tab. Naming a specific section here + // meant the page rendered empty whenever that section stopped existing. + $fallback = $default_section && isset( $sections[ $default_section ] ) + ? $default_section + : (string) array_key_first( $sections ); + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Value is matched to registered sections. - $active_tab = isset( $_REQUEST['section'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['section'] ) ) : $default_section; - return isset( $sections[ $active_tab ] ) ? $active_tab : $default_section; + $active_tab = isset( $_REQUEST['section'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['section'] ) ) : $fallback; + return isset( $sections[ $active_tab ] ) ? $active_tab : $fallback; } /** @@ -304,7 +310,7 @@ protected function render_settings_sections() { printf( '
', esc_attr( $section['id'] ) ); - do_settings_fields( self::SETTINGS_PAGE, $section['id'] ); + \Code_Snippets\Settings\do_settings_fields_with_headings( self::SETTINGS_PAGE, $section['id'] ); echo '
'; } } diff --git a/src/php/Settings/Settings_Layout.php b/src/php/Settings/Settings_Layout.php new file mode 100644 index 000000000..0b164979c --- /dev/null +++ b/src/php/Settings/Settings_Layout.php @@ -0,0 +1,219 @@ + Tab identifier keyed to its label. + */ + public static function get_tabs(): array { + $tabs = [ + 'editing' => __( 'Editing', 'code-snippets' ), + 'running' => __( 'Running', 'code-snippets' ), + 'insights' => __( 'Insights', 'code-snippets' ), + 'library' => __( 'Library', 'code-snippets' ), + 'interface' => __( 'Interface', 'code-snippets' ), + 'advanced' => __( 'Advanced', 'code-snippets' ), + ]; + + return apply_filters( 'code_snippets_settings_tabs', $tabs ); + } + + /** + * Retrieve the contents of every tab. + * + * Each entry is a `[ storage section, field identifier ]` pair. The storage + * section is the one the value is saved under and must not change; only the + * tab it appears in does. + * + * @return array> + */ + public static function get_tab_contents(): array { + $contents = [ + 'editing' => [ + [ 'general', 'enable_tags' ], + [ 'general', 'enable_description' ], + [ 'general', 'visual_editor_rows' ], + [ 'editor', 'theme' ], + [ 'editor', 'keymap' ], + [ 'editor', 'font_size' ], + [ 'editor', 'indent_with_tabs' ], + [ 'editor', 'tab_size' ], + [ 'editor', 'indent_unit' ], + [ 'editor', 'line_numbers' ], + [ 'editor', 'code_folding' ], + [ 'editor', 'wrap_lines' ], + [ 'editor', 'auto_close_brackets' ], + [ 'editor', 'highlight_active_line' ], + [ 'editor', 'highlight_selection_matches' ], + ], + 'running' => [ + [ 'general', 'enable_flat_files' ], + [ 'general', 'minify_output' ], + [ 'general', 'activate_by_default' ], + ], + 'insights' => [ + [ 'general', 'enable_performance_tracker' ], + [ 'general', 'enable_security_scan' ], + [ 'general', 'rescan_all_snippets' ], + ], + 'library' => [ + [ 'general', 'max_revisions' ], + [ 'general', 'preserve_on_delete' ], + [ 'general', 'delete_revision_default' ], + [ 'general', 'connect_cloud' ], + ], + 'interface' => [ + [ 'general', 'list_order' ], + [ 'general', 'disable_prism' ], + [ 'general', 'enable_admin_bar' ], + [ 'general', 'admin_bar_snippet_limit' ], + [ 'general', 'hide_upgrade_menu' ], + ], + 'advanced' => [ + [ 'permissions', 'role_capabilities' ], + [ 'version-switch', 'version_switcher' ], + [ 'version-switch', 'refresh_versions' ], + [ 'version-switch', 'version_warning' ], + [ 'debug', 'enable_version_change' ], + [ 'debug', 'reset_caches' ], + [ 'debug', 'database_update' ], + [ 'general', 'complete_uninstall' ], + ], + ]; + + return apply_filters( 'code_snippets_settings_tab_contents', $contents ); + } + + /** + * Retrieve the fields of a tab that exist and should be shown. + * + * @param string $tab_id Tab identifier. + * @param array>|null $settings Current setting values. + * + * @return array Storage section and field pairs. + */ + public static function get_visible_fields( string $tab_id, ?array $settings = null ): array { + $contents = self::get_tab_contents(); + + if ( empty( $contents[ $tab_id ] ) ) { + return []; + } + + $definitions = Settings_Fields::get_field_definitions(); + $settings = null === $settings ? get_settings_values() : $settings; + $visible = []; + + foreach ( $contents[ $tab_id ] as $entry ) { + list( $section_id, $field_id ) = $entry; + + // Fields belonging to the other edition simply are not there. + if ( ! isset( $definitions[ $section_id ][ $field_id ] ) ) { + continue; + } + + if ( ! should_render_setting_field( $definitions[ $section_id ][ $field_id ], $settings ) ) { + continue; + } + + $visible[] = $entry; + } + + return $visible; + } + + /** + * Retrieve the tabs that have something to show. + * + * @return array Tab identifier keyed to its label. + */ + public static function get_available_tabs(): array { + $settings = get_settings_values(); + $available = []; + + foreach ( self::get_tabs() as $tab_id => $label ) { + if ( self::get_visible_fields( $tab_id, $settings ) ) { + $available[ $tab_id ] = $label; + } + } + + return $available; + } + + /** + * Retrieve the headings that break a tab into labelled groups. + * + * Keyed by tab, then by the field the heading is drawn above. A heading + * whose field is absent is skipped along with it. + * + * @return array> + */ + public static function get_group_headings(): array { + return [ + 'editing' => [ + 'enable_tags' => __( 'Snippet fields', 'code-snippets' ), + 'theme' => __( 'Code editor', 'code-snippets' ), + ], + 'running' => [ + 'enable_flat_files' => __( 'Execution', 'code-snippets' ), + ], + 'insights' => [ + 'enable_performance_tracker' => __( 'Performance', 'code-snippets' ), + 'enable_security_scan' => __( 'Security', 'code-snippets' ), + ], + 'library' => [ + 'max_revisions' => __( 'Revisions', 'code-snippets' ), + 'connect_cloud' => __( 'Cloud', 'code-snippets' ), + ], + 'interface' => [ + 'list_order' => __( 'Snippets list', 'code-snippets' ), + 'enable_admin_bar' => __( 'Admin bar', 'code-snippets' ), + 'hide_upgrade_menu' => __( 'Notices', 'code-snippets' ), + ], + 'advanced' => [ + 'role_capabilities' => __( 'Access', 'code-snippets' ), + 'version_switcher' => __( 'Maintenance', 'code-snippets' ), + 'reset_caches' => __( 'Maintenance', 'code-snippets' ), + ], + ]; + } + + /** + * Retrieve replacement descriptions for fields whose current wording names + * the setting without saying what it is for. + * + * @return array Field identifier keyed to its description. + */ + public static function get_descriptions(): array { + return [ + 'enable_flat_files' => __( 'Run snippets from files on disk instead of the database. Faster on most sites, and it lets you keep snippets in version control.', 'code-snippets' ), + 'minify_output' => __( 'Strip whitespace from CSS and JavaScript snippets before they reach the browser, so pages load faster.', 'code-snippets' ), + 'enable_performance_tracker' => __( 'Measure how long each snippet takes to run. Timings appear on the snippets list, so you can find the slow one without guessing.', 'code-snippets' ), + 'enable_security_scan' => __( 'Check PHP snippets for risky patterns. Anything worth a second look is flagged against the snippet on the list.', 'code-snippets' ), + 'max_revisions' => __( 'Saves a copy each time a snippet changes, so you can compare versions and roll back a bad edit.', 'code-snippets' ), + 'preserve_on_delete' => __( 'Keep the revision history after a snippet is deleted, so it can still be recovered.', 'code-snippets' ), + 'enable_admin_bar' => __( 'Jump to any snippet from anywhere in the admin, and see which ones ran on the page you are looking at.', 'code-snippets' ), + 'reset_caches' => __( 'Clears stored snippet data. Worth doing before switching to an older version of the plugin.', 'code-snippets' ), + ]; + } +} diff --git a/src/php/Settings/settings.php b/src/php/Settings/settings.php index 8a9a9901a..9c688eacc 100644 --- a/src/php/Settings/settings.php +++ b/src/php/Settings/settings.php @@ -97,23 +97,69 @@ function update_setting( string $section, string $field, $new_value ): bool { return update_self_option( are_settings_unified(), OPTION_NAME, $settings ); } +/** + * Render the fields of a settings section, with group headings. + * + * Mirrors the core `do_settings_fields()`, adding a full-width heading row + * above any field carrying a `group_heading` argument. Tabs are long enough + * that unbroken rows are hard to scan. + * + * @param string $page Settings page slug. + * @param string $section Settings section identifier. + * + * @return void + */ +function do_settings_fields_with_headings( string $page, string $section ): void { + global $wp_settings_fields; + + if ( ! isset( $wp_settings_fields[ $page ][ $section ] ) ) { + return; + } + + $seen_headings = []; + + foreach ( (array) $wp_settings_fields[ $page ][ $section ] as $field ) { + $heading = $field['args']['group_heading'] ?? ''; + + // A heading is skipped if an identical one has already been drawn, so + // that a group whose first field is absent does not repeat it. + if ( $heading && ! in_array( $heading, $seen_headings, true ) ) { + $seen_headings[] = $heading; + printf( + '%s', + esc_html( $heading ) + ); + } + + $class = empty( $field['args']['class'] ) ? '' : ' class="' . esc_attr( $field['args']['class'] ) . '"'; + + echo ''; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above. + + if ( ! empty( $field['args']['label_for'] ) ) { + printf( + '', + esc_attr( $field['args']['label_for'] ), + esc_html( $field['title'] ) + ); + } else { + printf( '%s', esc_html( $field['title'] ) ); + } + + echo ''; + call_user_func( $field['callback'], $field['args'] ); + echo ''; + } +} + /** * Retrieve the settings sections * * @return array Settings sections. */ function get_settings_sections(): array { - $sections = array( - 'general' => __( 'General', 'code-snippets' ), - 'editor' => __( 'Code Editor', 'code-snippets' ), - 'debug' => __( 'Debug', 'code-snippets' ), - ); - - // Only show the Version section when the debug setting to enable version changes is enabled. - $enable_version = get_setting( 'debug', 'enable_version_change' ); - if ( $enable_version ) { - $sections['version-switch'] = __( 'Version', 'code-snippets' ); - } + // Tabs are grouped by task rather than by storage section. Values still + // save into their original sections; see Settings_Layout. + $sections = Settings_Layout::get_available_tabs(); return apply_filters( 'code_snippets_settings_sections', $sections ); } @@ -140,20 +186,37 @@ function register_plugin_settings() { add_settings_section( $section_id, $section_name, '__return_empty_string', 'code-snippets' ); } - // Register settings fields. Only register fields for sections that exist (some sections may be gated by settings). - $registered_sections = get_settings_sections(); - foreach ( Settings_Fields::get_field_definitions() as $section_id => $fields ) { - if ( ! isset( $registered_sections[ $section_id ] ) ) { - continue; - } - - foreach ( $fields as $field_id => $field ) { - if ( ! should_render_setting_field( $field, $current_settings ) ) { - continue; + // Register settings fields. The tab a field appears under comes from the + // layout, while the section it saves into stays exactly as it was. + $definitions = Settings_Fields::get_field_definitions(); + $descriptions = Settings_Layout::get_descriptions(); + $headings = Settings_Layout::get_group_headings(); + + foreach ( get_settings_sections() as $tab_id => $tab_name ) { + foreach ( Settings_Layout::get_visible_fields( $tab_id, $current_settings ) as $entry ) { + list( $section_id, $field_id ) = $entry; + $field = $definitions[ $section_id ][ $field_id ]; + + // Reword the settings whose description names the control without + // saying what it buys you. + if ( isset( $descriptions[ $field_id ] ) ) { + $field['desc'] = $descriptions[ $field_id ]; } $field_object = new Setting_Field( $section_id, $field_id, $field ); - add_settings_field( $field_id, $field['name'], [ $field_object, 'render' ], 'code-snippets', $section_id ); + + $field_args = isset( $headings[ $tab_id ][ $field_id ] ) + ? [ 'group_heading' => $headings[ $tab_id ][ $field_id ] ] + : []; + + add_settings_field( + $field_id, + $field['name'], + [ $field_object, 'render' ], + 'code-snippets', + $tab_id, + $field_args + ); } } @@ -165,7 +228,7 @@ function register_plugin_settings() { __( 'Editor Preview', 'code-snippets' ), [ $editor_preview, 'render' ], 'code-snippets', - 'editor' + 'editing' ); Version_Switch::init();