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>
399 lines
20 KiB
JavaScript
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);
|
|
});
|