feat(admin): upgrade message management and broadcast interactions
Add message details, per-row actions, selection-aware bulk controls and confirmation dialogs. Merge filters into the list card and link overview statistics to the matching views with site-timezone today filtering. Improve the compose dialog with Unicode character counters, recipient mode segments, role counts and explicit broadcast confirmation verified against the resolved role and recipient count on submission. Split rendering into focused sibling traits and cover rendering, handlers and UI interactions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6195508481
commit
fe3ef82718
9 files changed
+1242
-422
No files matched your search
@@ -33,10 +33,14 @@ test('write handlers check capability and nonce', () => {
|
||||
const page = read('src/Admin/Native/UsersMenusMessages.php');
|
||||
assert.match(page, /function handle_send_message\(\): void \{\s*\$this->ensure_cap\(\);\s*check_admin_referer\('aksrt_users_send_message'\);/);
|
||||
assert.match(page, /function handle_bulk_messages\(\): void \{\s*\$this->ensure_cap\(\);\s*check_admin_referer\('aksrt_users_bulk_messages'\);/);
|
||||
assert.ok(page.includes("wp_nonce_field('aksrt_users_send_message')"));
|
||||
assert.ok(page.includes("wp_nonce_field('aksrt_users_bulk_messages')"));
|
||||
const compose = read('src/Admin/Native/UsersMenusMessagesCompose.php');
|
||||
const list = read('src/Admin/Native/UsersMenusMessagesList.php');
|
||||
assert.ok(page.includes('use UsersMenusMessagesCompose;'));
|
||||
assert.ok(page.includes('use UsersMenusMessagesList;'));
|
||||
assert.ok(compose.includes("wp_nonce_field('aksrt_users_send_message')"));
|
||||
assert.ok(list.includes("wp_nonce_field('aksrt_users_bulk_messages')"));
|
||||
// 页面层不直接碰数据库,分页走共享组件。
|
||||
assert.doesNotMatch(page, /\$wpdb/);
|
||||
assert.doesNotMatch(page + compose + list, /\$wpdb/);
|
||||
assert.ok(page.includes('AdminListPagination::render('));
|
||||
});
|
||||
|
||||
@@ -53,3 +57,95 @@ test('dead notification helpers are removed', () => {
|
||||
assert.doesNotMatch(read('src/Messages/Message.php'), /get_all_notifications/);
|
||||
assert.ok(fs.existsSync(path.join(root, 'assets/css/admin/messages.css')));
|
||||
});
|
||||
|
||||
let rendered;
|
||||
function harness() {
|
||||
if (!rendered) {
|
||||
const result = require('node:child_process').spawnSync('php', [path.join(__dirname, 'message-admin-harness.php')], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr + result.stdout);
|
||||
rendered = JSON.parse(result.stdout);
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
test('list toolbar is merged into the table card and inline confirmation is removed', () => {
|
||||
const page = read('src/Admin/Native/UsersMenusMessages.php');
|
||||
const list = read('src/Admin/Native/UsersMenusMessagesList.php');
|
||||
assert.ok(page.indexOf("NativeUi::card_open(__('站内通知'") < page.indexOf('$this->render_message_filter_form'));
|
||||
assert.doesNotMatch(page + list, /onclick=|confirm\(|<details|aksrt-native-toolbar/);
|
||||
const { table, empty } = harness();
|
||||
assert.match(table, /data-aksrt-msg-action="delete" disabled/);
|
||||
assert.match(table, /data-aksrt-msg-selection/);
|
||||
assert.match(table, /name="period" value="today"/);
|
||||
assert.match(empty, /当前筛选条件下没有消息/);
|
||||
});
|
||||
|
||||
test('details preserve full content, escape HTML and unsafe links, and never mark read', () => {
|
||||
const { table, cache_calls } = harness();
|
||||
assert.match(table, /<template id="aksrt-msg-detail-8">/);
|
||||
assert.match(table, /完整内容(?:完整内容){99}/);
|
||||
assert.match(table, /<script>x<\/script>/);
|
||||
assert.doesNotMatch(table, /<script>|href="javascript:/);
|
||||
assert.match(table, /2026-10-30 11:30/);
|
||||
assert.match(table, /查看详情不会改变收件人的已读状态/);
|
||||
assert.equal((table.match(/data-aksrt-msg-action="mark_read" data-id=/g) || []).length, 1);
|
||||
assert.deepEqual(cache_calls, [[7]]);
|
||||
});
|
||||
|
||||
test('role choices display counts and disable only empty or oversized roles', () => {
|
||||
const { compose } = harness();
|
||||
assert.match(compose, /value="subscriber" data-count="3"[^>]*>订阅者 · 3 人/);
|
||||
assert.match(compose, /value="editor" data-count="501"[^>]* disabled/);
|
||||
assert.match(compose, /value="author" data-count="0"[^>]* disabled/);
|
||||
assert.match(compose, /value="contributor" data-count="500"[^>]*>贡献者 · 500 人/);
|
||||
assert.doesNotMatch(compose, /value="contributor"[^>]* disabled/);
|
||||
assert.match(compose, /data-count-limit="191"/);
|
||||
assert.match(compose, /data-count-limit="2000"/);
|
||||
assert.match(compose, /aria-describedby="aksrt-msg-content-count"/);
|
||||
assert.match(compose, /data-aksrt-msg-pane="role" hidden/);
|
||||
});
|
||||
|
||||
test('overview links, status tabs, filter forms and mutations preserve period filtering', () => {
|
||||
const data = harness();
|
||||
assert.equal(data.filters.period, 'today');
|
||||
assert.equal(data.bad_period.period, '');
|
||||
assert.equal(data.query_filters.period, 'today');
|
||||
assert.match(data.overview, /period=today/);
|
||||
assert.match(data.overview, /status=unread/);
|
||||
assert.match(data.filter_form, /value="today" selected/);
|
||||
assert.match(data.views, /period=today/);
|
||||
assert.equal(data.single_delete.redirect.period, 'today');
|
||||
assert.equal(data.single_delete.redirect.status, 'unread');
|
||||
assert.equal(data.single_delete.redirect.paged, '3');
|
||||
});
|
||||
|
||||
test('today counts and filtered lists share prepared site-timezone bounds including DST', () => {
|
||||
const { sql, dst_sql } = harness();
|
||||
assert.equal(sql.length, 4);
|
||||
sql.forEach(({ sql, params }) => {
|
||||
assert.match(sql, /created_at >= %s AND created_at < %s/);
|
||||
assert.ok(params.includes('2026-10-29 16:00:00'));
|
||||
assert.ok(params.includes('2026-10-30 16:00:00'));
|
||||
});
|
||||
assert.doesNotMatch(sql[2].sql, /is_read = 0 AND/);
|
||||
assert.ok(sql[0].params.includes('%a\\%\\_%'));
|
||||
assert.deepEqual(dst_sql[0].params, ['2026-11-01 04:00:00', '2026-11-02 05:00:00']);
|
||||
});
|
||||
|
||||
test('role broadcasts require confirmation of the current role and count on the server', () => {
|
||||
const { send, cap_checked, nonce_checked } = harness();
|
||||
['unconfirmed', 'changed_count', 'changed_role', 'too_large', 'long_title'].forEach((key) => {
|
||||
assert.equal(send[key].sent, 0, key);
|
||||
assert.equal(send[key].redirect.aksrt_msg, 'send_failed', key);
|
||||
});
|
||||
assert.equal(send.confirmed.sent, 3);
|
||||
assert.equal(send.users.sent, 1);
|
||||
assert.equal(cap_checked, 8);
|
||||
assert.equal(nonce_checked.length, 8);
|
||||
});
|
||||
|
||||
test('single row actions cannot accidentally operate on the checked bulk selection', () => {
|
||||
const { single_delete } = harness();
|
||||
assert.deepEqual(single_delete.sql[0].params, [8]);
|
||||
assert.match(single_delete.sql[0].sql, /DELETE FROM .* WHERE id IN/);
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
/** WordPress-free message admin render / handler / SQL harness. No actual notifications are sent. */
|
||||
namespace AKSRT\Core {
|
||||
final class DBMigrator {
|
||||
public static function table($key) { return 'wp_aksrt_' . $key; }
|
||||
}
|
||||
}
|
||||
namespace AKSRT\Messages {
|
||||
final class Message {
|
||||
public static $sent = [];
|
||||
public static function get_instance() { return new self(); }
|
||||
public function send($id, $type, $content, $args) {
|
||||
self::$sent[] = compact('id', 'type', 'content', 'args');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
namespace {
|
||||
define('ABSPATH', __DIR__);
|
||||
define('ARRAY_A', 'ARRAY_A');
|
||||
$root = dirname(__DIR__, 2);
|
||||
define('AKSRT_DIR', $root);
|
||||
define('AKSRT_URI', 'https://example.test/theme');
|
||||
class WP_Error {
|
||||
public function __construct(public $code, public $message) {}
|
||||
public function get_error_message() { return $this->message; }
|
||||
}
|
||||
class WP_User {
|
||||
public $ID = 7;
|
||||
public $display_name = 'Test <user>';
|
||||
}
|
||||
class Redirect extends \RuntimeException {}
|
||||
function __($text, $domain = '') { return $text; }
|
||||
function esc_html($text) { return htmlspecialchars((string) $text, ENT_QUOTES, 'UTF-8'); }
|
||||
function esc_attr($text) { return esc_html($text); }
|
||||
function esc_html__($text, $domain) { return esc_html($text); }
|
||||
function esc_attr__($text, $domain) { return esc_attr($text); }
|
||||
function esc_url($url) { return preg_match('/^javascript:/i', $url) ? '' : esc_attr($url); }
|
||||
function esc_url_raw($url) { return preg_match('/^javascript:/i', $url) ? '' : $url; }
|
||||
function sanitize_key($text) { return preg_replace('/[^a-z0-9_\-]/', '', strtolower($text)); }
|
||||
function sanitize_text_field($text) { return trim(strip_tags($text)); }
|
||||
function sanitize_textarea_field($text) { return trim(strip_tags($text)); }
|
||||
function sanitize_user($text) { return $text; }
|
||||
function wp_unslash($text) { return $text; }
|
||||
function absint($value) { return abs((int) $value); }
|
||||
function is_wp_error($value) { return $value instanceof WP_Error; }
|
||||
function is_email($value) { return filter_var($value, FILTER_VALIDATE_EMAIL); }
|
||||
function admin_url($path) { return 'https://example.test/wp-admin/' . $path; }
|
||||
function get_edit_user_link($id) { return admin_url('user-edit.php?user_id=' . $id); }
|
||||
function get_avatar_url($id, $args) { return 'https://example.test/avatar.png'; }
|
||||
function get_userdata($id) { return $id > 0 ? new WP_User() : false; }
|
||||
function get_user_by($key, $value) { return new WP_User(); }
|
||||
function cache_users($ids) { $GLOBALS['cache_calls'][] = $ids; }
|
||||
function number_format_i18n($count) { return number_format($count); }
|
||||
function human_time_diff($time) { return '1 分钟'; }
|
||||
function checked($actual, $expected, $echo = true) { return $actual === $expected ? ' checked' : ''; }
|
||||
function selected($actual, $expected, $echo = true) { return $actual === $expected ? ' selected' : ''; }
|
||||
function wp_nonce_field($action) { echo '<input type="hidden" name="_wpnonce" value="' . esc_attr($action) . '" />'; }
|
||||
function check_admin_referer($action) { $GLOBALS['nonce_checked'][] = $action; }
|
||||
function wp_roles() {
|
||||
return new class {
|
||||
public $roles = ['subscriber' => [], 'editor' => [], 'author' => [], 'contributor' => []];
|
||||
public function get_names() { return ['subscriber' => '订阅者', 'editor' => '编辑', 'author' => '作者', 'contributor' => '贡献者']; }
|
||||
};
|
||||
}
|
||||
function translate_user_role($role) { return $role; }
|
||||
function count_users($strategy) { return ['avail_roles' => ['subscriber' => 3, 'editor' => 501, 'author' => 0, 'contributor' => 500]]; }
|
||||
function get_users($args) { return array_slice(range(1, $GLOBALS['role_count'] ?? 3), 0, $args['number']); }
|
||||
function wp_safe_redirect($url) { throw new Redirect($url); }
|
||||
function add_query_arg($args, $url) { return $url . '?' . http_build_query($args); }
|
||||
function submit_button($text, $type, $name, $wrap) { echo '<button type="submit">' . esc_html($text) . '</button>'; }
|
||||
function current_datetime() { return new \DateTimeImmutable($GLOBALS['now'] ?? '2026-10-30 12:00:00', new \DateTimeZone($GLOBALS['zone'] ?? 'Asia/Shanghai')); }
|
||||
function get_gmt_from_date($local) { return (new \DateTimeImmutable($local, new \DateTimeZone($GLOBALS['zone'] ?? 'Asia/Shanghai')))->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s'); }
|
||||
function get_date_from_gmt($gmt, $format) { return (new \DateTimeImmutable($gmt, new \DateTimeZone('UTC')))->setTimezone(new \DateTimeZone('Asia/Shanghai'))->format($format); }
|
||||
function current_time($format, $gmt = false) { return '2026-10-30 04:00:00'; }
|
||||
class Database {
|
||||
public $prepared = [];
|
||||
public $queries = [];
|
||||
public function prepare($sql, ...$params) {
|
||||
$params = count($params) === 1 && is_array($params[0]) ? $params[0] : $params;
|
||||
$this->prepared[] = ['sql' => $sql, 'params' => $params];
|
||||
return $sql;
|
||||
}
|
||||
public function get_var($sql) { return 2; }
|
||||
public function get_results($sql, $format) { return []; }
|
||||
public function get_row($sql, $format) { return ['total' => 2, 'unread' => 1, 'today' => 1, 'recipients' => 2]; }
|
||||
public function get_col($sql) { return ['system']; }
|
||||
public function query($sql) { $this->queries[] = $sql; return 1; }
|
||||
public function esc_like($text) { return addcslashes($text, '_%\\'); }
|
||||
}
|
||||
$wpdb = new Database();
|
||||
require $root . '/src/Messages/MessageTypes.php';
|
||||
require $root . '/src/Messages/AdminBroadcast.php';
|
||||
require $root . '/src/Messages/NotificationAdminQuery.php';
|
||||
require $root . '/src/Admin/Native/NativeUi.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusMessagesList.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusMessagesCompose.php';
|
||||
require $root . '/src/Admin/Native/UsersMenusMessages.php';
|
||||
class Probe {
|
||||
use \AKSRT\Admin\Native\UsersMenusMessages;
|
||||
public $cap_checked = 0;
|
||||
private function ensure_cap(): void { $this->cap_checked++; }
|
||||
public function invoke($method, ...$args) { return $this->$method(...$args); }
|
||||
public function html($method, ...$args) {
|
||||
ob_start(); $this->$method(...$args); return ob_get_clean();
|
||||
}
|
||||
}
|
||||
$probe = new Probe();
|
||||
$out = [];
|
||||
$filters = $probe->invoke('message_filters', ['period' => 'today', 'status' => 'unread', 's' => 'a%_']);
|
||||
$out['filters'] = $filters;
|
||||
$out['bad_period'] = $probe->invoke('message_filters', ['period' => 'evil']);
|
||||
$out['query_filters'] = $probe->invoke('message_query_filters', $filters);
|
||||
$out['compose'] = $probe->html('render_message_compose');
|
||||
$out['dialogs'] = $probe->html('render_message_dialogs');
|
||||
$out['overview'] = $probe->html('render_message_overview');
|
||||
$out['filter_form'] = $probe->html('render_message_filter_form', $filters);
|
||||
$out['views'] = $probe->invoke('message_views_html', $filters, ['all' => 2, 'unread' => 1, 'read' => 1]);
|
||||
$row = ['id' => 8, 'user_id' => 7, 'type' => 'system', 'title' => '<script>alert(1)</script>', 'description' => str_repeat('完整内容', 100) . "\n<script>x</script>", 'target_url' => 'javascript:alert(1)', 'is_read' => 0, 'created_at' => '2026-10-30 03:00:00', 'read_at' => null];
|
||||
$out['table'] = $probe->html('render_message_table', [$row, array_merge($row, ['id' => 9, 'is_read' => 1, 'read_at' => '2026-10-30 03:30:00'])], $filters, 3, false);
|
||||
$out['empty'] = $probe->html('render_message_table', [], $filters, 1, false);
|
||||
$out['cache_calls'] = $GLOBALS['cache_calls'];
|
||||
|
||||
$wpdb->prepared = [];
|
||||
\AKSRT\Messages\NotificationAdminQuery::paginate($out['query_filters'], 1);
|
||||
\AKSRT\Messages\NotificationAdminQuery::status_counts($out['query_filters']);
|
||||
\AKSRT\Messages\NotificationAdminQuery::overview();
|
||||
$out['sql'] = $wpdb->prepared;
|
||||
$GLOBALS['zone'] = 'America/New_York';
|
||||
$GLOBALS['now'] = '2026-11-01 12:00:00';
|
||||
$wpdb->prepared = [];
|
||||
\AKSRT\Messages\NotificationAdminQuery::overview();
|
||||
$out['dst_sql'] = $wpdb->prepared;
|
||||
|
||||
$base = ['recipient_mode' => 'role', 'role' => 'subscriber', 'content' => 'Hello', 'title' => 'Title'];
|
||||
$cases = [
|
||||
'unconfirmed' => $base,
|
||||
'changed_count' => $base + ['confirmed_role' => 'subscriber', 'confirmed_count' => 2],
|
||||
'changed_role' => $base + ['confirmed_role' => 'editor', 'confirmed_count' => 3],
|
||||
'confirmed' => $base + ['confirmed_role' => 'subscriber', 'confirmed_count' => 3],
|
||||
'too_large' => $base + ['confirmed_role' => 'subscriber', 'confirmed_count' => 501],
|
||||
'users' => ['recipient_mode' => 'users', 'users' => '7', 'content' => 'Hello'],
|
||||
'long_title' => array_merge($base, ['title' => str_repeat('字', 192)]),
|
||||
];
|
||||
foreach ($cases as $name => $post) {
|
||||
$_POST = $post;
|
||||
$GLOBALS['role_count'] = $name === 'too_large' ? 501 : 3;
|
||||
\AKSRT\Messages\Message::$sent = [];
|
||||
try { $probe->handle_send_message(); } catch (Redirect $redirect) {
|
||||
parse_str(parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
|
||||
$out['send'][$name] = ['redirect' => $query, 'sent' => count(\AKSRT\Messages\Message::$sent)];
|
||||
}
|
||||
}
|
||||
$_POST = ['bulk_action' => 'delete', 'message_id' => 8, 'ids' => [9, 10], 'period' => 'today', 'status' => 'unread', 'paged' => 3];
|
||||
$wpdb->prepared = [];
|
||||
try { $probe->handle_bulk_messages(); } catch (Redirect $redirect) {
|
||||
parse_str(parse_url($redirect->getMessage(), PHP_URL_QUERY), $query);
|
||||
$out['single_delete'] = ['redirect' => $query, 'sql' => $wpdb->prepared];
|
||||
}
|
||||
$out['cap_checked'] = $probe->cap_checked;
|
||||
$out['nonce_checked'] = $GLOBALS['nonce_checked'];
|
||||
echo json_encode($out, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const test = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function runtime({ autoopen = false, empty = false } = {}) {
|
||||
const ids = new Map();
|
||||
const doc = { activeElement: null };
|
||||
class Element {
|
||||
constructor(attrs = {}) {
|
||||
this.attrs = attrs;
|
||||
this.listeners = {};
|
||||
this.queries = new Map();
|
||||
this.disabled = false;
|
||||
this.hidden = false;
|
||||
this.value = attrs.value || '';
|
||||
this.name = attrs.name || '';
|
||||
this.checked = false;
|
||||
this.required = false;
|
||||
this.validity = '';
|
||||
this.textContent = '';
|
||||
const classes = new Set();
|
||||
this.classList = {
|
||||
toggle(key, on) { if (on) classes.add(key); else classes.delete(key); },
|
||||
contains(key) { return classes.has(key); },
|
||||
};
|
||||
}
|
||||
getAttribute(name) { return this.attrs[name] ?? null; }
|
||||
hasAttribute(name) { return Object.hasOwn(this.attrs, name); }
|
||||
addEventListener(type, fn) { (this.listeners[type] ||= []).push(fn); }
|
||||
fire(type, values = {}) {
|
||||
const event = { target: this, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, ...values };
|
||||
(this.listeners[type] || []).forEach((fn) => fn(event));
|
||||
return event;
|
||||
}
|
||||
querySelector(selector) {
|
||||
const result = this.queries.get(selector);
|
||||
return Array.isArray(result) ? result[0] || null : result || null;
|
||||
}
|
||||
querySelectorAll(selector) {
|
||||
const result = this.queries.get(selector);
|
||||
return Array.isArray(result) ? result : result ? [result] : [];
|
||||
}
|
||||
map(selector, elements) { this.queries.set(selector, elements); return this; }
|
||||
focus() { doc.activeElement = this; }
|
||||
setCustomValidity(value) { this.validity = value; }
|
||||
closest(selector) { return this.row; }
|
||||
replaceChildren(child) { this.content = child; }
|
||||
getBoundingClientRect() { return { left: 100, top: 100, right: 500, bottom: 500 }; }
|
||||
}
|
||||
const root = new Element();
|
||||
const window = new Element();
|
||||
doc.documentElement = new Element();
|
||||
doc.getElementById = (id) => ids.get(id) || null;
|
||||
doc.querySelector = (selector) => selector === '.aksrt-messages-admin' ? root : null;
|
||||
const dialogs = [];
|
||||
function dialog(id) {
|
||||
const node = new Element();
|
||||
node.open = false;
|
||||
node.showModal = () => { node.previous = doc.activeElement; node.open = true; };
|
||||
node.close = () => { node.open = false; node.previous?.focus(); node.fire('close'); };
|
||||
node.cancel = () => { node.fire('cancel'); node.close(); };
|
||||
node.closeButton = new Element();
|
||||
node.closeButton.addEventListener('click', () => {});
|
||||
node.map('[data-aksrt-dialog-close]', [node.closeButton]);
|
||||
ids.set(id, node);
|
||||
dialogs.push(node);
|
||||
return node;
|
||||
}
|
||||
const compose = dialog('aksrt-msg-compose');
|
||||
const detail = dialog('aksrt-msg-detail');
|
||||
const confirm = dialog('aksrt-msg-confirm');
|
||||
confirm.attrs = { 'data-i18n-delete-title': '删除消息', 'data-i18n-delete-text': '删除 {count} 条消息', 'data-i18n-delete-send': '确认删除' };
|
||||
const accept = new Element();
|
||||
const cancel = confirm.closeButton;
|
||||
const confirmation = new Element();
|
||||
confirm.map('[data-aksrt-msg-confirm]', accept).map('[data-aksrt-dialog-title]', new Element()).map('#aksrt-msg-confirm-text', confirmation).map('[data-aksrt-dialog-close][autofocus]', cancel);
|
||||
const detailBody = new Element();
|
||||
detail.map('[data-aksrt-msg-detail-body]', detailBody);
|
||||
ids.set('aksrt-msg-detail-8', { content: { cloneNode: () => ({ text: 'Full <script> message' }) } });
|
||||
root.map('.aksrt-msg-dialog', dialogs);
|
||||
const originalQuery = root.querySelector.bind(root);
|
||||
root.querySelector = (selector) => selector === 'dialog[open]' ? dialogs.find((node) => node.open) || null : originalQuery(selector);
|
||||
|
||||
function field(name, value = '', required = false) {
|
||||
const node = new Element({ name, value });
|
||||
node.required = required;
|
||||
return node;
|
||||
}
|
||||
function form(elements, checks = []) {
|
||||
const node = new Element();
|
||||
node.elements = elements;
|
||||
node.submissions = [];
|
||||
node.reportValidity = () => Object.values(elements).every((field) => field.disabled || (!field.validity && (!field.required || field.value)));
|
||||
node.requestSubmit = (submitter) => {
|
||||
if (!node.reportValidity()) return;
|
||||
const event = node.fire('submit', { submitter });
|
||||
if (event.defaultPrevented) return;
|
||||
const data = {};
|
||||
Object.entries(elements).forEach(([key, field]) => { if (!field.disabled) data[key] = field.value; });
|
||||
if (checks.length) data.ids = checks.filter((check) => check.checked && !check.disabled).map((check) => check.value);
|
||||
node.submissions.push(data);
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
const checks = empty ? [] : [field('ids[]', '8'), field('ids[]', '9')];
|
||||
checks.forEach((check) => { check.row = new Element(); });
|
||||
const list = form({ bulk_action: field('bulk_action'), message_id: field('message_id') }, checks);
|
||||
const checkAll = new Element();
|
||||
const selection = new Element({ 'data-label': '已选 %d 条' });
|
||||
const bulkRead = new Element({ 'data-aksrt-msg-action': 'mark_read' });
|
||||
const bulkDelete = new Element({ 'data-aksrt-msg-action': 'delete' });
|
||||
const rowRead = new Element({ 'data-aksrt-msg-action': 'mark_read', 'data-id': '8' });
|
||||
const rowDelete = new Element({ 'data-aksrt-msg-action': 'delete', 'data-id': '8' });
|
||||
const view = new Element({ 'data-aksrt-msg-view': '8' });
|
||||
const actions = [bulkRead, bulkDelete, rowRead, rowDelete];
|
||||
list.map('[data-aksrt-msg-check]', checks).map('[data-aksrt-msg-check-all]', checkAll).map('[data-aksrt-msg-selection]', selection)
|
||||
.map('[data-aksrt-msg-action]:not([data-id])', [bulkRead, bulkDelete]).map('[data-aksrt-msg-action]', actions)
|
||||
.map('button', [...actions, view]).map('button[data-id], button[data-aksrt-msg-view]', [rowRead, rowDelete, view]);
|
||||
root.map('[data-aksrt-msg-list]', list).map('[data-aksrt-msg-view]', [view]);
|
||||
|
||||
const role = field('role', '', true);
|
||||
role.options = [new Element({ 'data-count': '0' }), new Element({ 'data-count': '3', 'data-label': '订阅者' })];
|
||||
role.options[0].disabled = true;
|
||||
role.selectedIndex = 0;
|
||||
const composeForm = form({
|
||||
users: field('users', '', true), role, title: field('title'), content: field('content', '', true), target_url: field('target_url'),
|
||||
confirmed_role: field('confirmed_role'), confirmed_count: field('confirmed_count'),
|
||||
});
|
||||
composeForm.attrs = {
|
||||
'data-i18n-count-error': '最多 {count} 字', 'data-i18n-role-hint': '发送给 {count} 人',
|
||||
'data-i18n-role-title': '确认群发', 'data-i18n-role-confirm': '{role}: {count} 人', 'data-i18n-role-send': '确认发送',
|
||||
};
|
||||
const usersRadio = new Element({ value: 'users' });
|
||||
usersRadio.checked = true;
|
||||
const roleRadio = new Element({ value: 'role' });
|
||||
const usersPane = new Element({ 'data-aksrt-msg-pane': 'users' });
|
||||
const rolePane = new Element({ 'data-aksrt-msg-pane': 'role' });
|
||||
usersPane.map('input, select, textarea', [composeForm.elements.users]);
|
||||
rolePane.map('input, select, textarea', [role]);
|
||||
const titleCount = new Element();
|
||||
const contentCount = new Element();
|
||||
const roleHint = new Element();
|
||||
const send = new Element();
|
||||
composeForm.elements.title.attrs['data-count-limit'] = '191';
|
||||
composeForm.elements.content.attrs['data-count-limit'] = '2000';
|
||||
composeForm.map('[data-aksrt-msg-send]', send).map('[data-count-limit]', [composeForm.elements.title, composeForm.elements.content])
|
||||
.map('[data-count-for="title"]', titleCount).map('[data-count-for="content"]', contentCount).map('[data-aksrt-msg-role-hint]', roleHint)
|
||||
.map('[data-aksrt-msg-pane]', [usersPane, rolePane]).map('input[name="recipient_mode"]', [usersRadio, roleRadio]);
|
||||
const formQuery = composeForm.querySelector.bind(composeForm);
|
||||
composeForm.querySelector = (selector) => selector === 'input[name="recipient_mode"]:checked' ? (usersRadio.checked ? usersRadio : roleRadio) : formQuery(selector);
|
||||
compose.map('[data-aksrt-msg-compose]', composeForm);
|
||||
if (autoopen) compose.attrs['data-aksrt-dialog-autoopen'] = '';
|
||||
const trigger = new Element();
|
||||
root.map('[data-aksrt-dialog-open]', [trigger]);
|
||||
|
||||
vm.runInNewContext(fs.readFileSync(path.resolve(__dirname, '../../assets/js/admin-messages.js'), 'utf8'), { document: doc, window });
|
||||
function chooseRole() {
|
||||
usersRadio.checked = false;
|
||||
roleRadio.checked = true;
|
||||
roleRadio.fire('change');
|
||||
role.value = 'subscriber';
|
||||
role.selectedIndex = 1;
|
||||
role.fire('change');
|
||||
}
|
||||
function select(index, checked = true) { checks[index].checked = checked; checks[index].fire('change'); }
|
||||
function input(name, value) { composeForm.elements[name].value = value; composeForm.fire('input'); }
|
||||
return { doc, window, compose, detail, confirm, trigger, accept, cancel, confirmation, detailBody, list, checks, checkAll, selection,
|
||||
bulkRead, bulkDelete, rowRead, rowDelete, view, composeForm, role, usersPane, rolePane, roleHint, send, titleCount, contentCount, chooseRole, select, input };
|
||||
}
|
||||
|
||||
test('selection count, indeterminate state and bulk availability stay in sync', () => {
|
||||
const r = runtime();
|
||||
assert.equal(r.bulkRead.disabled, true);
|
||||
assert.equal(r.selection.textContent, '已选 0 条');
|
||||
r.select(0);
|
||||
assert.equal(r.checkAll.indeterminate, true);
|
||||
assert.equal(r.bulkDelete.disabled, false);
|
||||
assert.equal(r.checks[0].row.classList.contains('is-selected'), true);
|
||||
r.checkAll.checked = true;
|
||||
r.checkAll.fire('change');
|
||||
assert.equal(r.selection.textContent, '已选 2 条');
|
||||
assert.equal(r.checkAll.indeterminate, false);
|
||||
assert.equal(runtime({ empty: true }).checkAll.disabled, true);
|
||||
});
|
||||
|
||||
test('bulk deletion requires confirmation, cancel and Escape do not submit', () => {
|
||||
const r = runtime();
|
||||
r.select(0); r.select(1);
|
||||
r.bulkDelete.fire('click');
|
||||
assert.equal(r.confirm.open, true);
|
||||
assert.match(r.confirmation.textContent, /2 条/);
|
||||
assert.equal(r.list.submissions.length, 0);
|
||||
r.cancel.fire('click');
|
||||
r.accept.fire('click');
|
||||
assert.equal(r.list.submissions.length, 0);
|
||||
r.bulkDelete.fire('click'); r.confirm.cancel();
|
||||
r.accept.fire('click');
|
||||
assert.equal(r.list.submissions.length, 0);
|
||||
r.bulkDelete.fire('click'); r.accept.fire('click');
|
||||
assert.deepEqual(r.list.submissions[0], { bulk_action: 'delete', message_id: '', ids: ['8', '9'] });
|
||||
assert.equal(r.bulkDelete.disabled, true);
|
||||
assert.equal(r.checks[0].disabled, false);
|
||||
r.bulkRead.fire('click');
|
||||
assert.equal(r.list.submissions.length, 1);
|
||||
});
|
||||
|
||||
test('single actions isolate one row even when other rows are selected', () => {
|
||||
const r = runtime();
|
||||
r.select(1);
|
||||
r.rowDelete.fire('click');
|
||||
assert.match(r.confirmation.textContent, /1 条/);
|
||||
r.accept.fire('click');
|
||||
assert.equal(r.list.submissions[0].message_id, '8');
|
||||
const read = runtime();
|
||||
read.rowRead.fire('click');
|
||||
assert.equal(read.confirm.open, false);
|
||||
assert.equal(read.list.submissions[0].bulk_action, 'mark_read');
|
||||
assert.equal(read.list.submissions[0].message_id, '8');
|
||||
});
|
||||
|
||||
test('details clone safe pre-rendered content without posting or changing read status', () => {
|
||||
const r = runtime();
|
||||
r.view.fire('click');
|
||||
assert.equal(r.detail.open, true);
|
||||
assert.equal(r.detailBody.content.text, 'Full <script> message');
|
||||
assert.equal(r.list.submissions.length, 0);
|
||||
});
|
||||
|
||||
test('recipient panes disable inactive inputs, switch required fields and show role counts', () => {
|
||||
const r = runtime();
|
||||
assert.equal(r.role.disabled, true);
|
||||
assert.equal(r.usersPane.hidden, false);
|
||||
r.chooseRole();
|
||||
assert.equal(r.role.disabled, false);
|
||||
assert.equal(r.composeForm.elements.users.disabled, true);
|
||||
assert.equal(r.usersPane.hidden, true);
|
||||
assert.equal(r.roleHint.textContent, '发送给 3 人');
|
||||
});
|
||||
|
||||
test('Unicode counters count emoji once and block over-limit input', () => {
|
||||
const r = runtime();
|
||||
r.input('title', '😀你好');
|
||||
assert.equal(r.titleCount.textContent, '3 / 191');
|
||||
r.input('content', '😀'.repeat(2001));
|
||||
assert.equal(r.contentCount.textContent, '2001 / 2000');
|
||||
assert.equal(r.contentCount.classList.contains('is-over'), true);
|
||||
assert.match(r.composeForm.elements.content.validity, /2000/);
|
||||
r.input('content', '字'.repeat(2000));
|
||||
assert.equal(r.composeForm.elements.content.validity, '');
|
||||
});
|
||||
|
||||
test('role sending uses a nested confirmation with the confirmed role and count', () => {
|
||||
const r = runtime();
|
||||
r.chooseRole(); r.input('content', 'Hello');
|
||||
r.trigger.fire('click');
|
||||
r.composeForm.requestSubmit(r.send);
|
||||
assert.equal(r.compose.open, true);
|
||||
assert.equal(r.confirm.open, true);
|
||||
assert.equal(r.confirmation.textContent, '订阅者: 3 人');
|
||||
assert.equal(r.composeForm.submissions.length, 0);
|
||||
r.cancel.fire('click');
|
||||
assert.equal(r.compose.open, true);
|
||||
assert.equal(r.composeForm.submissions.length, 0);
|
||||
assert.equal(r.doc.documentElement.classList.contains('aksrt-msg-modal-open'), true);
|
||||
r.composeForm.requestSubmit(r.send); r.accept.fire('click');
|
||||
assert.equal(r.composeForm.submissions.length, 1);
|
||||
assert.equal(r.composeForm.submissions[0].confirmed_role, 'subscriber');
|
||||
assert.equal(r.composeForm.submissions[0].confirmed_count, '3');
|
||||
assert.equal(r.send.disabled, true);
|
||||
r.composeForm.requestSubmit();
|
||||
assert.equal(r.composeForm.submissions.length, 1);
|
||||
});
|
||||
|
||||
test('confirmation snapshots cannot approve changed content', () => {
|
||||
const r = runtime();
|
||||
r.chooseRole(); r.input('content', 'One'); r.composeForm.requestSubmit();
|
||||
r.input('content', 'Two'); r.accept.fire('click');
|
||||
assert.equal(r.composeForm.submissions.length, 0);
|
||||
assert.equal(r.composeForm.elements.confirmed_role.value, '');
|
||||
});
|
||||
|
||||
test('direct user sending does not ask for role confirmation; bfcache restores buttons', () => {
|
||||
const r = runtime();
|
||||
r.input('users', '7'); r.input('content', 'Hello'); r.composeForm.requestSubmit();
|
||||
assert.equal(r.confirm.open, false);
|
||||
assert.equal(r.composeForm.submissions.length, 1);
|
||||
r.window.fire('pageshow');
|
||||
assert.equal(r.send.disabled, false);
|
||||
r.composeForm.requestSubmit();
|
||||
assert.equal(r.composeForm.submissions.length, 2);
|
||||
});
|
||||
|
||||
test('returning to the page clears the previous mutation target and restores selection controls', () => {
|
||||
const r = runtime();
|
||||
r.select(1); r.rowRead.fire('click');
|
||||
assert.equal(r.list.elements.message_id.value, '8');
|
||||
r.window.fire('pageshow');
|
||||
assert.equal(r.list.elements.message_id.value, '');
|
||||
assert.equal(r.list.elements.bulk_action.value, '');
|
||||
assert.equal(r.rowRead.disabled, false);
|
||||
assert.equal(r.bulkRead.disabled, false);
|
||||
r.list.requestSubmit();
|
||||
assert.equal(r.list.submissions.length, 1);
|
||||
});
|
||||
|
||||
test('failed sends reopen compose and backdrop clicks must start outside the dialog', () => {
|
||||
const r = runtime({ autoopen: true });
|
||||
assert.equal(r.compose.open, true);
|
||||
r.compose.fire('pointerdown', { clientX: 120, clientY: 120 });
|
||||
r.compose.fire('click', { clientX: 120, clientY: 120 });
|
||||
assert.equal(r.compose.open, true);
|
||||
r.compose.fire('pointerdown', { clientX: 0, clientY: 0 });
|
||||
r.compose.fire('click', { clientX: 0, clientY: 0 });
|
||||
assert.equal(r.compose.open, false);
|
||||
assert.equal(r.doc.documentElement.classList.contains('aksrt-msg-modal-open'), false);
|
||||
});
|
||||
Reference in new issue
Block a user