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();