fix: Admin-UX aus dem Review – Live-Gruppenfeedback, Protokollfilter, Divi-Hinweis, Rollen-Rückfrage
- 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
cc72f9b946
commit
1b73f0652a
+34
-22
@@ -260,16 +260,17 @@
|
|||||||
padding-bottom: 10px;
|
padding-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Room for "Standard (indirekt)" in the select on phones. */
|
||||||
.lit-mcp-tools-table .column-tool {
|
.lit-mcp-tools-table .column-tool {
|
||||||
width: 45%;
|
width: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lit-mcp-tools-table .column-kind {
|
.lit-mcp-tools-table .column-kind {
|
||||||
width: 12%;
|
width: 10%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lit-mcp-tools-table .column-mode {
|
.lit-mcp-tools-table .column-mode {
|
||||||
width: 43%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lit-mcp-tools-table th,
|
.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 {
|
.lit-mcp .lit_mcp_log_entries .column-object {
|
||||||
width: 24%;
|
width: 24%;
|
||||||
}
|
}
|
||||||
@@ -349,24 +379,6 @@
|
|||||||
box-shadow: inset 4px 0 0 #d63638;
|
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". */
|
/* Tab "Limits". */
|
||||||
.lit-mcp-unit {
|
.lit-mcp-unit {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
|
|||||||
+149
-23
@@ -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 () {
|
( function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
document.addEventListener( 'DOMContentLoaded', function () {
|
// "URL kopieren" – like WordPress' "Copy URL to clipboard" in the media library.
|
||||||
|
function setUpCopyButton() {
|
||||||
var hideTimeout;
|
var hideTimeout;
|
||||||
|
|
||||||
// "URL kopieren" – like WordPress' "Copy URL to clipboard" in the media library.
|
if ( ! window.ClipboardJS || ! document.querySelector( '.lit-mcp-copy' ) ) {
|
||||||
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' );
|
|
||||||
|
|
||||||
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 );
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
}
|
}
|
||||||
|
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' ) ) {
|
if ( window.postboxes && window.pagenow && document.querySelector( '.lit-mcp-groups' ) ) {
|
||||||
window.postboxes.add_postbox_toggles( window.pagenow );
|
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 ) {
|
function ( control ) {
|
||||||
control.addEventListener( 'change', function () {
|
control.addEventListener( 'change', function () {
|
||||||
var paged = control.form && control.form.querySelector( 'input[name="paged"]' );
|
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();
|
||||||
} );
|
} );
|
||||||
}() );
|
}() );
|
||||||
|
|||||||
+47
-16
@@ -68,29 +68,33 @@ final class AccessTab implements AdminTab
|
|||||||
)));
|
)));
|
||||||
|
|
||||||
if ($roles === []) {
|
if ($roles === []) {
|
||||||
self::warnOnce(
|
self::warnOnce('lit_mcp_no_roles', self::noRolesWarning());
|
||||||
'lit_mcp_no_roles',
|
|
||||||
__('Keine Rolle freigegeben – niemand kann LIT MCP nutzen.', 'wp-lit-mcp')
|
|
||||||
);
|
|
||||||
} elseif (self::currentUserLosesAccess($roles)) {
|
} elseif (self::currentUserLosesAccess($roles)) {
|
||||||
self::warnOnce(
|
self::warnOnce('lit_mcp_own_role', self::ownRoleWarning());
|
||||||
'lit_mcp_own_role',
|
|
||||||
__(
|
|
||||||
'Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.',
|
|
||||||
'wp-lit-mcp'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $roles;
|
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
|
public function render(): void
|
||||||
{
|
{
|
||||||
$allowed = $this->access->allowedRoles();
|
$allowed = $this->access->allowedRoles();
|
||||||
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
|
$legend = __('Rollen mit Zugriff', 'wp-lit-mcp');
|
||||||
|
|
||||||
echo '<form method="post" action="options.php">';
|
$this->openRolesForm();
|
||||||
settings_fields(self::GROUP);
|
settings_fields(self::GROUP);
|
||||||
// Makes an all-unchecked submission arrive as [''] instead of dropping the field.
|
// Makes an all-unchecked submission arrive as [''] instead of dropping the field.
|
||||||
printf('<input type="hidden" name="%s[]" value="">', esc_attr(Access::OPTION));
|
printf('<input type="hidden" name="%s[]" value="">', esc_attr(Access::OPTION));
|
||||||
@@ -148,14 +152,41 @@ final class AccessTab implements AdminTab
|
|||||||
/** @param list<string> $roles */
|
/** @param list<string> $roles */
|
||||||
private static function currentUserLosesAccess(array $roles): bool
|
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<string>|null
|
||||||
|
*/
|
||||||
|
private static function ownRoles(): ?array
|
||||||
|
{
|
||||||
if (is_multisite() && is_super_admin()) {
|
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(
|
||||||
|
'<form method="post" action="options.php" class="lit-mcp-access-form"'
|
||||||
|
. ' data-confirm-none="%s" data-confirm-own="%s"%s>',
|
||||||
|
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
|
private function renderConnections(): void
|
||||||
|
|||||||
@@ -21,8 +21,13 @@ final class AdminNotices
|
|||||||
add_action('admin_notices', [$this, 'render']);
|
add_action('admin_notices', [$this, 'render']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/**
|
||||||
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<string>
|
||||||
|
*/
|
||||||
|
public function messages(bool $onOwnPage, bool $statusShown = false): array
|
||||||
{
|
{
|
||||||
$messages = [];
|
$messages = [];
|
||||||
if (! $this->status->oauthEnabled()) {
|
if (! $this->status->oauthEnabled()) {
|
||||||
@@ -56,7 +61,7 @@ final class AdminNotices
|
|||||||
) . ' ' . __('Details unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp');
|
) . ' ' . __('Details unter Werkzeuge → Website-Zustand.', 'wp-lit-mcp');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($onOwnPage && ! $this->status->https()) {
|
if ($onOwnPage && ! $statusShown && ! $this->status->https()) {
|
||||||
$messages[] = __(
|
$messages[] = __(
|
||||||
'LIT MCP: Die Website läuft ohne HTTPS; KI-Assistenten verbinden sich nur mit HTTPS-Websites.',
|
'LIT MCP: Die Website läuft ohne HTTPS; KI-Assistenten verbinden sich nur mit HTTPS-Websites.',
|
||||||
'wp-lit-mcp'
|
'wp-lit-mcp'
|
||||||
@@ -74,7 +79,10 @@ final class AdminNotices
|
|||||||
|
|
||||||
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
|
$screen = function_exists('get_current_screen') ? get_current_screen() : null;
|
||||||
$onOwnPage = is_object($screen) && $screen->id === 'settings_page_' . SettingsPage::SLUG;
|
$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('<div class="notice notice-warning"><p>%s</p></div>', esc_html($message));
|
printf('<div class="notice notice-warning"><p>%s</p></div>', esc_html($message));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ defined('ABSPATH') || exit;
|
|||||||
|
|
||||||
final class ConnectionTab implements AdminTab
|
final class ConnectionTab implements AdminTab
|
||||||
{
|
{
|
||||||
|
public const SLUG = 'verbindung';
|
||||||
|
|
||||||
public function __construct(private ConnectionStatus $status)
|
public function __construct(private ConnectionStatus $status)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public function slug(): string
|
public function slug(): string
|
||||||
{
|
{
|
||||||
return 'verbindung';
|
return self::SLUG;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function label(): string
|
public function label(): string
|
||||||
|
|||||||
+17
-4
@@ -220,14 +220,27 @@ final class DiviTab implements AdminTab
|
|||||||
|
|
||||||
private function renderForms(ReleaseStatus $status, ?CheckReport $report): void
|
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('<form method="post" action="%s">', esc_url(DiviCheckRoute::url()));
|
printf('<form method="post" action="%s">', esc_url(DiviCheckRoute::url()));
|
||||||
printf('<input type="hidden" name="_wpnonce" value="%s">', esc_attr(wp_create_nonce('wp_rest')));
|
printf('<input type="hidden" name="_wpnonce" value="%s">', 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 '<p class="description">' . 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'
|
||||||
|
) . '</p>';
|
||||||
echo '</form>';
|
echo '</form>';
|
||||||
|
|
||||||
if ($status->state === ReleaseState::Untested && $status->version !== '') {
|
if ($untested) {
|
||||||
$fresh = $report !== null
|
|
||||||
&& $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version));
|
|
||||||
if ($fresh) {
|
if ($fresh) {
|
||||||
$this->actionForm(
|
$this->actionForm(
|
||||||
self::RELEASE_ACTION,
|
self::RELEASE_ACTION,
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ final class LogListTable extends \WP_List_Table
|
|||||||
/** Entries younger than a day show a relative time. */
|
/** Entries younger than a day show a relative time. */
|
||||||
private const RELATIVE_SECONDS = 86400;
|
private const RELATIVE_SECONDS = 86400;
|
||||||
|
|
||||||
/** @var (callable(): void)|null */
|
|
||||||
private $renderFilters;
|
|
||||||
|
|
||||||
private LogObjects $objects;
|
private LogObjects $objects;
|
||||||
|
|
||||||
private int $now;
|
private int $now;
|
||||||
@@ -36,19 +33,16 @@ final class LogListTable extends \WP_List_Table
|
|||||||
* @param array{
|
* @param array{
|
||||||
* ability: string, user_id: int, result?: string, date_from?: string, date_to?: string
|
* ability: string, user_id: int, result?: string, date_from?: string, date_to?: string
|
||||||
* } $filters
|
* } $filters
|
||||||
* @param (callable(): void)|null $renderFilters prints the filter controls into the top table nav
|
* @param array<string, string> $toolLabels ability name => human label
|
||||||
* @param array<string, string> $toolLabels ability name => human label
|
* @param int|null $now current Unix time (tests), default time()
|
||||||
* @param int|null $now current Unix time (tests), default time()
|
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ChangeLog $log,
|
private ChangeLog $log,
|
||||||
private array $filters,
|
private array $filters,
|
||||||
?callable $renderFilters = null,
|
|
||||||
private array $toolLabels = [],
|
private array $toolLabels = [],
|
||||||
?LogObjects $objects = null,
|
?LogObjects $objects = null,
|
||||||
?int $now = null
|
?int $now = null
|
||||||
) {
|
) {
|
||||||
$this->renderFilters = $renderFilters;
|
|
||||||
$this->objects = $objects ?? new LogObjects();
|
$this->objects = $objects ?? new LogObjects();
|
||||||
$this->now = $now ?? time();
|
$this->now = $now ?? time();
|
||||||
parent::__construct(['singular' => 'lit_mcp_log_entry', 'plural' => 'lit_mcp_log_entries', 'ajax' => false]);
|
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 '<div class="alignleft actions">';
|
|
||||||
($this->renderFilters)();
|
|
||||||
echo '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Marks rows whose write did not complete (result "partial": failed after an irreversible step,
|
* 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.
|
* spec §2 principle 7/8.2) or was refused (Divi release lock) so admins notice them while scrolling.
|
||||||
|
|||||||
+25
-13
@@ -151,19 +151,19 @@ final class LogTab implements AdminTab
|
|||||||
|
|
||||||
$filters = $this->filters();
|
$filters = $this->filters();
|
||||||
$extra = $this->extraFilters();
|
$extra = $this->extraFilters();
|
||||||
|
|
||||||
|
return new LogListTable($this->log, $filters + $extra, $this->toolLabels());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> ability name => label */
|
||||||
|
private function toolLabels(): array
|
||||||
|
{
|
||||||
$labels = [];
|
$labels = [];
|
||||||
foreach ($this->registry->abilities() as $ability) {
|
foreach ($this->registry->abilities() as $ability) {
|
||||||
$labels[$ability->name()] = $ability->label();
|
$labels[$ability->name()] = $ability->label();
|
||||||
}
|
}
|
||||||
|
|
||||||
return new LogListTable(
|
return $labels;
|
||||||
$this->log,
|
|
||||||
$filters + $extra,
|
|
||||||
function () use ($filters, $extra, $labels): void {
|
|
||||||
$this->renderFilterControls($filters, $extra, $labels);
|
|
||||||
},
|
|
||||||
$labels
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render(): void
|
public function render(): void
|
||||||
@@ -190,12 +190,20 @@ final class LogTab implements AdminTab
|
|||||||
echo '<form method="get">';
|
echo '<form method="get">';
|
||||||
printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
|
printf('<input type="hidden" name="page" value="%s">', esc_attr(SettingsPage::SLUG));
|
||||||
printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
|
printf('<input type="hidden" name="tab" value="%s">', esc_attr($this->slug()));
|
||||||
|
// Own block, not the table nav: core hides ".tablenav .actions" on phones.
|
||||||
|
echo '<div class="lit-mcp-log-filters">';
|
||||||
|
$this->renderFilterControls($this->filters(), $this->extraFilters(), $this->toolLabels());
|
||||||
|
echo '</div>';
|
||||||
$table = $this->table ?? $this->createTable();
|
$table = $this->table ?? $this->createTable();
|
||||||
$table->prepare_items();
|
$table->prepare_items();
|
||||||
$table->display();
|
$table->display();
|
||||||
echo '</form>';
|
echo '</form>';
|
||||||
|
|
||||||
$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('<form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
printf('<div class="lit-mcp-clear-log"><form method="post" action="%s">', esc_url(admin_url('admin-post.php')));
|
||||||
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::CLEAR_ACTION));
|
printf('<input type="hidden" name="action" value="%s">', esc_attr(self::CLEAR_ACTION));
|
||||||
wp_nonce_field(self::CLEAR_ACTION);
|
wp_nonce_field(self::CLEAR_ACTION);
|
||||||
$confirmMessage = esc_js(__(
|
$confirmMessage = esc_js(__(
|
||||||
@@ -316,13 +324,17 @@ final class LogTab implements AdminTab
|
|||||||
'wp-lit-mcp'
|
'wp-lit-mcp'
|
||||||
));
|
));
|
||||||
submit_button(
|
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',
|
'delete',
|
||||||
'submit',
|
'submit',
|
||||||
false,
|
false,
|
||||||
['onclick' => "return window.confirm('" . $confirmMessage . "');"]
|
['onclick' => "return window.confirm('" . $confirmMessage . "');"]
|
||||||
);
|
);
|
||||||
echo '</form>';
|
echo '</form></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handleClear(): void
|
public function handleClear(): void
|
||||||
|
|||||||
+54
-16
@@ -87,7 +87,12 @@ final class ToolsTab implements AdminTab
|
|||||||
echo '<form method="post" action="options.php">';
|
echo '<form method="post" action="options.php">';
|
||||||
settings_fields(self::GROUP);
|
settings_fields(self::GROUP);
|
||||||
submit_button(null, 'primary', 'submit-top');
|
submit_button(null, 'primary', 'submit-top');
|
||||||
echo '<div class="lit-mcp-groups">';
|
// The admin script mirrors a group's mode onto its rows before saving; it needs the label pattern.
|
||||||
|
printf(
|
||||||
|
'<div class="lit-mcp-groups" data-like-group="%s">',
|
||||||
|
/* translators: %s: mode inherited from the group */
|
||||||
|
esc_attr__('wie Gruppe (%s)', 'wp-lit-mcp')
|
||||||
|
);
|
||||||
$closed = $this->savedClosedBoxes();
|
$closed = $this->savedClosedBoxes();
|
||||||
foreach ($groups as $group => $abilities) {
|
foreach ($groups as $group => $abilities) {
|
||||||
$this->renderGroup($policy, $group, $labels[$group] ?? $group, $abilities, $closed);
|
$this->renderGroup($policy, $group, $labels[$group] ?? $group, $abilities, $closed);
|
||||||
@@ -174,7 +179,12 @@ final class ToolsTab implements AdminTab
|
|||||||
? $count > 0 && $modes[ExposurePolicy::DISABLED] === $count
|
? $count > 0 && $modes[ExposurePolicy::DISABLED] === $count
|
||||||
: in_array($boxId, $closedBoxes, true);
|
: in_array($boxId, $closedBoxes, true);
|
||||||
|
|
||||||
printf('<div id="%s" class="postbox lit-mcp-group%s">', esc_attr($boxId), $closed ? ' closed' : '');
|
printf(
|
||||||
|
'<div id="%s" class="postbox lit-mcp-group%s" data-group="%s">',
|
||||||
|
esc_attr($boxId),
|
||||||
|
$closed ? ' closed' : '',
|
||||||
|
esc_attr($group)
|
||||||
|
);
|
||||||
echo '<div class="postbox-header">';
|
echo '<div class="postbox-header">';
|
||||||
printf(
|
printf(
|
||||||
'<h2 class="hndle">%s <span class="count">(%s)</span> <span class="lit-mcp-group-summary">%s</span></h2>',
|
'<h2 class="hndle">%s <span class="count">(%s)</span> <span class="lit-mcp-group-summary">%s</span></h2>',
|
||||||
@@ -236,7 +246,8 @@ final class ToolsTab implements AdminTab
|
|||||||
|
|
||||||
echo '<div class="lit-mcp-group-controls">';
|
echo '<div class="lit-mcp-group-controls">';
|
||||||
printf(
|
printf(
|
||||||
'<span class="lit-mcp-control"><label for="%1$s">%2$s</label> <select name="%3$s[%4$s]" id="%1$s">',
|
'<span class="lit-mcp-control"><label for="%1$s">%2$s</label>'
|
||||||
|
. ' <select name="%3$s[%4$s]" class="lit-mcp-group-mode" id="%1$s">',
|
||||||
esc_attr($selectId),
|
esc_attr($selectId),
|
||||||
esc_html__('Ganze Gruppe', 'wp-lit-mcp'),
|
esc_html__('Ganze Gruppe', 'wp-lit-mcp'),
|
||||||
esc_attr(ExposurePolicy::OPTION),
|
esc_attr(ExposurePolicy::OPTION),
|
||||||
@@ -257,7 +268,8 @@ final class ToolsTab implements AdminTab
|
|||||||
}
|
}
|
||||||
echo '</select></span>';
|
echo '</select></span>';
|
||||||
printf(
|
printf(
|
||||||
'<span class="lit-mcp-control"><label><input type="checkbox" name="%s[%s]" value="1"%s> %s</label></span>',
|
'<span class="lit-mcp-control"><label>'
|
||||||
|
. '<input type="checkbox" class="lit-mcp-group-readonly" name="%s[%s]" value="1"%s> %s</label></span>',
|
||||||
esc_attr(ExposurePolicy::OPTION),
|
esc_attr(ExposurePolicy::OPTION),
|
||||||
esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
|
esc_attr(ExposurePolicy::READONLY_PREFIX . $group),
|
||||||
checked($policy->isReadonly($group), true, false),
|
checked($policy->isReadonly($group), true, false),
|
||||||
@@ -277,13 +289,16 @@ final class ToolsTab implements AdminTab
|
|||||||
$own = $policy->toolOverride($name);
|
$own = $policy->toolOverride($name);
|
||||||
$locked = $policy->disabledByReadonly($ability);
|
$locked = $policy->disabledByReadonly($ability);
|
||||||
$required = in_array($name, ExposurePolicy::REQUIRED, true);
|
$required = in_array($name, ExposurePolicy::REQUIRED, true);
|
||||||
|
$writes = ($ability->annotations()['readonly'] ?? false) !== true && ! $required;
|
||||||
$selectId = 'lit-mcp-tool-' . self::idPart($name);
|
$selectId = 'lit-mcp-tool-' . self::idPart($name);
|
||||||
|
/* translators: %s: default mode */
|
||||||
|
$defaultLabel = sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability)));
|
||||||
|
|
||||||
printf(
|
printf(
|
||||||
'<tr><td class="column-tool"><strong>%1$s</strong><br><span class="lit-mcp-tool-name">%2$s</span></td>'
|
'<tr><td class="column-tool"><strong>%1$s</strong><br><span class="lit-mcp-tool-name">%2$s</span></td>'
|
||||||
. '<td class="column-kind">%3$s</td><td class="column-mode">'
|
. '<td class="column-kind">%3$s</td><td class="column-mode">'
|
||||||
. '<label class="screen-reader-text" for="%4$s">%5$s</label>'
|
. '<label class="screen-reader-text" for="%4$s">%5$s</label>'
|
||||||
. '<select name="%6$s[%7$s]"%8$s id="%4$s">',
|
. '<select name="%6$s[%7$s]" class="lit-mcp-tool-mode" data-default-label="%9$s"%10$s%8$s id="%4$s">',
|
||||||
esc_html($ability->label()),
|
esc_html($ability->label()),
|
||||||
esc_html($name),
|
esc_html($name),
|
||||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in kind().
|
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in kind().
|
||||||
@@ -296,7 +311,10 @@ final class ToolsTab implements AdminTab
|
|||||||
)),
|
)),
|
||||||
esc_attr(ExposurePolicy::OPTION),
|
esc_attr(ExposurePolicy::OPTION),
|
||||||
esc_attr($name),
|
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));
|
$inherited = $this->modeLabel($policy->inheritedMode($ability));
|
||||||
printf(
|
printf(
|
||||||
@@ -305,8 +323,7 @@ final class ToolsTab implements AdminTab
|
|||||||
esc_html($groupOverride && ! $required
|
esc_html($groupOverride && ! $required
|
||||||
/* translators: %s: mode inherited from the group */
|
/* translators: %s: mode inherited from the group */
|
||||||
? sprintf(__('wie Gruppe (%s)', 'wp-lit-mcp'), $inherited)
|
? sprintf(__('wie Gruppe (%s)', 'wp-lit-mcp'), $inherited)
|
||||||
/* translators: %s: default mode */
|
: $defaultLabel)
|
||||||
: sprintf(__('Standard (%s)', 'wp-lit-mcp'), $this->modeLabel($policy->defaultMode($ability))))
|
|
||||||
);
|
);
|
||||||
foreach (ExposurePolicy::MODES as $mode) {
|
foreach (ExposurePolicy::MODES as $mode) {
|
||||||
if (! ExposurePolicy::isAllowed($name, $mode)) {
|
if (! ExposurePolicy::isAllowed($name, $mode)) {
|
||||||
@@ -331,18 +348,39 @@ final class ToolsTab implements AdminTab
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$note = match (true) {
|
if ($writes) {
|
||||||
$locked => __('Durch „Nur lesen“ aus', 'wp-lit-mcp'),
|
// Always in the row so the admin script can show it as soon as "nur lesen" is switched on.
|
||||||
$own === null && $policy->groupModeAdjusted($ability)
|
printf(
|
||||||
=> __('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp'),
|
'<p class="description lit-mcp-readonly-note"%s>%s</p>',
|
||||||
default => '',
|
$locked ? '' : ' hidden',
|
||||||
};
|
esc_html__('Durch „Nur lesen“ aus', 'wp-lit-mcp')
|
||||||
if ($note !== '') {
|
);
|
||||||
echo '<p class="description">' . esc_html($note) . '</p>';
|
}
|
||||||
|
if ($own === null && $policy->groupModeAdjusted($ability)) {
|
||||||
|
echo '<p class="description">'
|
||||||
|
. esc_html__('„indirekt“ geht hier nicht – bleibt direkt', 'wp-lit-mcp')
|
||||||
|
. '</p>';
|
||||||
}
|
}
|
||||||
echo '</td></tr>';
|
echo '</td></tr>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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
|
* "liest" / "schreibt" / "überschreibt" / "löscht" / "Papierkorb" with a dashicon. The
|
||||||
* "destructive" annotation is a broad hint (deleting, replacing, swapping, ruling S47),
|
* "destructive" annotation is a broad hint (deleting, replacing, swapping, ruling S47),
|
||||||
|
|||||||
@@ -162,6 +162,16 @@ class ChangeLog
|
|||||||
return array_values(array_map('strval', $abilities));
|
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. */
|
/** Deletes every row (button "Protokoll leeren", spec §8.3). Irreversible. */
|
||||||
public function clear(): void
|
public function clear(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -241,6 +241,35 @@ final class AccessTabTest extends TestCase
|
|||||||
self::assertStringContainsString('confirm(', $html);
|
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(
|
||||||
|
'<form method="post" action="options.php" class="lit-mcp-access-form"'
|
||||||
|
. ' data-confirm-none="Keine Rolle freigegeben – niemand kann LIT MCP nutzen. Trotzdem speichern?"'
|
||||||
|
. ' data-confirm-own="Keine Ihrer Rollen ist mehr freigegeben – Sie selbst können LIT MCP nicht mehr nutzen.'
|
||||||
|
. ' Trotzdem speichern?"'
|
||||||
|
. ' data-own-roles="editor">',
|
||||||
|
$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
|
public function testRolesAreAFormTableFieldsetWithLegendAndDescription(): void
|
||||||
{
|
{
|
||||||
$this->stubRender(['editor']);
|
$this->stubRender(['editor']);
|
||||||
|
|||||||
@@ -104,5 +104,6 @@ final class AdminNoticesTest extends TestCase
|
|||||||
|
|
||||||
self::assertSame([], $notices->messages(false));
|
self::assertSame([], $notices->messages(false));
|
||||||
self::assertCount(1, $notices->messages(true));
|
self::assertCount(1, $notices->messages(true));
|
||||||
|
self::assertSame([], $notices->messages(true, true), 'not where the status list already says it');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('rest_url')->justReturn('https://k.de/wp-json/lit-mcp/v1/divi-check');
|
||||||
Functions\when('wp_create_nonce')->justReturn('n');
|
Functions\when('wp_create_nonce')->justReturn('n');
|
||||||
Functions\when('wp_nonce_field')->justReturn('');
|
Functions\when('wp_nonce_field')->justReturn('');
|
||||||
Functions\when('submit_button')->alias(static function (string $text): void {
|
Functions\when('submit_button')->alias(static function (string $text, string $type = 'primary'): void {
|
||||||
echo '<button>' . $text . '</button>';
|
echo '<button class="' . $type . '">' . $text . '</button>';
|
||||||
});
|
});
|
||||||
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan']);
|
Functions\when('get_userdata')->justReturn((object) ['display_name' => 'Jan']);
|
||||||
Functions\when('date_i18n')->justReturn('01.10.2026 10:00');
|
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);
|
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('<button class="primary">Prüfung starten</button>', $stale);
|
||||||
|
self::assertStringContainsString('<button class="secondary">Prüfung starten</button>', $fresh);
|
||||||
|
self::assertStringContainsString('<button class="primary">Divi 5.3.0 freigeben</button>', $fresh);
|
||||||
|
self::assertStringContainsString('<button class="secondary">Prüfung starten</button>', $released);
|
||||||
|
}
|
||||||
|
|
||||||
public function testUntestedWithAStaleReportAsksForANewCheck(): void
|
public function testUntestedWithAStaleReportAsksForANewCheck(): void
|
||||||
{
|
{
|
||||||
$html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9'));
|
$html = $this->rendered(new ReleaseStatus(ReleaseState::Untested, '5.3.0'), self::report(true, '5.2.9'));
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ final class LogListTableTest extends TestCase
|
|||||||
return new LogListTable(
|
return new LogListTable(
|
||||||
$log ?? Mockery::mock(ChangeLog::class),
|
$log ?? Mockery::mock(ChangeLog::class),
|
||||||
$filters + ['ability' => '', 'user_id' => 0],
|
$filters + ['ability' => '', 'user_id' => 0],
|
||||||
null,
|
|
||||||
$toolLabels,
|
$toolLabels,
|
||||||
$objects,
|
$objects,
|
||||||
self::NOW
|
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 '<select name="ability"></select>';
|
|
||||||
});
|
|
||||||
|
|
||||||
ob_start();
|
|
||||||
$table->display();
|
|
||||||
|
|
||||||
self::assertStringContainsString('<div class="alignleft actions"><select name="ability"></select></div>', (string) ob_get_clean());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $item */
|
/** @param array<string, mixed> $item */
|
||||||
private function rowActions(LogListTable $table, array $item): string
|
private function rowActions(LogListTable $table, array $item): string
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -184,15 +184,16 @@ final class LogTabTest extends TestCase
|
|||||||
self::assertFalse($tab->savePerPage(false, 'other_per_page', '40'));
|
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"><script>']);
|
$html = $this->render(['lit/content-update', 'lit/media-upload-from-url', 'lit/x"><script>']);
|
||||||
|
|
||||||
self::assertStringContainsString('name="result"', $html);
|
self::assertStringContainsString('name="result"', $html);
|
||||||
self::assertStringNotContainsString('<script>', $html);
|
self::assertStringNotContainsString('<script>', $html);
|
||||||
self::assertStringContainsString('lit_mcp_clear_log', $html);
|
// Core hides ".tablenav .actions" on phones; the filters live in their own block before the table.
|
||||||
self::assertStringContainsString('confirm(', $html);
|
self::assertStringContainsString('<div class="lit-mcp-log-filters">', $html);
|
||||||
self::assertStringContainsString('nonce-lit_mcp_clear_log', $html);
|
self::assertStringNotContainsString('alignleft actions', $html);
|
||||||
|
self::assertStringNotContainsString('lit_mcp_clear_log', $html, 'nothing to clear in an empty log');
|
||||||
|
|
||||||
// One intro line; the retention days link to the Limits tab.
|
// One intro line; the retention days link to the Limits tab.
|
||||||
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
|
self::assertMatchesRegularExpression('#<p class="description">[^<]*<a href="[^"]*tab=limits">90 Tagen</a>#', $html);
|
||||||
@@ -207,6 +208,22 @@ final class LogTabTest extends TestCase
|
|||||||
self::assertStringNotContainsString('Filter zurücksetzen', $html, 'no reset without an active filter');
|
self::assertStringNotContainsString('Filter zurücksetzen', $html, 'no reset without an active filter');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testClearButtonAppearsOnlyWithEntriesAndNamesTheirCount(): void
|
||||||
|
{
|
||||||
|
$two = $this->render([], 2);
|
||||||
|
|
||||||
|
self::assertStringContainsString('<div class="lit-mcp-clear-log">', $two);
|
||||||
|
self::assertStringContainsString('lit_mcp_clear_log', $two);
|
||||||
|
self::assertStringContainsString('confirm(', $two);
|
||||||
|
self::assertStringContainsString('nonce-lit_mcp_clear_log', $two);
|
||||||
|
self::assertStringContainsString('>Protokoll leeren (2 Einträge)</button>', $two);
|
||||||
|
self::assertStringContainsString('>Protokoll leeren (1 Eintrag)</button>', $this->render([], 1));
|
||||||
|
|
||||||
|
// The count is the whole log, not the filtered page (query() above answers with no rows).
|
||||||
|
$_GET['result'] = 'refused';
|
||||||
|
self::assertStringContainsString('>Protokoll leeren (2 Einträge)</button>', $this->render([], 2));
|
||||||
|
}
|
||||||
|
|
||||||
public function testToolFilterShowsHumanLabelsGroupedByIntegration(): void
|
public function testToolFilterShowsHumanLabelsGroupedByIntegration(): void
|
||||||
{
|
{
|
||||||
$_GET['ability'] = 'lit/content-update';
|
$_GET['ability'] = 'lit/content-update';
|
||||||
@@ -234,7 +251,7 @@ final class LogTabTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param list<string> $abilities abilities found in the log */
|
/** @param list<string> $abilities abilities found in the log */
|
||||||
private function render(array $abilities): string
|
private function render(array $abilities, int $total = 0): string
|
||||||
{
|
{
|
||||||
Functions\stubEscapeFunctions();
|
Functions\stubEscapeFunctions();
|
||||||
Functions\stubTranslationFunctions();
|
Functions\stubTranslationFunctions();
|
||||||
@@ -267,6 +284,7 @@ final class LogTabTest extends TestCase
|
|||||||
$log = Mockery::mock(ChangeLog::class);
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
$log->allows('abilities')->andReturn($abilities);
|
$log->allows('abilities')->andReturn($abilities);
|
||||||
$log->allows('query')->andReturn(['rows' => [], 'total' => 0]);
|
$log->allows('query')->andReturn(['rows' => [], 'total' => 0]);
|
||||||
|
$log->allows('count')->andReturn($total);
|
||||||
|
|
||||||
ob_start();
|
ob_start();
|
||||||
$this->tab($log)->render();
|
$this->tab($log)->render();
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ final class ToolsTabTest extends TestCase
|
|||||||
|
|
||||||
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html);
|
self::assertStringContainsString('name="lit_mcp_exposure[@readonly:divi]" value="1" checked="checked"', $html);
|
||||||
$update = $this->row($html, 'lit/divi-update');
|
$update = $this->row($html, 'lit/divi-update');
|
||||||
self::assertStringContainsString('<select name="lit_mcp_exposure[lit/divi-update]" disabled', $update);
|
self::assertMatchesRegularExpression('#<select name="lit_mcp_exposure\[lit/divi-update\]"[^>]* disabled#', $update);
|
||||||
self::assertStringContainsString('Durch „Nur lesen“ aus', $update);
|
self::assertStringContainsString('Durch „Nur lesen“ aus', $update);
|
||||||
self::assertStringContainsString(
|
self::assertStringContainsString(
|
||||||
'<input type="hidden" name="lit_mcp_exposure[lit/divi-update]" value="indirect">',
|
'<input type="hidden" name="lit_mcp_exposure[lit/divi-update]" value="indirect">',
|
||||||
@@ -211,6 +211,36 @@ final class ToolsTabTest extends TestCase
|
|||||||
self::assertStringNotContainsString('Nur lesen', $outline);
|
self::assertStringNotContainsString('Nur lesen', $outline);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The admin script mirrors a group's mode and "nur lesen" switch onto its rows before saving. */
|
||||||
|
public function testMarkupCarriesWhatTheScriptNeedsForLiveGroupFeedback(): void
|
||||||
|
{
|
||||||
|
$html = $this->render([]);
|
||||||
|
|
||||||
|
self::assertStringContainsString('<div class="lit-mcp-groups" data-like-group="wie Gruppe (%s)">', $html);
|
||||||
|
self::assertStringContainsString('class="lit-mcp-group-mode"', $this->select($html, '@group:divi'));
|
||||||
|
self::assertStringContainsString('class="lit-mcp-group-readonly"', $this->group($html, 'Divi'));
|
||||||
|
$update = $this->select($html, 'lit/divi-update');
|
||||||
|
self::assertStringContainsString('class="lit-mcp-tool-mode"', $update);
|
||||||
|
self::assertStringContainsString('data-default-label="Standard (direkt)"', $update);
|
||||||
|
self::assertStringContainsString('data-writes="1"', $update);
|
||||||
|
self::assertStringNotContainsString('data-writes', $this->select($html, 'lit/divi-outline'));
|
||||||
|
self::assertStringContainsString('data-required="1"', $this->select($html, 'lit/guide'));
|
||||||
|
self::assertStringContainsString('data-direct-only="1"', $this->select($html, 'lit/media-preview'));
|
||||||
|
self::assertStringNotContainsString('data-direct-only', $update);
|
||||||
|
// Every write row carries the "nur lesen" note, hidden until the switch is on.
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'<p class="description lit-mcp-readonly-note" hidden>Durch „Nur lesen“ aus</p>',
|
||||||
|
$this->row($html, 'lit/divi-update')
|
||||||
|
);
|
||||||
|
self::assertStringNotContainsString('lit-mcp-readonly-note', $this->row($html, 'lit/divi-outline'));
|
||||||
|
|
||||||
|
$locked = $this->render(['@readonly:divi' => true]);
|
||||||
|
self::assertStringContainsString(
|
||||||
|
'<p class="description lit-mcp-readonly-note">Durch „Nur lesen“ aus</p>',
|
||||||
|
$this->row($locked, 'lit/divi-update')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function testCountsDirectToolsUnderTheSavedSettings(): void
|
public function testCountsDirectToolsUnderTheSavedSettings(): void
|
||||||
{
|
{
|
||||||
$html = $this->render(['@group:divi' => 'disabled']);
|
$html = $this->render(['@group:divi' => 'disabled']);
|
||||||
@@ -338,7 +368,7 @@ final class ToolsTabTest extends TestCase
|
|||||||
$html = $this->render([]);
|
$html = $this->render([]);
|
||||||
$divi = $this->group($html, 'Divi');
|
$divi = $this->group($html, 'Divi');
|
||||||
|
|
||||||
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $divi);
|
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group" data-group="divi">', $divi);
|
||||||
self::assertStringContainsString('<div class="postbox-header">', $divi);
|
self::assertStringContainsString('<div class="postbox-header">', $divi);
|
||||||
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="true">', $divi);
|
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="true">', $divi);
|
||||||
self::assertStringContainsString('<span class="screen-reader-text">Gruppe ein-/ausblenden: Divi</span>', $divi);
|
self::assertStringContainsString('<span class="screen-reader-text">Gruppe ein-/ausblenden: Divi</span>', $divi);
|
||||||
@@ -367,11 +397,11 @@ final class ToolsTabTest extends TestCase
|
|||||||
$default = $this->render(['@group:divi' => 'disabled']);
|
$default = $this->render(['@group:divi' => 'disabled']);
|
||||||
$saved = $this->render(['@group:divi' => 'disabled'], null, ['lit-mcp-group-media']);
|
$saved = $this->render(['@group:divi' => 'disabled'], null, ['lit-mcp-group-media']);
|
||||||
|
|
||||||
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group closed">', $default);
|
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group closed" data-group="divi">', $default);
|
||||||
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="false">', $this->group($default, 'Divi'));
|
self::assertStringContainsString('<button type="button" class="handlediv" aria-expanded="false">', $this->group($default, 'Divi'));
|
||||||
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group">', $default);
|
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group" data-group="media">', $default);
|
||||||
|
|
||||||
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group">', $saved);
|
self::assertStringContainsString('<div id="lit-mcp-group-divi" class="postbox lit-mcp-group" data-group="divi">', $saved);
|
||||||
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group closed">', $saved);
|
self::assertStringContainsString('<div id="lit-mcp-group-media" class="postbox lit-mcp-group closed" data-group="media">', $saved);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,14 +182,16 @@ final class ChangeLogTest extends TestCase
|
|||||||
self::assertSame([], $result['rows']);
|
self::assertSame([], $result['rows']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testAbilitiesAndClear(): void
|
public function testAbilitiesCountAndClear(): void
|
||||||
{
|
{
|
||||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
$db->expects('get_col')->with('SELECT DISTINCT ability FROM wp_lit_mcp_log ORDER BY ability')->andReturn(['lit/a']);
|
$db->expects('get_col')->with('SELECT DISTINCT ability FROM wp_lit_mcp_log ORDER BY ability')->andReturn(['lit/a']);
|
||||||
|
$db->expects('get_var')->with('SELECT COUNT(*) FROM wp_lit_mcp_log')->andReturn('7');
|
||||||
$db->expects('query')->with('DELETE FROM wp_lit_mcp_log')->andReturn(4);
|
$db->expects('query')->with('DELETE FROM wp_lit_mcp_log')->andReturn(4);
|
||||||
$log = new ChangeLog($db);
|
$log = new ChangeLog($db);
|
||||||
|
|
||||||
self::assertSame(['lit/a'], $log->abilities());
|
self::assertSame(['lit/a'], $log->abilities());
|
||||||
|
self::assertSame(7, $log->count());
|
||||||
$log->clear();
|
$log->clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user