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:
1 parent
d86a85e6a6
commit
54cc5301c3
9 files changed
+169
-65
No files matched your search
@@ -22,7 +22,7 @@
|
||||
.aksrt-ticket-user img { flex: none; border-radius: 50%; }
|
||||
.aksrt-ticket-user span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* 工作区与创建页共用:页头、提交反馈与表单控件。 */
|
||||
/* 工作区与代客提交弹窗共用:页头、提交反馈与表单控件。 */
|
||||
.aksrt-tickets-admin__detail-header .aksrt-native-header__title { overflow-wrap: anywhere; }
|
||||
.aksrt-tickets-admin__detail-header .aksrt-native-header__desc { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.aksrt-tickets-admin [hidden] { display: none !important; }
|
||||
@@ -34,8 +34,21 @@
|
||||
.aksrt-ticket-feedback p { margin: 0; }
|
||||
.aksrt-ticket-feedback.is-error { color: #a61b36; background: #fdf0f2; border-color: #f6d3da; }
|
||||
.aksrt-ticket-feedback .button { flex: none; }
|
||||
.aksrt-tickets-admin__create { max-width: 880px; }
|
||||
.aksrt-tickets-admin__create .aksrt-native-form-foot .aksrt-ticket-hint { margin-left: auto; }
|
||||
|
||||
/* 代客提交:原生 top layer 弹窗,头尾固定,仅正文滚动。 */
|
||||
.aksrt-ticket-dialog { box-sizing: border-box; width: min(720px, calc(100vw - 48px)); max-width: none; max-height: calc(100dvh - 48px); margin: auto; padding: 0; color: var(--an-text); background: var(--an-card); border: 0; border-radius: var(--an-radius); box-shadow: 0 12px 32px rgba(15, 23, 42, .12), 0 32px 80px rgba(15, 23, 42, .16); overflow: hidden; font: inherit; }
|
||||
.aksrt-ticket-dialog:not([open]) { display: none; }
|
||||
.aksrt-ticket-dialog::backdrop { background: rgba(15, 23, 42, .45); }
|
||||
.aksrt-ticket-dialog__frame { display: flex; flex-direction: column; max-height: calc(100dvh - 48px); margin: 0; }
|
||||
.aksrt-ticket-dialog__head { display: flex; flex: none; align-items: flex-start; justify-content: space-between; gap: 20px; padding: 18px 24px; border-bottom: 1px solid var(--an-border-soft); }
|
||||
.aksrt-ticket-dialog__head h2 { margin: 0; font-size: 18px; font-weight: 600; line-height: 1.5; }
|
||||
.aksrt-ticket-dialog__head h2:focus { outline: 0; }
|
||||
.aksrt-ticket-dialog__head p { margin: 4px 0 0; color: var(--an-muted); font-size: 12px; }
|
||||
.aksrt-ticket-dialog__close { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 32px; height: 32px; margin: -2px -6px 0 0; padding: 0; color: var(--an-muted); background: transparent; border: 0; border-radius: 8px; cursor: pointer; }
|
||||
.aksrt-ticket-dialog__close:hover { color: var(--an-text); background: var(--an-bg-soft); }
|
||||
.aksrt-ticket-dialog__body { min-height: 0; padding: 20px 24px; overflow-y: auto; overscroll-behavior: contain; }
|
||||
.aksrt-ticket-dialog__foot { display: flex; flex: none; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px; padding: 14px 24px; border-top: 1px solid var(--an-border-soft); }
|
||||
.aksrt-ticket-dialog__foot > .aksrt-ticket-hint { margin-right: auto; }
|
||||
|
||||
/* 处理工作区:会话与回复在左,处理信息与内部备注在右。 */
|
||||
.aksrt-ticket-detail { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; align-items: start; }
|
||||
@@ -142,7 +155,12 @@
|
||||
.aksrt-ticket-detail__avatar { width: 28px; height: 28px; }
|
||||
.aksrt-ticket-composer__bar select { flex-basis: 100%; max-width: 100%; }
|
||||
.aksrt-ticket-feedback { align-items: flex-start; flex-direction: column; }
|
||||
.aksrt-tickets-admin__create .aksrt-native-form-foot .aksrt-ticket-hint { flex-basis: 100%; margin-left: 0; }
|
||||
.aksrt-ticket-dialog { width: calc(100vw - 16px); max-height: calc(100dvh - 16px); }
|
||||
.aksrt-ticket-dialog__frame { max-height: calc(100dvh - 16px); }
|
||||
.aksrt-ticket-dialog__head,
|
||||
.aksrt-ticket-dialog__body,
|
||||
.aksrt-ticket-dialog__foot { padding: 12px 14px; }
|
||||
.aksrt-ticket-dialog__foot > .aksrt-ticket-hint { flex-basis: 100%; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.aksrt-tickets-admin *, .aksrt-tickets-admin *::before { scroll-behavior: auto !important; transition: none !important; animation: none !important; }
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** 工单后台:列表 / 处理工作区 / 代客提交均为页内视图;写操作只使用工单 REST 接口,成功后整页跳转刷新。 */
|
||||
/** 工单后台:列表与处理工作区为页内视图,代客提交为列表页上的原生 dialog;写操作只使用工单 REST 接口,成功后整页跳转刷新。 */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
if (!root || !config) return;
|
||||
|
||||
var strings = config.strings;
|
||||
var dialog = document.getElementById('aksrt-ticket-create-dialog');
|
||||
var opener = null;
|
||||
var feedbackBox = root.querySelector('[data-aksrt-ticket-feedback]');
|
||||
var detail = root.querySelector('[data-aksrt-ticket-detail]');
|
||||
var ticketId = detail ? parseInt(detail.dataset.aksrtTicketDetail, 10) : 0;
|
||||
@@ -133,7 +135,6 @@
|
||||
/** 成功后跳回工作区并带结果码;未提交的其它表单草稿暂存到 sessionStorage,加载后恢复。 */
|
||||
function go(id, kind, remaining) {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.delete('view');
|
||||
url.searchParams.set('id', String(id));
|
||||
url.searchParams.set('aksrt_ticket_msg', kind);
|
||||
url.hash = '';
|
||||
@@ -176,12 +177,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
/** 关闭代客提交弹窗:保存中不可关;有未保存输入时先确认。不响应遮罩点击,避免误关丢失输入。 */
|
||||
function closeDialog() {
|
||||
if (saving || !dialog.open) return;
|
||||
if (!stale && Object.keys(drafts()).length && !window.confirm(strings.discard)) return;
|
||||
dialog.close();
|
||||
}
|
||||
|
||||
if (dialog) {
|
||||
dialog.addEventListener('cancel', function (event) {
|
||||
event.preventDefault();
|
||||
closeDialog();
|
||||
});
|
||||
dialog.addEventListener('close', function () {
|
||||
var form = dialog.querySelector('[data-aksrt-ticket-form]');
|
||||
if (form && !stale) {
|
||||
form.reset();
|
||||
baselines.set(form, values(form));
|
||||
form.querySelectorAll('[name]').forEach(validateText);
|
||||
form.querySelectorAll('[data-aksrt-ticket-editor]').forEach(counter);
|
||||
feedback('', false, false);
|
||||
}
|
||||
if (opener && opener.isConnected) opener.focus();
|
||||
});
|
||||
}
|
||||
|
||||
root.addEventListener('click', function (event) {
|
||||
if (event.target.closest('[data-aksrt-ticket-reload]')) {
|
||||
leaving = true;
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (dialog && event.target.closest('[data-aksrt-dialog-close]')) {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
var open = dialog && event.target.closest('[data-aksrt-dialog-open="' + dialog.id + '"]');
|
||||
if (open) {
|
||||
if (saving) return;
|
||||
opener = open;
|
||||
// 结果未确认时保留「重新加载」提示,重新打开也不能再提交。
|
||||
if (!stale) feedback('', false, false);
|
||||
dialog.showModal();
|
||||
return;
|
||||
}
|
||||
if (saving || stale) return;
|
||||
var assign = event.target.closest('[data-aksrt-ticket-assign]');
|
||||
if (assign) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* 工单管理:列表、处理工作区(?id=)与代客提交(?view=new)三个页内视图。
|
||||
* 工单管理:列表(含代客提交弹窗)与处理工作区(?id=)两个页内视图。
|
||||
* 写操作统一使用 TicketRestAdmin;此处只负责服务端渲染。
|
||||
*
|
||||
* @package AKSRT
|
||||
@@ -46,6 +46,7 @@ trait UsersMenusTicketsCenter {
|
||||
'uncertain' => __('连接中断,提交结果尚未确认。请先重新加载核对,避免重复提交。', 'aksrt'),
|
||||
'required' => __('请填写内容,不能只包含空格。', 'aksrt'),
|
||||
'tooLong' => __('插入后将超过字数限制,请先精简回复内容。', 'aksrt'),
|
||||
'discard' => __('有尚未保存的内容。关闭后将丢弃,是否继续?', 'aksrt'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
@@ -55,12 +56,9 @@ trait UsersMenusTicketsCenter {
|
||||
$filters = $this->ticket_filters($_GET);
|
||||
$paged = max(1, absint($_GET['paged'] ?? 1));
|
||||
$id = absint($_GET['id'] ?? 0);
|
||||
$view = isset($_GET['view']) && is_scalar($_GET['view']) ? sanitize_key((string) wp_unslash($_GET['view'])) : '';
|
||||
NativeUi::page_open('aksrt-tickets-admin');
|
||||
if ($id > 0) {
|
||||
$this->render_ticket_detail_page($id, $this->ticket_context($filters, $paged));
|
||||
} elseif ($view === 'new') {
|
||||
$this->render_ticket_create_page($this->ticket_context($filters, $paged));
|
||||
} else {
|
||||
$this->render_ticket_list_page($filters, $paged);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<?php
|
||||
/** 代客提交工单(?view=new):独立页内表单,创建成功后跳转到新工单的工作区。@package AKSRT */
|
||||
/** 代客提交工单:列表页上的原生 <dialog>,创建成功后跳转到新工单的工作区。@package AKSRT */
|
||||
|
||||
namespace AKSRT\Admin\Native;
|
||||
|
||||
@@ -10,40 +10,42 @@ defined('ABSPATH') || exit;
|
||||
|
||||
trait UsersMenusTicketsCreate {
|
||||
|
||||
private function render_ticket_create_page(array $context): void {
|
||||
$back = $this->ticket_page_url($context);
|
||||
NativeUi::header(
|
||||
__('代客提交工单', 'aksrt'),
|
||||
__('为指定用户创建工单,创建后由你跟进,用户会收到通知。', 'aksrt'),
|
||||
'dashicons-plus-alt2',
|
||||
[['label' => __('返回工单列表', 'aksrt'), 'url' => $back, 'icon' => 'dashicons-arrow-left-alt']]
|
||||
);
|
||||
private const TICKET_CREATE_DIALOG = 'aksrt-ticket-create-dialog';
|
||||
|
||||
private function render_ticket_create_dialog(): void {
|
||||
$types = TicketTypeService::all();
|
||||
NativeUi::card_open('', '', false, 'aksrt-tickets-admin__create');
|
||||
echo '<dialog id="' . esc_attr(self::TICKET_CREATE_DIALOG) . '" class="aksrt-ticket-dialog" aria-labelledby="aksrt-ticket-create-title" aria-describedby="aksrt-ticket-create-description">';
|
||||
echo $types === [] ? '<div class="aksrt-ticket-dialog__frame">' : $this->ticket_form_open('create', 0, 'aksrt-ticket-dialog__frame'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<header class="aksrt-ticket-dialog__head"><div><h2 id="aksrt-ticket-create-title" tabindex="-1" autofocus>' . esc_html__('代客提交工单', 'aksrt') . '</h2>';
|
||||
echo '<p id="aksrt-ticket-create-description">' . esc_html__('为指定用户创建工单,创建后由你跟进,用户会收到通知。', 'aksrt') . '</p></div>';
|
||||
echo '<button type="button" class="aksrt-ticket-dialog__close" data-aksrt-dialog-close aria-label="' . esc_attr__('关闭弹窗', 'aksrt') . '"><span class="dashicons dashicons-no-alt" aria-hidden="true"></span></button></header>';
|
||||
echo '<div class="aksrt-ticket-dialog__body">' . $this->ticket_feedback(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
if ($types === []) {
|
||||
echo NativeUi::empty_state(__('尚未配置工单部门', 'aksrt'), __('请先添加部门,再为用户创建工单。', 'aksrt')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<p><a class="button" href="' . esc_url(admin_url('admin.php?page=aksrt-settings#/users/ticket')) . '">' . esc_html__('前往工单配置', 'aksrt') . '</a></p>';
|
||||
NativeUi::card_close();
|
||||
return;
|
||||
} else {
|
||||
echo '<div class="aksrt-native-form-grid">';
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('用户 ID', 'aksrt') . '</span><input type="number" name="user_id" min="1" step="1" required placeholder="' . esc_attr__('填写工单归属用户的 ID', 'aksrt') . '"></label>';
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('工单部门', 'aksrt') . '</span><select name="ticket_type_id" required><option value="">' . esc_html__('请选择部门', 'aksrt') . '</option>';
|
||||
foreach ($types as $type) {
|
||||
echo '<option value="' . (int) $type['id'] . '">' . esc_html((string) $type['name']) . '</option>';
|
||||
}
|
||||
echo '</select></label>';
|
||||
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('工单标题', 'aksrt') . '</span><input type="text" name="title" maxlength="' . TicketSettings::title_max() . '" required placeholder="' . esc_attr__('简要说明用户遇到的问题', 'aksrt') . '"></label>';
|
||||
if (TicketSettings::order_link_enabled()) {
|
||||
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('关联订单(可选)', 'aksrt') . '</span><input type="text" name="order_number" placeholder="' . esc_attr__('填写订单号', 'aksrt') . '"></label>';
|
||||
}
|
||||
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('问题描述', 'aksrt') . '</span><textarea id="aksrt-ticket-create-content" name="content" rows="7" maxlength="' . TicketSettings::content_max() . '" required data-aksrt-ticket-editor aria-describedby="aksrt-ticket-create-count" placeholder="' . esc_attr__('描述问题、发生时间和已尝试的处理方式', 'aksrt') . '"></textarea>';
|
||||
echo '<span id="aksrt-ticket-create-count" class="aksrt-ticket-counter" data-aksrt-ticket-counter="aksrt-ticket-create-content">0 / ' . TicketSettings::content_max() . '</span></label></div>';
|
||||
}
|
||||
echo $this->ticket_feedback(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo $this->ticket_form_open('create', 0, 'aksrt-tickets-admin__create-form'); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo '<div class="aksrt-native-form-grid">';
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('用户 ID', 'aksrt') . '</span><input type="number" name="user_id" min="1" step="1" required placeholder="' . esc_attr__('填写工单归属用户的 ID', 'aksrt') . '"></label>';
|
||||
echo '<label class="aksrt-native-field"><span class="aksrt-native-field__label">' . esc_html__('工单部门', 'aksrt') . '</span><select name="ticket_type_id" required><option value="">' . esc_html__('请选择部门', 'aksrt') . '</option>';
|
||||
foreach ($types as $type) {
|
||||
echo '<option value="' . (int) $type['id'] . '">' . esc_html((string) $type['name']) . '</option>';
|
||||
echo '</div><footer class="aksrt-ticket-dialog__foot">';
|
||||
if ($types !== []) {
|
||||
echo '<span class="aksrt-ticket-hint">' . esc_html__('新工单状态为「已回复」', 'aksrt') . '</span>';
|
||||
}
|
||||
echo '</select></label>';
|
||||
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('工单标题', 'aksrt') . '</span><input type="text" name="title" maxlength="' . TicketSettings::title_max() . '" required placeholder="' . esc_attr__('简要说明用户遇到的问题', 'aksrt') . '"></label>';
|
||||
if (TicketSettings::order_link_enabled()) {
|
||||
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('关联订单(可选)', 'aksrt') . '</span><input type="text" name="order_number" placeholder="' . esc_attr__('填写订单号', 'aksrt') . '"></label>';
|
||||
echo '<button type="button" class="button" data-aksrt-dialog-close>' . esc_html__('取消', 'aksrt') . '</button>';
|
||||
if ($types !== []) {
|
||||
echo '<button type="submit" class="button button-primary">' . esc_html__('创建工单并通知用户', 'aksrt') . '</button>';
|
||||
}
|
||||
echo '<label class="aksrt-native-field is-wide"><span class="aksrt-native-field__label">' . esc_html__('问题描述', 'aksrt') . '</span><textarea id="aksrt-ticket-create-content" name="content" rows="8" maxlength="' . TicketSettings::content_max() . '" required data-aksrt-ticket-editor aria-describedby="aksrt-ticket-create-count" placeholder="' . esc_attr__('描述问题、发生时间和已尝试的处理方式', 'aksrt') . '"></textarea>';
|
||||
echo '<span id="aksrt-ticket-create-count" class="aksrt-ticket-counter" data-aksrt-ticket-counter="aksrt-ticket-create-content">0 / ' . TicketSettings::content_max() . '</span></label></div>';
|
||||
echo '<div class="aksrt-native-form-foot"><button type="submit" class="button button-primary">' . esc_html__('创建工单并通知用户', 'aksrt') . '</button>';
|
||||
echo '<a class="button" href="' . esc_url($back) . '">' . esc_html__('取消', 'aksrt') . '</a>';
|
||||
echo '<span class="aksrt-ticket-hint">' . esc_html__('新工单状态为「已回复」', 'aksrt') . '</span></div></form>';
|
||||
NativeUi::card_close();
|
||||
echo '</footer>' . ($types === [] ? '</div>' : '</form>') . '</dialog>';
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ defined('ABSPATH') || exit;
|
||||
trait UsersMenusTicketsList {
|
||||
|
||||
private function render_ticket_list_page(array $filters, int $paged): void {
|
||||
// 先查询并夹取页码,页头「代客提交」链接才能带上实际页码。
|
||||
// 先查询并夹取页码,创建成功后跳转的工作区链接才能带上实际页码。
|
||||
[$result, $paged] = $this->ticket_list_result($filters, $paged);
|
||||
NativeUi::header(
|
||||
__('工单管理', 'aksrt'),
|
||||
@@ -21,7 +21,7 @@ trait UsersMenusTicketsList {
|
||||
[
|
||||
[
|
||||
'label' => __('代客提交工单', 'aksrt'),
|
||||
'url' => $this->ticket_page_url(['view' => 'new'] + $this->ticket_context($filters, $paged)),
|
||||
'dialog' => self::TICKET_CREATE_DIALOG,
|
||||
'primary' => true,
|
||||
'icon' => 'dashicons-plus-alt2',
|
||||
],
|
||||
@@ -33,6 +33,7 @@ trait UsersMenusTicketsList {
|
||||
]
|
||||
);
|
||||
$this->render_ticket_list($filters, $paged, $result);
|
||||
$this->render_ticket_create_dialog();
|
||||
}
|
||||
|
||||
/** 越界页码夹到末页,避免处理最后一条工单后停在空页。返回 [查询结果, 实际页码]。 */
|
||||
|
||||
@@ -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 +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&view=new&status=handling&ticket_type_id=1&s=needle&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&id=21&status=handling&ticket_type_id=1&s=needle&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&status=pending&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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user