fix(admin): show lead refresh progress and prevent stale responses
This commit is contained in:
parent
b0e9da35e7
commit
d8e7272800
@ -29,6 +29,7 @@ func pageParams(r *http.Request) (limit, offset int) {
|
|||||||
// ---------- Заявки ----------
|
// ---------- Заявки ----------
|
||||||
|
|
||||||
func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
func (a *API) adminLeads(w http.ResponseWriter, r *http.Request, u *storage.AdminUser) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
limit, offset := pageParams(r)
|
limit, offset := pageParams(r)
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
var from, to *time.Time
|
var from, to *time.Time
|
||||||
|
|||||||
20
internal/api/admin_leads_refresh_test.go
Normal file
20
internal/api/admin_leads_refresh_test.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminLeadsResponseIsNotCacheable(t *testing.T) {
|
||||||
|
// Invalid dates return before storage access; no database or patient data needed.
|
||||||
|
a := &API{}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
a.adminLeads(w, httptest.NewRequest(http.MethodGet, "/api/v1/admin/leads?from=invalid", nil), nil)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Fatalf("Cache-Control = %q, want no-store", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -6,7 +6,7 @@
|
|||||||
<title>DANA — админка</title>
|
<title>DANA — админка</title>
|
||||||
<link rel="stylesheet" href="admin.css?v=20261014-share">
|
<link rel="stylesheet" href="admin.css?v=20261014-share">
|
||||||
<link rel="stylesheet" href="weekly-experiment.css?v=20261005-corners2">
|
<link rel="stylesheet" href="weekly-experiment.css?v=20261005-corners2">
|
||||||
<link rel="stylesheet" href="leads.css">
|
<link rel="stylesheet" href="leads.css?v=20261023-refresh">
|
||||||
<link rel="stylesheet" href="onec.css">
|
<link rel="stylesheet" href="onec.css">
|
||||||
<link rel="stylesheet" href="reports-history.css">
|
<link rel="stylesheet" href="reports-history.css">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
@ -93,8 +93,9 @@
|
|||||||
<label class="ld-search"><span aria-hidden="true">⌕</span><input type="search" id="ld-q" placeholder="Поиск по имени, телефону или сообщению…" aria-label="Поиск заявок"></label>
|
<label class="ld-search"><span aria-hidden="true">⌕</span><input type="search" id="ld-q" placeholder="Поиск по имени, телефону или сообщению…" aria-label="Поиск заявок"></label>
|
||||||
<div class="ld-date"><label><span>С</span><input id="ld-from" type="date" aria-label="Дата с"></label><label><span>По</span><input id="ld-to" type="date" aria-label="Дата по"></label></div>
|
<div class="ld-date"><label><span>С</span><input id="ld-from" type="date" aria-label="Дата с"></label><label><span>По</span><input id="ld-to" type="date" aria-label="Дата по"></label></div>
|
||||||
<button class="ld-export" id="ld-export" type="button" title="Экспортировать показанные заявки в CSV"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3"/></svg><span>Экспорт страницы</span></button>
|
<button class="ld-export" id="ld-export" type="button" title="Экспортировать показанные заявки в CSV"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v12m-4-4 4 4 4-4M4 17v3h16v-3"/></svg><span>Экспорт страницы</span></button>
|
||||||
<button class="ld-refresh" id="ld-refresh" type="button" aria-label="Обновить заявки" title="Обновить заявки">↻</button>
|
<button class="ld-refresh" id="ld-refresh" type="button" aria-label="Обновить заявки" title="Обновить заявки"><span aria-hidden="true">↻</span></button>
|
||||||
</div>
|
</div>
|
||||||
|
<p id="ld-updated" class="ld-updated" role="status" aria-live="polite"></p>
|
||||||
<div class="ld-table-wrap"><table class="ld-table">
|
<div class="ld-table-wrap"><table class="ld-table">
|
||||||
<thead><tr><th>Дата</th><th>Имя</th><th>Телефон</th><th>Форма</th><th>Сообщение</th><th>Статус</th><th><span class="visually-hidden">Действие</span></th></tr></thead>
|
<thead><tr><th>Дата</th><th>Имя</th><th>Телефон</th><th>Форма</th><th>Сообщение</th><th>Статус</th><th><span class="visually-hidden">Действие</span></th></tr></thead>
|
||||||
<tbody id="ld-body"></tbody>
|
<tbody id="ld-body"></tbody>
|
||||||
@ -2408,7 +2409,7 @@ function debounce(fn, ms) {
|
|||||||
|
|
||||||
</script>
|
</script>
|
||||||
<script src="dashboard.js"></script>
|
<script src="dashboard.js"></script>
|
||||||
<script src="leads.js?v=20261014-wish"></script>
|
<script src="leads.js?v=20261023-refresh"></script>
|
||||||
<script src="doctors.js?v=20261006-rating"></script>
|
<script src="doctors.js?v=20261006-rating"></script>
|
||||||
<script src="doctors-dash.js"></script>
|
<script src="doctors-dash.js"></script>
|
||||||
<script src="banners.js"></script>
|
<script src="banners.js"></script>
|
||||||
|
|||||||
@ -43,6 +43,14 @@ body.leads-view .page-title, body.leads-view .page-sub, body.leads-view .subtabs
|
|||||||
.ld-export svg { width: 21px; height: 21px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
.ld-export svg { width: 21px; height: 21px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
.ld-export:disabled { opacity: .5; cursor: not-allowed; }
|
.ld-export:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
.ld-refresh { color: #324f70; background: #f2f7ff; border: 1px solid #d7e4f5; font-size: 26px; }
|
.ld-refresh { color: #324f70; background: #f2f7ff; border: 1px solid #d7e4f5; font-size: 26px; }
|
||||||
|
.ld-refresh span { display: inline-block; }
|
||||||
|
.ld-refresh[aria-busy="true"] span { animation: ld-refresh-spin .8s linear infinite; }
|
||||||
|
.ld-refresh:disabled { opacity: .65; cursor: wait; }
|
||||||
|
@keyframes ld-refresh-spin { to { transform: rotate(360deg); } }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .ld-refresh[aria-busy="true"] span { animation: none; } }
|
||||||
|
.ld-updated { margin: -9px 0 13px; color: #637891; font-size: 12px; }
|
||||||
|
.ld-updated:empty { display: none; }
|
||||||
|
.ld-updated--error { padding: 10px 12px; color: #92291f; background: #ffe3df; border-radius: 9px; }
|
||||||
.ld-table-wrap { overflow-x: auto; border: 1px solid #e3edfa; border-radius: 11px; }
|
.ld-table-wrap { overflow-x: auto; border: 1px solid #e3edfa; border-radius: 11px; }
|
||||||
.ld-table { min-width: 830px; }
|
.ld-table { min-width: 830px; }
|
||||||
.ld-table th { padding: 13px 19px; background: #f2f7ff; color: #526782; font-size: 12px; font-weight: 600; }
|
.ld-table th { padding: 13px 19px; background: #f2f7ff; color: #526782; font-size: 12px; font-weight: 600; }
|
||||||
|
|||||||
@ -2,17 +2,52 @@
|
|||||||
Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */
|
Просмотр списка — доступ к персональным данным, сервер пишет его в журнал. */
|
||||||
|
|
||||||
const Leads = {
|
const Leads = {
|
||||||
offset: 0, limit: 30, total: 0, loaded: false, visible: [],
|
offset: 0, limit: 30, total: 0, loaded: false, visible: [], requestId: 0, controller: null,
|
||||||
|
|
||||||
async load() {
|
async load({ notify = false } = {}) {
|
||||||
|
const requestId = ++Leads.requestId;
|
||||||
|
Leads.controller?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
Leads.controller = controller;
|
||||||
|
const refresh = $('#ld-refresh'), updated = $('#ld-updated');
|
||||||
|
refresh.disabled = true;
|
||||||
|
refresh.setAttribute('aria-busy', 'true');
|
||||||
|
$('#ld-body').setAttribute('aria-busy', 'true');
|
||||||
|
updated.classList.remove('ld-updated--error');
|
||||||
|
updated.textContent = 'Обновляем заявки…';
|
||||||
|
let timedOut = false;
|
||||||
|
const timeout = setTimeout(() => { timedOut = true; controller.abort(); }, 20000);
|
||||||
const status = $('#ld-status').value;
|
const status = $('#ld-status').value;
|
||||||
const params = new URLSearchParams({ status, q: $('#ld-q').value.trim(), limit: Leads.limit, offset: Leads.offset });
|
const params = new URLSearchParams({ status, q: $('#ld-q').value.trim(), limit: Leads.limit, offset: Leads.offset });
|
||||||
if ($('#ld-from').value) params.set('from', $('#ld-from').value);
|
if ($('#ld-from').value) params.set('from', $('#ld-from').value);
|
||||||
if ($('#ld-to').value) params.set('to', $('#ld-to').value);
|
if ($('#ld-to').value) params.set('to', $('#ld-to').value);
|
||||||
let r;
|
|
||||||
try {
|
try {
|
||||||
r = await api(`/admin/leads?${params}`);
|
const r = await api(`/admin/leads?${params}`, { signal: controller.signal, cache: 'no-store' });
|
||||||
} catch (e) { toast(e.message, true); return; }
|
if (requestId !== Leads.requestId || controller.signal.aborted) return false;
|
||||||
|
Leads.render(r, status);
|
||||||
|
Leads.loaded = true;
|
||||||
|
updated.textContent = 'Обновлено в ' + new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||||
|
if (notify) toast('Список заявок обновлён');
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
if (requestId !== Leads.requestId) return false;
|
||||||
|
const message = timedOut ? 'Истекло время ожидания. Попробуйте обновить заявки ещё раз.' : e.message;
|
||||||
|
updated.classList.add('ld-updated--error');
|
||||||
|
updated.textContent = `Не удалось обновить заявки. ${message}${Leads.loaded ? ' Показаны предыдущие данные.' : ''}`;
|
||||||
|
toast(message, true);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
if (requestId === Leads.requestId) {
|
||||||
|
Leads.controller = null;
|
||||||
|
refresh.disabled = false;
|
||||||
|
refresh.setAttribute('aria-busy', 'false');
|
||||||
|
$('#ld-body').setAttribute('aria-busy', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
render(r, status) {
|
||||||
Leads.total = r.meta?.total || 0;
|
Leads.total = r.meta?.total || 0;
|
||||||
const counts = r.meta?.counts || {};
|
const counts = r.meta?.counts || {};
|
||||||
for (const [id, value] of [['ld-new', counts.new || 0], ['ld-new-badge', counts.new || 0],
|
for (const [id, value] of [['ld-new', counts.new || 0], ['ld-new-badge', counts.new || 0],
|
||||||
@ -148,7 +183,7 @@ $('#ld-dlg').addEventListener('click', (e) => { if (e.target === e.currentTarget
|
|||||||
$('#ld-status').onchange = () => { Leads.offset = 0; Leads.load(); };
|
$('#ld-status').onchange = () => { Leads.offset = 0; Leads.load(); };
|
||||||
$('#ld-q').addEventListener('input', debounce(() => { Leads.offset = 0; Leads.load(); }, 300));
|
$('#ld-q').addEventListener('input', debounce(() => { Leads.offset = 0; Leads.load(); }, 300));
|
||||||
for (const id of ['ld-from', 'ld-to']) $('#' + id).onchange = () => { Leads.offset = 0; Leads.load(); };
|
for (const id of ['ld-from', 'ld-to']) $('#' + id).onchange = () => { Leads.offset = 0; Leads.load(); };
|
||||||
$('#ld-refresh').onclick = () => Leads.load();
|
$('#ld-refresh').onclick = () => { Leads.offset = 0; return Leads.load({ notify: true }); };
|
||||||
$('#ld-export').onclick = () => Leads.exportVisible();
|
$('#ld-export').onclick = () => Leads.exportVisible();
|
||||||
$('#ld-prev').onclick = () => { Leads.offset = Math.max(0, Leads.offset - Leads.limit); Leads.load(); };
|
$('#ld-prev').onclick = () => { Leads.offset = Math.max(0, Leads.offset - Leads.limit); Leads.load(); };
|
||||||
$('#ld-next').onclick = () => { Leads.offset += Leads.limit; Leads.load(); };
|
$('#ld-next').onclick = () => { Leads.offset += Leads.limit; Leads.load(); };
|
||||||
|
|||||||
82
web/scripts/test-admin-leads-refresh.mjs
Normal file
82
web/scripts/test-admin-leads-refresh.mjs
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
// Embedded admin UI with a fake API and fictional leads; never connects to a server.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { parseHTML } from 'linkedom';
|
||||||
|
const html = await readFile(new URL('../../internal/api/adminui/index.html', import.meta.url), 'utf8');
|
||||||
|
const source = await readFile(new URL('../../internal/api/adminui/leads.js', import.meta.url), 'utf8');
|
||||||
|
const { document } = parseHTML(html);
|
||||||
|
const $ = (selector) => document.querySelector(selector);
|
||||||
|
const select = $('#ld-status');
|
||||||
|
Object.defineProperty(select, 'options', { get: () => [...select.querySelectorAll('option')] });
|
||||||
|
function selectStatus(status) { for (const option of select.options) { if (option.value === status) option.setAttribute('selected', ''); else option.removeAttribute('selected'); } }
|
||||||
|
selectStatus('new');
|
||||||
|
const pending = [], notifications = [], timers = new Map();
|
||||||
|
let timerId = 0;
|
||||||
|
const context = vm.createContext({ document, $, URLSearchParams, AbortController, Date, Intl, console,
|
||||||
|
el: (tag, props = {}) => Object.assign(document.createElement(tag), props),
|
||||||
|
api: (path, options) => new Promise((resolve, reject) => pending.push({ path, options, resolve, reject })),
|
||||||
|
toast: (message, bad) => notifications.push({ message, bad }),
|
||||||
|
debounce: (fn) => fn, plural: () => 'заявок',
|
||||||
|
LEAD_STATUS: { new: ['Новая', 'blue'], done: ['Обработана', 'green'] },
|
||||||
|
setTimeout: (fn) => { const id = ++timerId; timers.set(id, fn); return id; },
|
||||||
|
clearTimeout: (id) => timers.delete(id),
|
||||||
|
});
|
||||||
|
vm.runInContext(source + '\nglobalThis.testLeads = Leads;', context);
|
||||||
|
const leads = context.testLeads;
|
||||||
|
const response = (name = 'Тестовая заявка') => ({ data: [{ id: 1, created_at: '2026-10-23T10:00:00+03:00', name, phone: '+79990000000', status: 'new', form_title: 'Тестовая форма', message: 'Вымышленные данные' }], meta: { total: 1, counts: { new: 1 }, today: 1 } });
|
||||||
|
leads.offset = 60;
|
||||||
|
const first = $('#ld-refresh').onclick();
|
||||||
|
assert.equal($('#ld-refresh').disabled, true, 'Refresh must visibly enter loading state');
|
||||||
|
assert.equal($('#ld-refresh').getAttribute('aria-busy'), 'true');
|
||||||
|
assert.match($('#ld-updated').textContent, /Обновляем/);
|
||||||
|
assert.equal(new URLSearchParams(pending[0].path.split('?')[1]).get('offset'), '0', 'Manual refresh returns to newest results');
|
||||||
|
assert.equal(pending[0].options.cache, 'no-store');
|
||||||
|
pending[0].resolve(response());
|
||||||
|
await first;
|
||||||
|
assert.match($('#ld-body').textContent, /Тестовая заявка/);
|
||||||
|
assert.equal($('#ld-refresh').disabled, false);
|
||||||
|
assert.match($('#ld-updated').textContent, /Обновлено/);
|
||||||
|
assert.ok(notifications.some((n) => n.message === 'Список заявок обновлён'));
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
|
||||||
|
// Filters win over an older request even if a transport ignores cancellation.
|
||||||
|
const older = leads.load();
|
||||||
|
selectStatus('done');
|
||||||
|
const newer = leads.load();
|
||||||
|
assert.equal(pending[1].options.signal.aborted, true);
|
||||||
|
pending[2].resolve(response('Свежий ответ'));
|
||||||
|
await newer;
|
||||||
|
pending[1].resolve(response('УСТАРЕВШИЙ ответ'));
|
||||||
|
await older;
|
||||||
|
assert.match($('#ld-body').textContent, /Свежий ответ/);
|
||||||
|
assert.doesNotMatch($('#ld-body').textContent, /УСТАРЕВШИЙ/);
|
||||||
|
|
||||||
|
// Preserve filters, but reset pagination for an explicit refresh.
|
||||||
|
$('#ld-q').value = 'проверка'; $('#ld-from').value = '2026-10-01'; $('#ld-to').value = '2026-10-23';
|
||||||
|
leads.offset = 30;
|
||||||
|
const refresh = $('#ld-refresh').onclick();
|
||||||
|
const params = new URLSearchParams(pending[3].path.split('?')[1]);
|
||||||
|
assert.equal(params.get('status'), 'done'); assert.equal(params.get('q'), 'проверка');
|
||||||
|
assert.equal(params.get('from'), '2026-10-01'); assert.equal(params.get('to'), '2026-10-23');
|
||||||
|
pending[3].reject(new Error('Тестовая ошибка сервера'));
|
||||||
|
await refresh;
|
||||||
|
assert.equal($('#ld-refresh').disabled, false, 'Errors must release the button');
|
||||||
|
assert.match($('#ld-updated').textContent, /Не удалось/);
|
||||||
|
assert.match($('#ld-body').textContent, /Свежий ответ/, 'Failure must not erase the previous results');
|
||||||
|
assert.ok(notifications.some((n) => n.bad && n.message.includes('Тестовая ошибка')));
|
||||||
|
|
||||||
|
const retry = $('#ld-refresh').onclick();
|
||||||
|
pending.at(-1).resolve({ data: [], meta: { total: 0, counts: {} } });
|
||||||
|
await retry;
|
||||||
|
assert.match($('#ld-body').textContent, /Заявки не найдены/);
|
||||||
|
assert.equal($('#ld-refresh').disabled, false);
|
||||||
|
|
||||||
|
const timeout = $('#ld-refresh').onclick();
|
||||||
|
for (const fn of [...timers.values()]) fn();
|
||||||
|
pending.at(-1).reject(Object.assign(new Error('Aborted'), { name: 'AbortError' }));
|
||||||
|
await timeout;
|
||||||
|
assert.equal($('#ld-refresh').disabled, false, 'Timeout cannot leave refresh stuck');
|
||||||
|
assert.match($('#ld-updated').textContent, /время ожидания/i);
|
||||||
|
assert.equal(timers.size, 0);
|
||||||
|
console.log('PASS: actual refresh handler, loading/success/error/timeout feedback, no-store requests, first page, retained filters and stale-response protection; no live lead changes');
|
||||||
Loading…
Reference in New Issue
Block a user