feat(admin): open ticket creation in a dialog on the list page

Move "代客提交工单" back into a native <dialog> on the ticket list while
the ticket workspace stays a full page. Closing asks before discarding
input, ignores backdrop clicks, resets the form and returns focus; an
uncertain create result keeps the form locked behind "重新加载".

Remove the ?view=new create page route and its styles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Fable 5 committed 2026-10-31 14:57:35 +08:00
1 parent d86a85e6a6
commit 54cc5301c3
9 files changed
+169 -65

No files matched your search

+3 -4
View File
@@ -135,16 +135,15 @@ namespace {
$closed = render_page($page, ['id' => 99]);
$missing = render_page($page, ['id' => 404]);
$detail_list_queries = count($repo::$queries) - $list_queries;
$create = render_page($page, ['view' => 'new', 'status' => 'pending', 'paged' => 2]);
$bogus_notice = render_page($page, ['id' => 21, 'aksrt_ticket_msg' => '<script>']);
\AKSRT\Ticket\TicketTypeService::$empty = true;
$no_types = render_page($page, ['view' => 'new']);
$no_types = render_page($page, []);
\AKSRT\Ticket\TicketTypeService::$empty = false;
\AKSRT\Ticket\TicketSettings::$orders = false;
$no_orders = render_page($page, ['view' => 'new']);
$no_orders = render_page($page, []);
$GLOBALS['wpdb'] = new FakeDb();
$query = new QueryProbe();
$where = $query->where([]);
$statistic = $query->counts(['status' => ['pending', 'handling', 'closed', 'archived'], 'ticket_type_id' => 1, 'keywords' => 'needle']);
echo json_encode(compact('list', 'initial', 'open', 'closed', 'missing', 'detail_list_queries', 'create', 'bogus_notice', 'no_types', 'no_orders', 'where', 'statistic') + ['count_sql' => $GLOBALS['wpdb']->sql, 'count_params' => $GLOBALS['wpdb']->params], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
echo json_encode(compact('list', 'initial', 'open', 'closed', 'missing', 'detail_list_queries', 'bogus_notice', 'no_types', 'no_orders', 'where', 'statistic') + ['count_sql' => $GLOBALS['wpdb']->sql, 'count_params' => $GLOBALS['wpdb']->params], JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
+13 -12
View File
@@ -13,12 +13,13 @@ function rendered() {
return fixture;
}
test('list view links to page views and renders no dialogs or eager conversations', () => {
test('list view links rows to the workspace and offers creation only as a dialog', () => {
const { list, initial } = rendered();
assert.doesNotMatch(list, /<dialog|data-aksrt-dialog-open|aria-haspopup="dialog"/);
assert.match(list, /aksrt-native-header__actions[^]*href="[^"]*page=aksrt_tickets&amp;view=new&amp;status=handling&amp;ticket_type_id=1&amp;s=needle&amp;paged=2"/);
assert.equal((list.match(/<dialog /g) || []).length, 1, 'only the create dialog');
assert.match(list, /aksrt-native-header__actions[^]*data-aksrt-dialog-open="aksrt-ticket-create-dialog"/);
assert.doesNotMatch(list, /view=new/);
assert.match(list, /<a class="row-title" href="[^"]*page=aksrt_tickets&amp;id=21&amp;status=handling&amp;ticket_type_id=1&amp;s=needle&amp;paged=2"/);
assert.doesNotMatch(list, /data-aksrt-ticket-detail=|data-aksrt-ticket-form=/);
assert.doesNotMatch(list, /data-aksrt-ticket-detail=|data-aksrt-ticket-form="(?:reply|status|note)"/);
assert.equal(initial.detail_reads, 0);
assert.equal(initial.reply_reads, 0);
assert.equal(initial.queries.at(-1).offset, 20);
@@ -88,14 +89,14 @@ test('missing ticket renders an empty state with a way back and no forms', () =>
assert.doesNotMatch(missing, /data-aksrt-ticket-form|data-aksrt-ticket-detail/);
});
test('create page is a full-page form that posts to REST and returns to the list context', () => {
const { create } = rendered();
assert.doesNotMatch(create, /<dialog|data-aksrt-dialog/);
assert.match(create, /<form method="post" action="https:\/\/example.test\/wp-json\/aksrt\/v1\/admin\/tickets" class="aksrt-tickets-admin__create-form" data-aksrt-ticket-form="create">/);
for (const name of ['user_id', 'ticket_type_id', 'title', 'order_number', 'content']) assert.ok(create.includes(`name="${name}"`), name);
assert.match(create, /data-aksrt-ticket-feedback/);
const back = /href="[^"]*page=aksrt_tickets&amp;status=pending&amp;paged=2"/g;
assert.equal((create.match(back) || []).length, 2, 'header back link and cancel both keep the list context');
test('create dialog is a labelled REST form with its own feedback and close controls', () => {
const { list } = rendered();
const dialog = list.match(/<dialog id="aksrt-ticket-create-dialog"[^]*?<\/dialog>/)?.[0] || '';
assert.match(dialog, /aria-labelledby="aksrt-ticket-create-title"/);
assert.match(dialog, /<form method="post" action="https:\/\/example.test\/wp-json\/aksrt\/v1\/admin\/tickets" class="aksrt-ticket-dialog__frame" data-aksrt-ticket-form="create">/);
for (const name of ['user_id', 'ticket_type_id', 'title', 'order_number', 'content']) assert.ok(dialog.includes(`name="${name}"`), name);
assert.match(dialog, /data-aksrt-ticket-feedback/);
assert.equal((dialog.match(/data-aksrt-dialog-close/g) || []).length, 2, 'header X and footer cancel');
});
test('real rendering escapes user content, quotes and metadata', () => {
+43 -3
View File
@@ -7,7 +7,7 @@ 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' };
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 = {} } = {}) {
@@ -161,12 +161,19 @@ function makeRuntime({ href = 'https://site.test/wp-admin/users.php?page=aksrt_t
nodes.noteText = el('textarea', { id: 'aksrt-ticket-note-41', name: 'content', required: '', maxlength: '24', 'data-aksrt-ticket-editor': '' }, note);
el('button', { type: 'submit' }, note);
} else {
const create = nodes.create = el('form', { action: REST, 'data-aksrt-ticket-form': 'create' }, root);
nodes.opener = el('button', { type: 'button', 'data-aksrt-dialog-open': 'aksrt-ticket-create-dialog' }, root);
const dialog = nodes.dialog = el('dialog', { id: 'aksrt-ticket-create-dialog' }, root);
dialog.open = false;
dialog.showModal = () => { dialog.open = true; };
dialog.close = () => { if (!dialog.open) return; dialog.open = false; dialog.fire('close'); };
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);
@@ -179,6 +186,9 @@ function makeRuntime({ href = 'https://site.test/wp-admin/users.php?page=aksrt_t
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 = [];
@@ -232,8 +242,38 @@ test('after the reload, stashed drafts are restored once and the result code is
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&view=new&status=pending&paged=2' });
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';
@@ -28,16 +28,22 @@ test('ticket native page registers no AJAX read; writes reuse REST', () => {
assert.match(menus, /TicketSettings::enabled\(\)/);
});
test('list, workspace and create are page views, never dialogs', () => {
test('list and workspace are page views; only creation is a dialog on the list page', () => {
assert.match(center, /use UsersMenusTicketsCreate;/);
assert.doesNotMatch(center, /UsersMenusTicketsDialogs/);
assert.ok(!fs.existsSync(path.join(root, 'src/Admin/Native/UsersMenusTicketsDialogs.php')));
assert.match(center, /render_ticket_detail_page\(\$id,/);
assert.match(center, /\$view === 'new'/);
assert.match(list, /'url' => \$this->ticket_page_url\(\['view' => 'new'\]/);
assert.doesNotMatch(center + list + js, /view=new|'view' => 'new'|\$view === 'new'|render_ticket_create_page/);
assert.match(list, /'dialog' => self::TICKET_CREATE_DIALOG/);
assert.match(list, /\$this->render_ticket_create_dialog\(\);/);
assert.match(list, /ticket_page_url\(\['id' => \$id\] \+ \$context\)/);
assert.match(create, /<dialog id="' \. esc_attr\(self::TICKET_CREATE_DIALOG\)/);
assert.match(create, /ticket_form_open\('create', 0,/);
assert.doesNotMatch(center + list + detail + create + js + css, /<dialog|showModal|data-aksrt-dialog|aksrt-ticket-dialog|aksrt-ticket-modal-open/);
assert.match(js, /dialog\.showModal\(\)/);
assert.match(js, /window\.confirm\(strings\.discard\)/);
// 处理工作区不得回到弹窗。
assert.doesNotMatch(detail, /<dialog|data-aksrt-dialog/);
assert.doesNotMatch(js + css, /aksrt-ticket-detail-dialog|aksrt-ticket-modal-open|data-aksrt-ticket-panel/);
});
test('detail lookup is independent of the filtered list; paging clamps and preserves filters', () => {
@@ -78,7 +84,7 @@ test('page safety: unsaved-change guard, drafts survive the post-save reload, un
assert.match(css, /:focus-visible/);
assert.match(css, /@media \(prefers-reduced-motion: reduce\)/);
assert.match(css, /@media \(max-width: 600px\)/);
assert.doesNotMatch(css, /detail-row|row\.is-open|::backdrop/);
assert.doesNotMatch(css, /detail-row|row\.is-open/);
});
test('configuration remains in the SPA; native ops has no old config tabs', () => {