Files
wordpress-theme-aksrt/tests/admin/ticket-page-runtime.test.cjs
T
kate522andClaude Fable 5 b9000e1b93 feat(admin): dock ticket reply composer in conversation and move notes to a dialog
Put the reply composer at the bottom of the conversation card so the
timeline and composer form one viewport-height panel; the send button is
visible on load and Ctrl/Cmd+Enter submits. Internal notes now open in a
dialog instead of expanding inline, so the sidebar no longer grows.

Remove the shared ticket_composer helper and the inline note expansion CSS;
both dialogs share ticket_dialog_head.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-10-31 15:10:13 +08:00

399 lines
20 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');
const scriptPath = path.resolve(__dirname, '../../assets/js/admin-tickets.js');
const strings = { saving: 'Saving', saveFailed: 'Save failed', uncertain: 'Unknown result', required: 'Required', tooLong: 'Too long', discard: 'Discard?' };
const REST = 'https://site.test/wp-json/aksrt/v1/admin/tickets';
function makeRuntime({ href = 'https://site.test/wp-admin/users.php?page=aksrt_tickets&id=41', page = 'detail', storage = {} } = {}) {
const ids = new Map();
const document = { activeElement: null };
class Element {
constructor(tag, attrs = {}) {
this.tagName = tag.toUpperCase();
this.attrs = { ...attrs };
this.dataset = {};
for (const [name, value] of Object.entries(attrs)) {
const match = name.match(/^data-(.+)$/);
if (match) this.dataset[match[1].replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = String(value);
}
this.children = [];
this.parentElement = null;
this.listeners = {};
this.id = attrs.id || '';
this.name = attrs.name || '';
this.type = attrs.type || (this.tagName === 'TEXTAREA' ? 'textarea' : '');
this.action = attrs.action || '';
this.required = Object.hasOwn(attrs, 'required');
this.hidden = Object.hasOwn(attrs, 'hidden');
this.maxLength = attrs.maxlength === undefined ? -1 : Number(attrs.maxlength);
this.disabled = false;
this.checked = false;
this.validity = '';
this.textContent = '';
this.selectionStart = 0;
this.selectionEnd = 0;
this.scrollTop = 0;
this.scrollHeight = 100;
this.clientHeight = 40;
this._value = String(attrs.value ?? '');
this.selectedIndex = 0;
const classes = new Set(String(attrs.class || '').split(/\s+/).filter(Boolean));
this.classList = {
contains: (name) => classes.has(name),
toggle: (name, on) => { if (on) classes.add(name); else classes.delete(name); },
};
if (this.id) ids.set(this.id, this);
}
get options() { return this.children.filter((child) => child.tagName === 'OPTION'); }
get value() { return this.tagName === 'SELECT' ? this.options[this.selectedIndex]?.value ?? '' : this._value; }
set value(value) {
this._value = String(value);
if (this.tagName === 'SELECT') this.selectedIndex = this.options.findIndex((option) => option.value === this._value);
}
get form() { return this.closest('form'); }
append(child) { child.parentElement = this; this.children.push(child); return child; }
addEventListener(type, callback) { (this.listeners[type] ||= []).push(callback); }
fire(type, values = {}) {
const event = { target: this, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }, ...values };
(this.listeners[type] || []).forEach((callback) => callback(event));
return event;
}
querySelectorAll(selector) {
const parts = expandIs(selector).split(',').map((part) => part.trim()).filter(Boolean);
const found = new Set();
const visit = (node) => node.children.forEach((child) => {
if (parts.some((part) => matches(child, part))) found.add(child);
visit(child);
});
visit(this);
return [...found];
}
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
closest(selector) {
for (let node = this; node; node = node.parentElement) if (node.matches(selector)) return node;
return null;
}
matches(selector) { return expandIs(selector).split(',').some((part) => matches(this, part.trim())); }
getAttribute(name) { return this.attrs[name] ?? null; }
setAttribute(name, value) { this.attrs[name] = String(value); }
focus() { document.activeElement = this; }
setCustomValidity(message) { this.validity = message; }
setSelectionRange(start, end) { this.selectionStart = start; this.selectionEnd = end; }
reportValidity() {
return this.querySelectorAll('input, select, textarea').every((control) => {
if (control.validity) return false;
if (!control.required) return true;
return control.type === 'checkbox' ? control.checked : control.value.trim() !== '';
});
}
}
// 把 `A :is(x, y), B` 展开成 `A x, B, A y, B`;结果去重。
function expandIs(selector) {
const match = selector.match(/^(.*?):is\(([^)]*)\)(.*)$/);
return match ? match[2].split(',').map((part) => match[1] + part.trim() + match[3]).join(', ') : selector;
}
function matches(element, selector) {
const parts = selector.split(/\s+/);
if (!matchesSimple(element, parts.pop())) return false;
let ancestor = element.parentElement;
while (parts.length) {
const part = parts.pop();
while (ancestor && !matchesSimple(ancestor, part)) ancestor = ancestor.parentElement;
if (!ancestor) return false;
ancestor = ancestor.parentElement;
}
return true;
}
function matchesSimple(element, selector) {
const tag = selector.match(/^[a-z][\w-]*/i)?.[0];
if (tag && element.tagName !== tag.toUpperCase()) return false;
for (const [, className] of selector.matchAll(/\.([\w-]+)/g)) if (!element.classList.contains(className)) return false;
for (const [, name, expected] of selector.matchAll(/\[([^\]=]+)(?:="([^"]*)")?\]/g)) {
if (!Object.hasOwn(element.attrs, name)) return false;
if (expected !== undefined && String(element.attrs[name]) !== expected) return false;
}
return true;
}
const el = (tag, attrs, parent) => parent.append(new Element(tag, attrs));
const select = (parent, attrs, choices, selected = '') => {
const node = el('select', attrs, parent);
choices.forEach(([value, extra = {}]) => el('option', { value, ...extra }, node));
node.value = selected;
return node;
};
function makeDialog(id) {
const dialog = el('dialog', { id, class: 'aksrt-ticket-dialog' }, root);
dialog.open = false;
dialog.showModal = () => { dialog.open = true; };
dialog.close = () => { if (!dialog.open) return; dialog.open = false; dialog.fire('close'); };
return dialog;
}
const root = new Element('div', { class: 'aksrt-tickets-admin' });
const feedback = el('div', { 'data-aksrt-ticket-feedback': '', hidden: '' }, root);
el('p', {}, feedback);
el('button', { type: 'button', 'data-aksrt-ticket-reload': '', hidden: '' }, feedback);
const nodes = { root, feedback };
if (page === 'detail') {
const detail = el('div', { 'data-aksrt-ticket-detail': '41' }, root);
nodes.latest = el('button', { type: 'button', 'data-aksrt-ticket-latest': '', hidden: '' }, detail);
nodes.conversation = el('div', { class: 'aksrt-ticket-conversation__scroll' }, detail);
const reply = nodes.reply = el('form', { action: `${REST}/41/reply`, 'data-aksrt-ticket-form': 'reply' }, detail);
nodes.replyText = el('textarea', { id: 'aksrt-ticket-reply-41', name: 'content', required: '', maxlength: '24', 'data-aksrt-ticket-editor': '' }, reply);
nodes.replyCount = el('span', { 'data-aksrt-ticket-counter': 'aksrt-ticket-reply-41' }, reply);
nodes.prereply = select(reply, { 'data-aksrt-ticket-prereply': 'aksrt-ticket-reply-41' }, [[''], ['short'], ['01234567890123456789012345']]);
el('button', { type: 'submit' }, reply);
const section = el('section', {}, detail);
nodes.assign = el('button', { type: 'button', 'data-aksrt-ticket-assign': '' }, section);
const status = nodes.status = el('form', { action: `${REST}/41/status`, 'data-aksrt-ticket-form': 'status' }, section);
nodes.statusSelect = select(status, { name: 'status', 'data-aksrt-ticket-status': '' }, [['pending', { 'data-closed': '0' }], ['handling', { 'data-closed': '0' }], ['closed', { 'data-closed': '1' }]], 'pending');
select(status, { name: 'ticket_type_id', required: '' }, [['3'], ['6']], '3');
nodes.admin = select(status, { name: 'admin_id' }, [['0'], ['7'], ['44']], '7');
nodes.warning = el('div', { 'data-aksrt-ticket-close-warning': '', hidden: '' }, status);
nodes.confirm = el('input', { type: 'checkbox', 'data-aksrt-ticket-close-confirm': '' }, nodes.warning);
nodes.statusSubmit = el('button', { type: 'submit' }, status);
nodes.noteOpener = el('button', { type: 'button', 'data-aksrt-dialog-open': 'aksrt-ticket-note-dialog' }, detail);
const noteDialog = nodes.noteDialog = makeDialog('aksrt-ticket-note-dialog');
const note = nodes.note = el('form', { action: `${REST}/41/notes`, 'data-aksrt-ticket-form': 'note' }, noteDialog);
nodes.noteClose = el('button', { type: 'button', 'data-aksrt-dialog-close': '' }, note);
nodes.noteFeedback = el('div', { 'data-aksrt-ticket-feedback': '', hidden: '' }, note);
el('p', {}, nodes.noteFeedback);
el('button', { type: 'button', 'data-aksrt-ticket-reload': '', hidden: '' }, nodes.noteFeedback);
nodes.noteText = el('textarea', { id: 'aksrt-ticket-note-41', name: 'content', required: '', autofocus: '', maxlength: '24', 'data-aksrt-ticket-editor': '' }, note);
el('button', { type: 'submit' }, note);
note.reset = () => { nodes.noteText.value = ''; };
nodes.replyText.attrs['data-aksrt-ticket-send-shortcut'] = '';
reply.requestSubmit = () => reply.fire('submit') && root.fire('submit', { target: reply });
} else {
nodes.opener = el('button', { type: 'button', 'data-aksrt-dialog-open': 'aksrt-ticket-create-dialog' }, root);
const dialog = nodes.dialog = makeDialog('aksrt-ticket-create-dialog');
const create = nodes.create = el('form', { action: REST, 'data-aksrt-ticket-form': 'create' }, dialog);
nodes.closeButton = el('button', { type: 'button', 'data-aksrt-dialog-close': '' }, create);
el('input', { type: 'number', name: 'user_id', required: '' }, create);
select(create, { name: 'ticket_type_id', required: '' }, [[''], ['3']], '');
el('input', { type: 'text', name: 'title', required: '' }, create);
el('textarea', { id: 'aksrt-ticket-create-content', name: 'content', required: '', maxlength: '500', 'data-aksrt-ticket-editor': '' }, create);
el('button', { type: 'submit' }, create);
create.reset = () => create.querySelectorAll('input, select, textarea').forEach((control) => { control.value = ''; });
}
document.querySelector = (selector) => (selector === '.aksrt-tickets-admin' ? root : null);
document.getElementById = (id) => ids.get(id) || null;
const store = new Map(Object.entries(storage));
const window = new Element('window');
Object.assign(window, {
AKSRTAdminTickets: { restNonce: 'rest-nonce', currentUser: 44, strings },
location: { href, assigned: null, reloads: 0, assign(url) { this.assigned = url; }, reload() { this.reloads += 1; } },
history: { state: null, replaceState(_state, _title, url) { window.location.href = url; } },
sessionStorage: { getItem: (key) => store.get(key) ?? null, setItem: (key, value) => store.set(key, String(value)), removeItem: (key) => store.delete(key) },
confirmAnswer: false,
confirmCalls: [],
confirm(message) { this.confirmCalls.push(message); return this.confirmAnswer; },
});
const fetchCalls = [];
const fetch = (url, options) => new Promise((resolve, reject) => fetchCalls.push({ url, options, resolve, reject }));
vm.runInNewContext(fs.readFileSync(scriptPath, 'utf8'), { document, window, fetch, URL, AbortController, setTimeout, clearTimeout }, { filename: scriptPath });
return { ...nodes, document, window, store, fetchCalls };
}
const reply = (data, status = 200) => ({ ok: status >= 200 && status < 300, status, json: async () => data });
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
const type = (rt, field, value) => { field.value = value; rt.root.fire('input', { target: field }); };
const submit = (rt, form) => rt.root.fire('submit', { target: form });
const unload = (rt) => rt.window.fire('beforeunload', { returnValue: undefined });
test('a successful reply posts JSON to REST, then reloads the workspace with a result code and stashes other drafts', async () => {
const rt = makeRuntime({ href: 'https://site.test/wp-admin/users.php?page=aksrt_tickets&id=41&status=pending&paged=3' });
type(rt, rt.noteText, 'pending note');
type(rt, rt.replyText, 'hello');
const event = submit(rt, rt.reply);
assert.equal(event.defaultPrevented, true);
assert.equal(rt.fetchCalls.length, 1);
const call = rt.fetchCalls[0];
assert.equal(call.url, `${REST}/41/reply`);
assert.equal(call.options.method, 'POST');
assert.equal(call.options.headers['X-WP-Nonce'], 'rest-nonce');
assert.deepEqual(JSON.parse(call.options.body), { content: 'hello' });
assert.equal(rt.replyText.disabled, true, 'form locked while saving');
submit(rt, rt.reply);
assert.equal(rt.fetchCalls.length, 1, 'duplicate submit ignored');
call.resolve(reply({ success: true, data: { reply_id: 9 } }));
await tick();
const next = new URL(rt.window.location.assigned);
assert.equal(next.searchParams.get('id'), '41');
assert.equal(next.searchParams.get('aksrt_ticket_msg'), 'reply');
assert.equal(next.searchParams.get('status'), 'pending');
assert.equal(next.searchParams.get('paged'), '3');
assert.deepEqual(JSON.parse(rt.store.get('aksrt-ticket-drafts-41')), { note: { content: 'pending note' } });
assert.equal(unload(rt).defaultPrevented, false, 'navigation after save is not blocked');
});
test('after the reload, stashed drafts are restored once and the result code is stripped from the URL', () => {
const rt = makeRuntime({
href: 'https://site.test/wp-admin/users.php?page=aksrt_tickets&id=41&aksrt_ticket_msg=reply',
storage: { 'aksrt-ticket-drafts-41': JSON.stringify({ note: { content: 'keep me' } }) },
});
assert.equal(rt.noteText.value, 'keep me');
assert.equal(rt.store.has('aksrt-ticket-drafts-41'), false);
assert.equal(new URL(rt.window.location.href).searchParams.has('aksrt_ticket_msg'), false);
assert.equal(new URL(rt.window.location.href).searchParams.get('id'), '41');
assert.equal(unload(rt).defaultPrevented, true, 'a restored draft still counts as unsaved');
});
test('create dialog opens from the header button, guards dirty close, resets and returns focus', () => {
const rt = makeRuntime({ page: 'create', href: 'https://site.test/wp-admin/users.php?page=aksrt_tickets' });
rt.opener.isConnected = true;
rt.root.fire('click', { target: rt.opener });
assert.equal(rt.dialog.open, true);
const title = rt.create.querySelector('[name="title"]');
type(rt, title, 'Unsaved');
rt.root.fire('click', { target: rt.closeButton });
assert.equal(rt.dialog.open, true, 'declined discard keeps the dialog open');
assert.deepEqual(rt.window.confirmCalls, [strings.discard]);
const esc = rt.dialog.fire('cancel');
assert.equal(esc.defaultPrevented, true, 'Esc goes through the same guard');
assert.equal(rt.dialog.open, true);
rt.window.confirmAnswer = true;
rt.root.fire('click', { target: rt.closeButton });
assert.equal(rt.dialog.open, false);
assert.equal(title.value, '', 'closing resets the form');
assert.equal(rt.document.activeElement, rt.opener);
rt.window.confirmCalls.length = 0;
rt.root.fire('click', { target: rt.opener });
rt.root.fire('click', { target: rt.closeButton });
assert.equal(rt.dialog.open, false, 'a clean dialog closes without asking');
assert.equal(rt.window.confirmCalls.length, 0);
});
test('create posts to the collection route and opens the new ticket workspace with the list context kept', async () => {
const rt = makeRuntime({ page: 'create', href: 'https://site.test/wp-admin/users.php?page=aksrt_tickets&status=pending&paged=2' });
rt.root.fire('click', { target: rt.opener });
const form = rt.create;
form.querySelector('[name="user_id"]').value = '12';
form.querySelector('[name="ticket_type_id"]').value = '3';
type(rt, form.querySelector('[name="title"]'), 'Login fails');
type(rt, form.querySelector('[name="content"]'), 'Details');
submit(rt, form);
assert.equal(rt.fetchCalls[0].url, REST);
assert.deepEqual(JSON.parse(rt.fetchCalls[0].options.body), { user_id: '12', ticket_type_id: '3', title: 'Login fails', content: 'Details' });
rt.fetchCalls[0].resolve(reply({ success: true, data: { id: 77 } }));
await tick();
const next = new URL(rt.window.location.assigned);
assert.equal(next.searchParams.has('view'), false);
assert.equal(next.searchParams.get('id'), '77');
assert.equal(next.searchParams.get('aksrt_ticket_msg'), 'created');
assert.equal(next.searchParams.get('status'), 'pending');
assert.equal(next.searchParams.get('paged'), '2');
});
test('whitespace-only content is rejected before any request', () => {
const rt = makeRuntime();
type(rt, rt.replyText, ' ');
submit(rt, rt.reply);
assert.equal(rt.fetchCalls.length, 0);
assert.equal(rt.replyText.validity, strings.required);
});
test('a confirmed validation error keeps the form editable and shows the server message without a reload button', async () => {
const rt = makeRuntime();
type(rt, rt.replyText, 'hello');
submit(rt, rt.reply);
rt.fetchCalls[0].resolve(reply({ code: 'aksrt_ticket_reply_content', message: 'Bad content', data: { status: 400 } }, 400));
await tick();
assert.equal(rt.window.location.assigned, null);
assert.equal(rt.feedback.hidden, false);
assert.equal(rt.feedback.querySelector('p').textContent, 'Bad content');
assert.equal(rt.feedback.querySelector('[data-aksrt-ticket-reload]').hidden, true);
assert.equal(rt.replyText.disabled, false);
assert.equal(rt.replyText.value, 'hello');
submit(rt, rt.reply);
assert.equal(rt.fetchCalls.length, 2, 'retry allowed after a confirmed failure');
});
for (const [label, settle] of [
['an unknown network result', (call) => call.reject(new TypeError('network'))],
['a 409 state conflict', (call) => call.resolve(reply({ code: 'aksrt_ticket_closed', message: 'Closed', data: { status: 409 } }, 409))],
['a 404 missing ticket', (call) => call.resolve(reply({ code: 'aksrt_ticket_missing', message: 'Missing', data: { status: 404 } }, 404))],
]) {
test(`${label} locks every write form and only offers a page reload`, async () => {
const rt = makeRuntime();
type(rt, rt.replyText, 'hello');
submit(rt, rt.reply);
settle(rt.fetchCalls[0]);
await tick();
const reload = rt.feedback.querySelector('[data-aksrt-ticket-reload]');
assert.equal(reload.hidden, false);
for (const control of [rt.replyText, rt.noteText, rt.statusSelect, rt.statusSubmit, rt.assign]) assert.equal(control.disabled, true);
submit(rt, rt.note);
assert.equal(rt.fetchCalls.length, 1, 'no write after the result became uncertain');
rt.root.fire('click', { target: reload });
assert.equal(rt.window.location.reloads, 1);
assert.equal(unload(rt).defaultPrevented, false, 'the explicit reload is not blocked');
});
}
test('closing requires the confirmation checkbox', async () => {
const rt = makeRuntime();
rt.statusSelect.value = 'handling';
rt.root.fire('change', { target: rt.statusSelect });
assert.equal(rt.warning.hidden, true);
rt.statusSelect.value = 'closed';
rt.root.fire('change', { target: rt.statusSelect });
assert.equal(rt.warning.hidden, false);
assert.equal(rt.confirm.required, true);
submit(rt, rt.status);
assert.equal(rt.fetchCalls.length, 0);
rt.confirm.checked = true;
submit(rt, rt.status);
assert.deepEqual(JSON.parse(rt.fetchCalls[0].options.body), { status: 'closed', ticket_type_id: '3', admin_id: '7' });
});
test('“assign to me” selects the current admin, moves pending to handling and focuses save', () => {
const rt = makeRuntime();
rt.root.fire('click', { target: rt.assign });
assert.equal(rt.admin.value, '44');
assert.equal(rt.statusSelect.value, 'handling');
assert.equal(rt.document.activeElement, rt.statusSubmit);
assert.equal(rt.fetchCalls.length, 0, 'assigning only fills the form');
});
test('an overlong prereply is rejected without changing the editor; a fitting one is inserted', () => {
const rt = makeRuntime();
rt.prereply.value = '01234567890123456789012345';
rt.root.fire('change', { target: rt.prereply });
assert.equal(rt.replyText.value, '');
assert.equal(rt.feedback.querySelector('p').textContent, strings.tooLong);
assert.equal(rt.prereply.value, '');
rt.prereply.value = 'short';
rt.root.fire('change', { target: rt.prereply });
assert.equal(rt.replyText.value, 'short');
assert.equal(rt.replyCount.textContent, '5 / 24');
});
test('leaving with unsaved input is guarded; a clean page leaves freely', () => {
const rt = makeRuntime();
assert.equal(unload(rt).defaultPrevented, false);
type(rt, rt.replyText, 'draft');
assert.equal(unload(rt).defaultPrevented, true);
});