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>
322 lines
15 KiB
JavaScript
322 lines
15 KiB
JavaScript
'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);
|
|
});
|