From 1b73f0652a1031022c040726eeed410e55ea0445 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Thu, 8 Oct 2026 16:39:44 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Admin-UX=20aus=20dem=20Review=20?= =?UTF-8?q?=E2=80=93=20Live-Gruppenfeedback,=20Protokollfilter,=20Divi-Hin?= =?UTF-8?q?weis,=20Rollen-R=C3=BCckfrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tools: „Ganze Gruppe“ und „Nur lesen“ wirken sofort auf die Zeilen (Option „wie Gruppe (…)“, gesperrte Selects mit Hinweis), der Server bleibt die Wahrheit; Markup trägt die nötigen data-Attribute. - Protokoll: Filter in eigenem Block über der Tabelle (Core blendet .tablenav .actions auf Telefonen aus); „Protokoll leeren“ nur bei Einträgen, rechtsbündig, mit Gesamtzahl über ChangeLog::count(). - Divi: Beschreibung unter „Prüfung starten“, Button primär solange ungetestet ohne frischen Bericht. - Zugriff: Rückfrage vor dem Speichern ohne Rolle oder ohne eigene Rolle. - Verbindung: HTTPS-Hinweis nicht doppelt neben der Statusliste. - Mobil: breitere Einstellungs-Spalte im Tools-Reiter. Co-Authored-By: Claude Fable 5.1 --- assets/css/admin.css | 56 ++++---- assets/js/admin.js | 172 +++++++++++++++++++++---- src/Admin/AccessTab.php | 63 ++++++--- src/Admin/AdminNotices.php | 16 ++- src/Admin/ConnectionTab.php | 4 +- src/Admin/DiviTab.php | 21 ++- src/Admin/LogListTable.php | 26 +--- src/Admin/LogTab.php | 38 ++++-- src/Admin/ToolsTab.php | 70 +++++++--- src/ChangeLog/ChangeLog.php | 10 ++ tests/Unit/Admin/AccessTabTest.php | 29 +++++ tests/Unit/Admin/AdminNoticesTest.php | 1 + tests/Unit/Admin/DiviTabTest.php | 19 ++- tests/Unit/Admin/LogListTableTest.php | 13 -- tests/Unit/Admin/LogTabTest.php | 28 +++- tests/Unit/Admin/ToolsTabTest.php | 42 +++++- tests/Unit/ChangeLog/ChangeLogTest.php | 4 +- 17 files changed, 462 insertions(+), 150 deletions(-) diff --git a/assets/css/admin.css b/assets/css/admin.css index 6f8eac9..2bf4ce4 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -260,16 +260,17 @@ padding-bottom: 10px; } + /* Room for "Standard (indirekt)" in the select on phones. */ .lit-mcp-tools-table .column-tool { - width: 45%; + width: 40%; } .lit-mcp-tools-table .column-kind { - width: 12%; + width: 10%; } .lit-mcp-tools-table .column-mode { - width: 43%; + width: 50%; } .lit-mcp-tools-table th, @@ -296,7 +297,36 @@ } } -/* Tab "Protokoll": a core-style list table; the change is the wide primary column. */ +/* Tab "Protokoll": filters in their own block above the table (core hides ".tablenav .actions" + on phones); a core-style list table whose change is the wide primary column. */ +.lit-mcp-log-filters { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + margin: 8px 0; +} + +.lit-mcp-log-filters select { + max-width: 14rem; +} + +.lit-mcp-date { + display: flex; + align-items: center; + gap: 4px; +} + +.lit-mcp-log-filters .lit-mcp-reset { + line-height: 2.30769231; +} + +/* "Protokoll leeren": a destructive action, kept apart from the data it removes. */ +.lit-mcp-clear-log { + margin-top: 1em; + text-align: right; +} + .lit-mcp .lit_mcp_log_entries .column-object { width: 24%; } @@ -349,24 +379,6 @@ box-shadow: inset 4px 0 0 #d63638; } -.lit-mcp .tablenav .actions select { - max-width: 14rem; -} - -.lit-mcp-date { - float: left; - display: flex; - align-items: center; - gap: 4px; - margin-right: 6px; -} - -.lit-mcp .tablenav .lit-mcp-reset { - display: inline-block; - line-height: 2.30769231; - margin-left: 8px; -} - /* Tab "Limits". */ .lit-mcp-unit { margin-left: 4px; diff --git a/assets/js/admin.js b/assets/js/admin.js index e950819..2eeb77e 100644 --- a/assets/js/admin.js +++ b/assets/js/admin.js @@ -1,37 +1,127 @@ -/* Einstellungen → LIT MCP: copy the server URL, collapsible tool groups, filter paging. */ +/* Einstellungen → LIT MCP: copy the server URL, live group feedback and collapsible tool + groups, filter paging, and a question before a role save that locks people out. */ ( function () { 'use strict'; - document.addEventListener( 'DOMContentLoaded', function () { + // "URL kopieren" – like WordPress' "Copy URL to clipboard" in the media library. + function setUpCopyButton() { var hideTimeout; - // "URL kopieren" – like WordPress' "Copy URL to clipboard" in the media library. - if ( window.ClipboardJS && document.querySelector( '.lit-mcp-copy' ) ) { - new window.ClipboardJS( '.lit-mcp-copy' ).on( 'success', function ( event ) { - var success = event.trigger.parentNode.querySelector( '.success' ); - - event.clearSelection(); - event.trigger.focus(); - if ( success ) { - clearTimeout( hideTimeout ); - success.classList.remove( 'hidden' ); - hideTimeout = setTimeout( function () { - success.classList.add( 'hidden' ); - }, 3000 ); - } - if ( window.wp && window.wp.a11y && window.litMcpAdmin ) { - window.wp.a11y.speak( window.litMcpAdmin.copied ); - } - } ); + if ( ! window.ClipboardJS || ! document.querySelector( '.lit-mcp-copy' ) ) { + return; } + new window.ClipboardJS( '.lit-mcp-copy' ).on( 'success', function ( event ) { + var success = event.trigger.parentNode.querySelector( '.success' ); - // Tool groups: WordPress' own postbox toggles, closed state saved per user. + event.clearSelection(); + event.trigger.focus(); + if ( success ) { + clearTimeout( hideTimeout ); + success.classList.remove( 'hidden' ); + hideTimeout = setTimeout( function () { + success.classList.add( 'hidden' ); + }, 3000 ); + } + if ( window.wp && window.wp.a11y && window.litMcpAdmin ) { + window.wp.a11y.speak( window.litMcpAdmin.copied ); + } + } ); + } + + // Tool groups: WordPress' own postbox toggles, closed state saved per user. + function setUpGroupToggles() { if ( window.postboxes && window.pagenow && document.querySelector( '.lit-mcp-groups' ) ) { window.postboxes.add_postbox_toggles( window.pagenow ); } + } - // Protokoll: a changed filter starts again on page 1. - document.querySelectorAll( '.lit-mcp .tablenav .actions select, .lit-mcp .tablenav .actions input' ).forEach( + /* + * Tools: a group's mode and its "nur lesen" switch show on the group's rows at once, + * the same way the page renders them after saving (the server stays the authority). + */ + function setUpLiveGroupFeedback() { + var groups = document.querySelector( '.lit-mcp-groups' ); + + if ( ! groups ) { + return; + } + groups.querySelectorAll( '.lit-mcp-group' ).forEach( function ( group ) { + var mode = group.querySelector( '.lit-mcp-group-mode' ); + var readonly = group.querySelector( '.lit-mcp-group-readonly' ); + var tools = group.querySelectorAll( '.lit-mcp-tool-mode' ); + + if ( mode ) { + mode.addEventListener( 'change', function () { + tools.forEach( function ( select ) { + labelInheritedOption( select, mode, groups.dataset.likeGroup ); + } ); + } ); + } + if ( readonly ) { + readonly.addEventListener( 'change', function () { + tools.forEach( function ( select ) { + if ( select.dataset.writes ) { + lockTool( select, readonly.checked ); + } + } ); + } ); + } + } ); + } + + // First option: "Standard (…)" without a group mode, "wie Gruppe (…)" with one. + function labelInheritedOption( select, mode, likeGroup ) { + var label; + + if ( select.dataset.required ) { + return; + } + if ( ! mode.value ) { + select.options[ 0 ].text = select.dataset.defaultLabel; + return; + } + label = mode.options[ mode.selectedIndex ].text; + if ( select.dataset.directOnly && mode.value === 'indirect' ) { + label = optionText( select, 'direct' ) || label; + } + select.options[ 0 ].text = likeGroup.replace( '%s', label ); + } + + function optionText( select, value ) { + var option = select.querySelector( 'option[value="' + value + '"]' ); + + return option ? option.text : ''; + } + + /* + * "Nur lesen": the select of a writing tool is disabled and its note shown. A disabled + * select is not submitted, so its own setting is kept in a hidden field meanwhile + * (the server does the same when it renders a locked row). + */ + function lockTool( select, locked ) { + var cell = select.parentNode; + var note = cell.querySelector( '.lit-mcp-readonly-note' ); + var keep = cell.querySelector( 'input[type="hidden"][name="' + select.name + '"]' ); + + select.disabled = locked; + if ( locked && select.value && ! keep ) { + keep = document.createElement( 'input' ); + keep.type = 'hidden'; + keep.name = select.name; + keep.value = select.value; + cell.appendChild( keep ); + } + if ( ! locked && keep ) { + keep.remove(); + } + if ( note ) { + note.hidden = ! locked; + } + } + + // Protokoll: a changed filter starts again on page 1. + function setUpFilterPaging() { + document.querySelectorAll( '.lit-mcp-log-filters select, .lit-mcp-log-filters input' ).forEach( function ( control ) { control.addEventListener( 'change', function () { var paged = control.form && control.form.querySelector( 'input[name="paged"]' ); @@ -41,5 +131,41 @@ } ); } ); + } + + // Zugriff: ask before saving a role set that leaves nobody, or the admin themselves, with access. + function setUpAccessConfirmation() { + var form = document.querySelector( '.lit-mcp-access-form' ); + + if ( ! form ) { + return; + } + form.addEventListener( 'submit', function ( event ) { + var checked = Array.prototype.map.call( + form.querySelectorAll( 'input[type="checkbox"]:checked' ), + function ( box ) { + return box.value; + } + ); + var own = form.dataset.ownRoles ? form.dataset.ownRoles.split( ' ' ) : null; + var question = ''; + + if ( checked.length === 0 ) { + question = form.dataset.confirmNone; + } else if ( own && ! own.some( function ( role ) { return checked.indexOf( role ) !== -1; } ) ) { + question = form.dataset.confirmOwn; + } + if ( question && ! window.confirm( question ) ) { + event.preventDefault(); + } + } ); + } + + document.addEventListener( 'DOMContentLoaded', function () { + setUpCopyButton(); + setUpGroupToggles(); + setUpLiveGroupFeedback(); + setUpFilterPaging(); + setUpAccessConfirmation(); } ); }() ); diff --git a/src/Admin/AccessTab.php b/src/Admin/AccessTab.php index 726e582..ead5a0b 100644 --- a/src/Admin/AccessTab.php +++ b/src/Admin/AccessTab.php @@ -68,29 +68,33 @@ final class AccessTab implements AdminTab ))); if ($roles === []) { - self::warnOnce( - 'lit_mcp_no_roles', - __('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp') - ); + self::warnOnce('lit_mcp_no_roles', self::noRolesWarning()); } elseif (self::currentUserLosesAccess($roles)) { - self::warnOnce( - 'lit_mcp_own_role', - __( - 'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.', - 'wp-lit-mcp' - ) - ); + self::warnOnce('lit_mcp_own_role', self::ownRoleWarning()); } return $roles; } + private static function noRolesWarning(): string + { + return __('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp'); + } + + private static function ownRoleWarning(): string + { + return __( + 'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.', + 'wp-lit-mcp' + ); + } + public function render(): void { $allowed = $this->access->allowedRoles(); $legend = __('Rollen mit Zugriff', 'wp-lit-mcp'); - echo '
'; + $this->openRolesForm(); settings_fields(self::GROUP); // Makes an all-unchecked submission arrive as [''] instead of dropping the field. printf('', esc_attr(Access::OPTION)); @@ -148,14 +152,41 @@ final class AccessTab implements AdminTab /** @param list $roles */ private static function currentUserLosesAccess(array $roles): bool { - // Multisite super admins pass every capability check regardless of role. + $own = self::ownRoles(); + + return $own !== null && array_intersect($own, $roles) === []; + } + + /** + * Roles of the current user, or null when they do not matter (multisite super admins + * pass every capability check regardless of role). + * + * @return list|null + */ + private static function ownRoles(): ?array + { if (is_multisite() && is_super_admin()) { - return false; + return null; } - $user = wp_get_current_user(); + return array_values(array_map('strval', (array) wp_get_current_user()->roles)); + } - return array_intersect((array) $user->roles, $roles) === []; + /** + * The form carries the two warnings as data attributes so the admin script can ask + * before a save that leaves nobody, or the admin themselves, with access. + */ + private function openRolesForm(): void + { + $ask = ' ' . __('Trotzdem speichern?', 'wp-lit-mcp'); + $own = self::ownRoles(); + printf( + '', + esc_attr(self::noRolesWarning() . $ask), + esc_attr(self::ownRoleWarning() . $ask), + $own === null ? '' : sprintf(' data-own-roles="%s"', esc_attr(implode(' ', $own))) + ); } private function renderConnections(): void diff --git a/src/Admin/AdminNotices.php b/src/Admin/AdminNotices.php index a824fd6..1463af5 100644 --- a/src/Admin/AdminNotices.php +++ b/src/Admin/AdminNotices.php @@ -21,8 +21,13 @@ final class AdminNotices add_action('admin_notices', [$this, 'render']); } - /** @return list */ - public function messages(bool $onOwnPage): array + /** + * @param bool $onOwnPage On the plugin's own settings page. + * @param bool $statusShown The tab already lists the connection status (the HTTPS hint would be said twice). + * + * @return list + */ + public function messages(bool $onOwnPage, bool $statusShown = false): array { $messages = []; if (! $this->status->oauthEnabled()) { @@ -56,7 +61,7 @@ final class AdminNotices ) . ' ' . __('Details unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp'); } - if ($onOwnPage && ! $this->status->https()) { + if ($onOwnPage && ! $statusShown && ! $this->status->https()) { $messages[] = __( 'LIT MCP: Die Website läuft ohne HTTPS; KI-Assistenten verbinden sich nur mit HTTPS-Websites.', 'wp-lit-mcp' @@ -74,7 +79,10 @@ final class AdminNotices $screen = function_exists('get_current_screen') ? get_current_screen() : null; $onOwnPage = is_object($screen) && $screen->id === 'settings_page_' . SettingsPage::SLUG; - foreach ($this->messages($onOwnPage) as $message) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch. + $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : ''; + $statusShown = $onOwnPage && ($tab === '' || $tab === ConnectionTab::SLUG); + foreach ($this->messages($onOwnPage, $statusShown) as $message) { printf('

%s

', esc_html($message)); } } diff --git a/src/Admin/ConnectionTab.php b/src/Admin/ConnectionTab.php index 83b17ca..3efe473 100644 --- a/src/Admin/ConnectionTab.php +++ b/src/Admin/ConnectionTab.php @@ -8,13 +8,15 @@ defined('ABSPATH') || exit; final class ConnectionTab implements AdminTab { + public const SLUG = 'verbindung'; + public function __construct(private ConnectionStatus $status) { } public function slug(): string { - return 'verbindung'; + return self::SLUG; } public function label(): string diff --git a/src/Admin/DiviTab.php b/src/Admin/DiviTab.php index d1bff9c..cb1176f 100644 --- a/src/Admin/DiviTab.php +++ b/src/Admin/DiviTab.php @@ -220,14 +220,27 @@ final class DiviTab implements AdminTab private function renderForms(ReleaseStatus $status, ?CheckReport $report): void { + $fresh = $report !== null + && $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version)); + $untested = $status->state === ReleaseState::Untested && $status->version !== ''; + printf('', esc_url(DiviCheckRoute::url())); printf('', esc_attr(wp_create_nonce('wp_rest'))); - submit_button(__('Prüfung starten', 'wp-lit-mcp'), 'secondary', 'lit-mcp-divi-check', false); + // The check is the one thing to do while untested without a usable report; otherwise the release leads. + submit_button( + __('Prüfung starten', 'wp-lit-mcp'), + $untested && ! $fresh ? 'primary' : 'secondary', + 'lit-mcp-divi-check', + false + ); + echo '

' . esc_html__( + // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. + 'Die Prüfung legt eine private Testseite an, prüft Lesen und Schreiben und löscht sie wieder. Sie dauert einige Sekunden.', + 'wp-lit-mcp' + ) . '

'; echo ''; - if ($status->state === ReleaseState::Untested && $status->version !== '') { - $fresh = $report !== null - && $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version)); + if ($untested) { if ($fresh) { $this->actionForm( self::RELEASE_ACTION, diff --git a/src/Admin/LogListTable.php b/src/Admin/LogListTable.php index 707a375..53f632b 100644 --- a/src/Admin/LogListTable.php +++ b/src/Admin/LogListTable.php @@ -25,9 +25,6 @@ final class LogListTable extends \WP_List_Table /** Entries younger than a day show a relative time. */ private const RELATIVE_SECONDS = 86400; - /** @var (callable(): void)|null */ - private $renderFilters; - private LogObjects $objects; private int $now; @@ -36,19 +33,16 @@ final class LogListTable extends \WP_List_Table * @param array{ * ability: string, user_id: int, result?: string, date_from?: string, date_to?: string * } $filters - * @param (callable(): void)|null $renderFilters prints the filter controls into the top table nav - * @param array $toolLabels ability name => human label - * @param int|null $now current Unix time (tests), default time() + * @param array $toolLabels ability name => human label + * @param int|null $now current Unix time (tests), default time() */ public function __construct( private ChangeLog $log, private array $filters, - ?callable $renderFilters = null, private array $toolLabels = [], ?LogObjects $objects = null, ?int $now = null ) { - $this->renderFilters = $renderFilters; $this->objects = $objects ?? new LogObjects(); $this->now = $now ?? time(); parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]); @@ -109,22 +103,6 @@ final class LogListTable extends \WP_List_Table ); } - /** - * Filters left of the pagination in the top table nav, as on WordPress' own list screens. - * - * @param string $which - */ - protected function extra_tablenav($which): void - { - if ($which !== 'top' || $this->renderFilters === null) { - return; - } - - echo '
'; - ($this->renderFilters)(); - echo '
'; - } - /** * Marks rows whose write did not complete (result "partial": failed after an irreversible step, * spec §2 principle 7/8.2) or was refused (Divi release lock) so admins notice them while scrolling. diff --git a/src/Admin/LogTab.php b/src/Admin/LogTab.php index 82860fa..8f0b420 100644 --- a/src/Admin/LogTab.php +++ b/src/Admin/LogTab.php @@ -151,19 +151,19 @@ final class LogTab implements AdminTab $filters = $this->filters(); $extra = $this->extraFilters(); + + return new LogListTable($this->log, $filters + $extra, $this->toolLabels()); + } + + /** @return array ability name => label */ + private function toolLabels(): array + { $labels = []; foreach ($this->registry->abilities() as $ability) { $labels[$ability->name()] = $ability->label(); } - return new LogListTable( - $this->log, - $filters + $extra, - function () use ($filters, $extra, $labels): void { - $this->renderFilterControls($filters, $extra, $labels); - }, - $labels - ); + return $labels; } public function render(): void @@ -190,12 +190,20 @@ final class LogTab implements AdminTab echo '
'; printf('', esc_attr(SettingsPage::SLUG)); printf('', esc_attr($this->slug())); + // Own block, not the table nav: core hides ".tablenav .actions" on phones. + echo '
'; + $this->renderFilterControls($this->filters(), $this->extraFilters(), $this->toolLabels()); + echo '
'; $table = $this->table ?? $this->createTable(); $table->prepare_items(); $table->display(); echo '
'; - $this->renderClearForm(); + // The whole log, not the filtered page: "leeren" removes everything. + $total = $this->log->count(); + if ($total > 0) { + $this->renderClearForm($total); + } } /** @@ -306,9 +314,9 @@ final class LogTab implements AdminTab ); } - private function renderClearForm(): void + private function renderClearForm(int $total): void { - printf('
', esc_url(admin_url('admin-post.php'))); + printf('
', esc_url(admin_url('admin-post.php'))); printf('', esc_attr(self::CLEAR_ACTION)); wp_nonce_field(self::CLEAR_ACTION); $confirmMessage = esc_js(__( @@ -316,13 +324,17 @@ final class LogTab implements AdminTab 'wp-lit-mcp' )); submit_button( - __('Protokoll leeren', 'wp-lit-mcp'), + sprintf( + /* translators: %d: number of log entries */ + _n('Protokoll leeren (%d Eintrag)', 'Protokoll leeren (%d Einträge)', $total, 'wp-lit-mcp'), + $total + ), 'delete', 'submit', false, ['onclick' => "return window.confirm('" . $confirmMessage . "');"] ); - echo ''; + echo '
'; } public function handleClear(): void diff --git a/src/Admin/ToolsTab.php b/src/Admin/ToolsTab.php index e39563d..511b213 100644 --- a/src/Admin/ToolsTab.php +++ b/src/Admin/ToolsTab.php @@ -87,7 +87,12 @@ final class ToolsTab implements AdminTab echo '
'; settings_fields(self::GROUP); submit_button(null, 'primary', 'submit-top'); - echo '
'; + // The admin script mirrors a group's mode onto its rows before saving; it needs the label pattern. + printf( + '
', + /* translators: %s: mode inherited from the group */ + esc_attr__('wie Gruppe (%s)', 'wp-lit-mcp') + ); $closed = $this->savedClosedBoxes(); foreach ($groups as $group => $abilities) { $this->renderGroup($policy, $group, $labels[$group] ?? $group, $abilities, $closed); @@ -174,7 +179,12 @@ final class ToolsTab implements AdminTab ? $count > 0 && $modes[ExposurePolicy::DISABLED] === $count : in_array($boxId, $closedBoxes, true); - printf('
', esc_attr($boxId), $closed ? ' closed' : ''); + printf( + '
', + esc_attr($boxId), + $closed ? ' closed' : '', + esc_attr($group) + ); echo '
'; printf( '

%s (%s) %s

', @@ -236,7 +246,8 @@ final class ToolsTab implements AdminTab echo '
'; printf( - ' ', esc_attr($selectId), esc_html__('Ganze Gruppe', 'wp-lit-mcp'), esc_attr(ExposurePolicy::OPTION), @@ -257,7 +268,8 @@ final class ToolsTab implements AdminTab } echo ''; printf( - '', + '', esc_attr(ExposurePolicy::OPTION), esc_attr(ExposurePolicy::READONLY_PREFIX . $group), checked($policy->isReadonly($group), true, false), @@ -277,13 +289,16 @@ final class ToolsTab implements AdminTab $own = $policy->toolOverride($name); $locked = $policy->disabledByReadonly($ability); $required = in_array($name, ExposurePolicy::REQUIRED, true); + $writes = ($ability->annotations()['readonly'] ?? false) !== true && ! $required; $selectId = 'lit-mcp-tool-' . self::idPart($name); + /* translators: %s: default mode */ + $defaultLabel = sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability))); printf( '%1$s
%2$s' . '%3$s' . '' - . '', esc_html($ability->label()), esc_html($name), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in kind(). @@ -296,7 +311,10 @@ final class ToolsTab implements AdminTab )), esc_attr(ExposurePolicy::OPTION), esc_attr($name), - $locked ? ' disabled' : '' + $locked ? ' disabled' : '', + esc_attr($defaultLabel), + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- fixed attribute names only. + self::scriptFlags($name, $writes, $required) ); $inherited = $this->modeLabel($policy->inheritedMode($ability)); printf( @@ -305,8 +323,7 @@ final class ToolsTab implements AdminTab esc_html($groupOverride && ! $required /* translators: %s: mode inherited from the group */ ? sprintf(__('wie Gruppe (%s)', 'wp-lit-mcp'), $inherited) - /* translators: %s: default mode */ - : sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability)))) + : $defaultLabel) ); foreach (ExposurePolicy::MODES as $mode) { if (! ExposurePolicy::isAllowed($name, $mode)) { @@ -331,18 +348,39 @@ final class ToolsTab implements AdminTab ); } - $note = match (true) { - $locked => __('Durch „Nur lesen“ aus', 'wp-lit-mcp'), - $own === null && $policy->groupModeAdjusted($ability) - => __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'), - default => '', - }; - if ($note !== '') { - echo '

' . esc_html($note) . '

'; + if ($writes) { + // Always in the row so the admin script can show it as soon as "nur lesen" is switched on. + printf( + '

%s

', + $locked ? '' : ' hidden', + esc_html__('Durch „Nur lesen“ aus', 'wp-lit-mcp') + ); + } + if ($own === null && $policy->groupModeAdjusted($ability)) { + echo '

' + . esc_html__('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp') + . '

'; } echo ''; } + /** Data attributes telling the admin script how a row reacts to its group's controls. */ + private static function scriptFlags(string $name, bool $writes, bool $required): string + { + $flags = ''; + if ($writes) { + $flags .= ' data-writes="1"'; + } + if ($required) { + $flags .= ' data-required="1"'; + } + if (in_array($name, ExposurePolicy::DIRECT_ONLY, true)) { + $flags .= ' data-direct-only="1"'; + } + + return $flags; + } + /** * "liest" / "schreibt" / "überschreibt" / "löscht" / "Papierkorb" with a dashicon. The * "destructive" annotation is a broad hint (deleting, replacing, swapping, ruling S47), diff --git a/src/ChangeLog/ChangeLog.php b/src/ChangeLog/ChangeLog.php index 9e0f6d3..065922e 100644 --- a/src/ChangeLog/ChangeLog.php +++ b/src/ChangeLog/ChangeLog.php @@ -162,6 +162,16 @@ class ChangeLog return array_values(array_map('strval', $abilities)); } + /** Number of all entries, regardless of any list filter. */ + public function count(): int + { + $wpdb = $this->db(); + $table = $this->tableName(); + + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- own table name. + return (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}"); + } + /** Deletes every row (button "Protokoll leeren", spec §8.3). Irreversible. */ public function clear(): void { diff --git a/tests/Unit/Admin/AccessTabTest.php b/tests/Unit/Admin/AccessTabTest.php index a8c494e..20ac784 100644 --- a/tests/Unit/Admin/AccessTabTest.php +++ b/tests/Unit/Admin/AccessTabTest.php @@ -241,6 +241,35 @@ final class AccessTabTest extends TestCase self::assertStringContainsString('confirm(', $html); } + /** The admin script asks before a save that leaves nobody, or the admin themselves, with access. */ + public function testRolesFormCarriesTheConfirmationTextsAndTheOwnRoles(): void + { + $this->stubRender(['editor']); + + $html = $this->renderTab([]); + + self::assertStringContainsString( + '', + $html + ); + } + + public function testMultisiteSuperAdminsGetNoOwnRoleConfirmation(): void + { + $this->stubRender(['editor']); + Functions\when('is_multisite')->justReturn(true); + Functions\when('is_super_admin')->justReturn(true); + + $html = $this->renderTab([]); + + self::assertStringContainsString('class="lit-mcp-access-form"', $html); + self::assertStringNotContainsString('data-own-roles', $html); + } + public function testRolesAreAFormTableFieldsetWithLegendAndDescription(): void { $this->stubRender(['editor']); diff --git a/tests/Unit/Admin/AdminNoticesTest.php b/tests/Unit/Admin/AdminNoticesTest.php index e3151e1..805134b 100644 --- a/tests/Unit/Admin/AdminNoticesTest.php +++ b/tests/Unit/Admin/AdminNoticesTest.php @@ -104,5 +104,6 @@ final class AdminNoticesTest extends TestCase self::assertSame([], $notices->messages(false)); self::assertCount(1, $notices->messages(true)); + self::assertSame([], $notices->messages(true, true), 'not where the status list already says it'); } } diff --git a/tests/Unit/Admin/DiviTabTest.php b/tests/Unit/Admin/DiviTabTest.php index c5cd754..705ae9c 100644 --- a/tests/Unit/Admin/DiviTabTest.php +++ b/tests/Unit/Admin/DiviTabTest.php @@ -150,8 +150,8 @@ final class DiviTabTest extends TestCase Functions\when('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check'); Functions\when('wp_create_nonce')->justReturn('n'); Functions\when('wp_nonce_field')->justReturn(''); - Functions\when('submit_button')->alias(static function (string $text): void { - echo ''; + Functions\when('submit_button')->alias(static function (string $text, string $type = 'primary'): void { + echo ''; }); Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan']); Functions\when('date_i18n')->justReturn('01.10.2026 10:00'); @@ -173,6 +173,21 @@ final class DiviTabTest extends TestCase self::assertStringContainsString('name="_wpnonce" value="n"', $html); } + public function testCheckButtonSaysWhatItDoesAndLeadsWhileNothingElseCanBeDone(): void + { + $stale = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9')); + $fresh = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report()); + $released = $this->rendered(ReleaseStatus::released('5.3.0'), self::report()); + + foreach ([$stale, $fresh, $released] as $html) { + self::assertStringContainsString('legt eine private Testseite an', $html); + } + self::assertStringContainsString('', $stale); + self::assertStringContainsString('', $fresh); + self::assertStringContainsString('', $fresh); + self::assertStringContainsString('', $released); + } + public function testUntestedWithAStaleReportAsksForANewCheck(): void { $html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9')); diff --git a/tests/Unit/Admin/LogListTableTest.php b/tests/Unit/Admin/LogListTableTest.php index ad9775a..9706b08 100644 --- a/tests/Unit/Admin/LogListTableTest.php +++ b/tests/Unit/Admin/LogListTableTest.php @@ -48,7 +48,6 @@ final class LogListTableTest extends TestCase return new LogListTable( $log ?? Mockery::mock(ChangeLog::class), $filters + ['ability' => '', 'user_id' => 0], - null, $toolLabels, $objects, self::NOW @@ -254,18 +253,6 @@ final class LogListTableTest extends TestCase ); } - public function testTopNavRendersTheGivenFilters(): void - { - $table = new LogListTable(Mockery::mock(ChangeLog::class), ['ability' => '', 'user_id' => 0], static function (): void { - echo ''; - }); - - ob_start(); - $table->display(); - - self::assertStringContainsString('
', (string) ob_get_clean()); - } - /** @param array $item */ private function rowActions(LogListTable $table, array $item): string { diff --git a/tests/Unit/Admin/LogTabTest.php b/tests/Unit/Admin/LogTabTest.php index 7bd99c5..67b2122 100644 --- a/tests/Unit/Admin/LogTabTest.php +++ b/tests/Unit/Admin/LogTabTest.php @@ -184,15 +184,16 @@ final class LogTabTest extends TestCase self::assertFalse($tab->savePerPage(false, 'other_per_page', '40')); } - public function testRenderShowsFiltersAndAConfirmProtectedClearButton(): void + public function testRenderShowsTheFiltersAboveTheTableOutsideTheTableNav(): void { $html = $this->render(['lit/content-update', 'lit/media-upload-from-url', 'lit/x">