From c92523f2350ba0301422575b9dc814b01f3a4faa Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 11 Nov 2026 14:07:05 +0300 Subject: [PATCH 1/4] feat: show onboarding tips beside Workers fields --- README.md | 2 +- app/static/admin.css | 70 ++++++- app/static/guide.js | 233 ++++++++++++++++++++---- app/templates/base.html | 4 +- app/templates/council.html | 4 +- app/templates/council_publications.html | 4 +- app/templates/guide.html | 2 +- app/templates/guide_dialog.html | 24 --- app/templates/guide_walkthrough.html | 24 +++ app/version.py | 6 +- pyproject.toml | 2 +- tests/browser_users.py | 77 ++++++-- tests/test_api.py | 2 +- tests/test_council.py | 2 +- tests/test_users.py | 6 +- 15 files changed, 362 insertions(+), 100 deletions(-) delete mode 100644 app/templates/guide_dialog.html create mode 100644 app/templates/guide_walkthrough.html diff --git a/README.md b/README.md index 80c56cd..e2534b8 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,6 @@ python scripts/prepare_council_articles.py app/data/council_articles.csv ### Подсказки первого входа -После первой успешной смены пароля автоматически открываются шесть шагов: навигация, поиск сотрудников и профиль, фильтры диссовета, показатели сотрудника, строка публикации, редактирование и XLSX. Подсказки можно закрыть или пропустить и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения. +После первой успешной смены пароля запускается обучение. Кнопка «Далее» открывает нужный раздел, а короткие подсказки появляются рядом с полями поиска сотрудников, фильтрами и показателями диссовета, выгрузкой XLSX и сведениями о публикациях. Обучение можно закрыть в любой момент и снова открыть через «Как пользоваться» в меню. Без JavaScript доступна полная инструкция `/admin/guide` и обычная форма завершения. Состояние хранится для учётной записи в существующей истории действий (`guide_start` / `guide_finish`), без миграции схемы. Закрытие сохраняется на сервере; повторная смена или сброс пароля не запускают обучение заново. Для существующих пользователей, уже сменивших пароль, автоматический показ не включается. POST `/admin/guide/dismiss` требует входа, смены временного пароля и CSRF. При ошибке сохранения подсказки остаются доступными для повторного завершения. diff --git a/app/static/admin.css b/app/static/admin.css index db1d93f..92b2092 100644 --- a/app/static/admin.css +++ b/app/static/admin.css @@ -990,15 +990,71 @@ .users__history-table .table__cell:nth-child(2) { min-width: 100px; white-space: nowrap; } -.guide__panel { box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; } -.guide__panel > :not([data-guide-step]) { flex-shrink: 0; } -.guide__panel .guide__step { min-height: 0; overflow-y: auto; overscroll-behavior: contain; } -.guide__progress { margin-block: 16px; color: #374151; font-variant-numeric: tabular-nums; } -.guide__heading { margin-block: 20px 12px; font-size: 18px; } +.guide { + margin-block: 16px; + padding: clamp(12px, 2vw, 20px); + border: 1px solid #99d5ce; + border-radius: 12px; + background: #f0fdfa; + color: #164e49; + scroll-margin-block: 16px; +} +.guide__header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px 20px; +} +.guide__progress { margin: 0 0 4px; color: #35615b; font-size: 13px; font-variant-numeric: tabular-nums; } +.guide__heading { margin: 0; font-size: 18px; } +.guide--page .guide__heading { margin-block: 20px 12px; } +.guide__heading:focus-visible { outline: 2px solid #0f766e; outline-offset: 4px; border-radius: 2px; } +.guide__intro { max-width: 66ch; margin: 8px 0 0; line-height: 1.5; } +.guide__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.guide__actions .button { min-height: 44px; } +.guide__field { display: grid; justify-items: start; min-width: 0; } +.guide__tip { + position: relative; + display: block; + box-sizing: border-box; + min-width: 0; + max-width: 44ch; + margin-block: 7px 2px; + padding: 7px 10px; + border-radius: 8px; + background: #e0f5f1; + color: #164e49; + font-size: 13px; + font-weight: 500; + line-height: 1.4; + text-align: start; + overflow-wrap: anywhere; +} +.guide__tip::before { + position: absolute; + inset-block-start: -4px; + inset-inline-start: 13px; + width: 8px; + height: 8px; + background: inherit; + content: ""; + transform: rotate(45deg); +} +.council__candidate-count .guide__tip { padding-inline: 6px; } +.council__search:has(.guide__tip) > .directory__field { align-self: start; } +@media (min-width: 701px) { + .council__candidate-summary:has(.guide__tip) { + grid-template-columns: minmax(0, 1fr) minmax(0, 3fr) 18px; + } +} +@media (max-width: 420px) { + .council__counts:has(.guide__tip) { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +.directory__checkbox:has(.guide__tip) { flex-wrap: wrap; } +.directory__checkbox .guide__tip { flex-basis: 100%; } .guide__fields { margin-block: 16px; } .guide__fields dt { margin-block-start: 14px; font-weight: 600; } .guide__fields dd { margin: 4px 0 0; overflow-wrap: anywhere; } -.guide__actions { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 12px; margin-block-start: 24px; } .guide--page { max-width: 75ch; } .guide--page .guide__step + .guide__step { margin-block-start: 32px; } -.guide .columns-modal__header { flex-wrap: wrap; } diff --git a/app/static/guide.js b/app/static/guide.js index 8311e91..956e92c 100644 --- a/app/static/guide.js +++ b/app/static/guide.js @@ -1,69 +1,230 @@ (() => { - const dialog = document.querySelector('[data-guide-dialog]'); - if (!dialog) return; + const guide = document.querySelector('[data-guide]'); + if (!guide) return; + + const directory = '/admin/directory'; + const council = '/admin/council'; + const publications = '/admin/council/publications'; + const steps = [ + {id: 'welcome', title: 'Познакомьтесь с Workers', + intro: 'Найдите сотрудника, подберите кандидатов в диссовет и проверьте публикации. Подсказки можно повторить из меню «Как пользоваться».'}, + {id: 'employee-search', path: directory, title: 'Найдите сотрудника', region: '.directory__filters', tips: [ + ['[name="q"]', 'Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.'], + ['[name="status"]', '«Требует проверки» означает, что профиль недоступен, а увольнение ещё не подтверждено.'], + ['[name="has_email"]', 'Чтобы найти рабочие контакты, выберите «Есть email».'], + ]}, + {id: 'employee-filters', path: directory, title: 'Уточните список', region: '.directory__filters', tips: [ + ['[name="has_academic_degree"]', 'Звание профессора или доцента само по себе не считается учёной степенью.'], + ['[name="pps"]', 'Оставьте сотрудников профессорско-преподавательского состава.'], + ['[name="started_to"]', '«От» и «До» задают период первого обнаружения профиля системой.'], + ]}, + {id: 'employee-sort', path: directory, title: 'Настройте порядок списка', region: '.directory__filters', tips: [ + ['[name="sort"]', '«Год начала» — начало работы в ВШЭ. «Впервые найден» — когда профиль обнаружила система.'], + ['[name="direction"]', 'Чтобы увидеть новые записи первыми, выберите «По убыванию».'], + ['[name="limit"]', 'Выберите число строк и нажмите «Применить фильтры».'], + ]}, + {id: 'employee-profile', path: directory, title: 'Откройте карточку сотрудника', region: '.directory__header', tips: [ + ['[data-columns-open]', 'Выберите столбцы: например, должности, контакты и число публикаций.'], + ['.directory__help', 'Нажмите на ФИО в таблице, чтобы открыть контакты, степени, публикации и другие данные профиля.'], + ]}, + {id: 'council-filters', path: council, title: 'Задайте параметры подбора', region: '.council__search', tips: [ + ['#council-branch', 'Отрасль определяет публикационный минимум для доктора наук. Варианты зависят от специальности ВАК.'], + ['#council-specialty', 'Фильтр ищет связь журнала со специальностью. Тематику отдельной статьи проверяют отдельно.'], + ['#council-search', 'Введите фамилию или часть ФИО, чтобы сузить список кандидатов.'], + ]}, + {id: 'council-sort', path: council, title: 'Отсортируйте кандидатов', region: '.council__search', tips: [ + ['[name="sort"]', 'Выберите «Подтверждено», чтобы сравнить число работ с проверенными основаниями.'], + ['[name="direction"]', 'Выберите «По убыванию» и нажмите «Применить», чтобы вывести наибольшие значения первыми.'], + ]}, + {id: 'council-counts', path: council, title: 'Прочитайте оценку сотрудника', region: '.council__list-heading', + intro: 'Нажмите на ФИО, чтобы открыть публикации и основания зачёта.', tips: [ + ['.council__candidate-identity', '«Подтверждено» — работы с проверенными видом, периодом и основанием зачёта.', 'inside'], + ['[data-guide-count="total"]', 'Уникальные работы Q1/Q2/К1/RSCI. Для докторов учитывается также К2.', 'inside'], + ['[data-guide-count="all"]', 'Публикации из ЛК и выгрузки ВШЭ за все годы, без дублей.', 'inside'], + ]}, + {id: 'council-export', path: council, title: 'Проверьте расчёт и выгрузите список', region: '.council__notice', tips: [ + ['.council__notice', 'Здесь описаны период оценки, минимумы по отраслям и правила учёта работ.'], + ['[formaction="/admin/council/export.xlsx"]', 'В XLSX попадут все найденные сотрудники и их публикации, включая другие страницы списка.'], + ]}, + {id: 'publication-search', path: publications, title: 'Найдите нужную публикацию', region: '.council__search', tips: [ + ['#publications-employee', 'Укажите фамилию или часть ФИО автора.'], + ['#publications-article', 'Введите несколько слов из названия статьи.'], + ['#publications-journal', 'Укажите часть названия журнала. Фильтры можно сочетать.'], + ]}, + {id: 'publication-check', path: publications, title: 'Проверьте сведения о статье', region: '.council__articles', + intro: 'Выберите строку статьи, чтобы открыть кнопку «Редактировать статью». С клавиатуры нажмите Enter на строке.', tips: [ + ['.council__article-heading', 'Год определяет, за какой период можно учесть работу. Кнопка «Уточнение» показывает, что нужно проверить.'], + ['[data-guide-publication="journal"]', 'Квартиль Q1–Q4 относится к году статьи. К1 и К2 — категории ВАК.'], + ['[data-guide-publication="specialties"]', 'Ручная привязка действует для всех статей журнала за все годы.'], + ], editorTips: [ + ['#article-edit-name', 'Укажите название так, как оно приведено в источнике.'], + ['#article-edit-journal', 'Начните вводить название и выберите журнал из списка, если он там есть.'], + ['#article-edit-quartile', 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.'], + ]}, + ]; + + const main = document.querySelector('#main-content'); const trigger = document.querySelector('[data-guide-open]'); - const steps = Array.from(dialog.querySelectorAll('[data-guide-step]')); - const back = dialog.querySelector('[data-guide-back]'); - const next = dialog.querySelector('[data-guide-next]'); - const close = dialog.querySelector('[data-guide-close]'); - const error = dialog.querySelector('[data-guide-error]'); - const unsaved = dialog.querySelector('[data-guide-unsaved]'); + const heading = guide.querySelector('#guide-title'); + const progress = guide.querySelector('[data-guide-progress]'); + const intro = guide.querySelector('[data-guide-intro]'); + const back = guide.querySelector('[data-guide-back]'); + const next = guide.querySelector('[data-guide-next]'); + const close = guide.querySelector('[data-guide-close]'); + const error = guide.querySelector('[data-guide-error]'); + const unsaved = guide.querySelector('[data-guide-unsaved]'); + const hashFor = step => '#guide-' + step.id; let index = 0; let saving = false; + let removeTips = []; - function showStep() { - steps.forEach((step, number) => { step.hidden = number !== index; }); - dialog.querySelector('[data-guide-progress]').textContent = `Шаг ${index + 1} из ${steps.length}`; + function addTip([selector, message, placement], id, allowHidden = false) { + const target = main.querySelector(selector); + if (!target || (!allowHidden && !target.getClientRects().length)) return; + const tip = document.createElement('span'); + tip.className = 'guide__tip'; + tip.id = 'guide-tip-' + id; + tip.textContent = message; + + const description = target.getAttribute('aria-describedby'); + target.setAttribute('aria-describedby', [description, tip.id].filter(Boolean).join(' ')); + const label = target.labels?.[0]; + let group; + if (label) { + // The description is announced separately from the label text. + tip.setAttribute('aria-hidden', 'true'); + label.append(tip); + } else if (placement === 'inside') { + target.append(tip); + } else if (target.matches('button')) { + group = document.createElement('div'); + group.className = 'guide__field'; + target.before(group); + group.append(target, tip); + } else { + target.after(tip); + } + removeTips.push(() => { + tip.remove(); + if (description === null) target.removeAttribute('aria-describedby'); + else target.setAttribute('aria-describedby', description); + if (group) group.replaceWith(target); + }); + } + + function clearTips() { + removeTips.forEach(remove => remove()); + removeTips = []; + } + + function showStep(focus = true) { + const step = steps[index]; + clearTips(); + const region = step.region && main.querySelector(step.region); + if (region) region.before(guide); + else main.prepend(guide); + heading.textContent = step.title; + progress.textContent = `Шаг ${index + 1} из ${steps.length}`; + intro.textContent = step.intro || ''; + intro.hidden = !step.intro; + error.hidden = unsaved.hidden = true; back.disabled = index === 0; next.textContent = index === steps.length - 1 ? 'Завершить подсказки' : 'Далее'; - steps[index].scrollTop = 0; - steps[index].querySelector('h3').focus({preventScroll: true}); + step.tips?.forEach((tip, number) => addTip(tip, number)); + step.editorTips?.forEach((tip, number) => addTip(tip, 'editor-' + number, true)); + guide.hidden = false; + if (focus) { + heading.focus({preventScroll: true}); + guide.scrollIntoView({block: 'start'}); + } } - function open() { - index = 0; - error.hidden = true; - unsaved.hidden = true; - dialog.showModal(); + + function goTo(number, path = steps[number].path || '/admin/account') { + const step = steps[number]; + if (path !== location.pathname) { + location.assign(path + hashFor(step)); + return; + } + index = number; + history.replaceState(history.state, '', hashFor(step)); showStep(); } + + function hide() { + clearTips(); + guide.hidden = true; + history.replaceState(history.state, '', location.pathname + location.search); + trigger?.focus(); + } + async function finish() { if (saving) return; - if (dialog.dataset.guidePending !== 'true') { - dialog.close(); + if (guide.dataset.guidePending !== 'true') { + hide(); return; } saving = true; - dialog.setAttribute('aria-busy', 'true'); + guide.setAttribute('aria-busy', 'true'); close.disabled = next.disabled = back.disabled = true; - error.hidden = true; + error.hidden = unsaved.hidden = true; try { const response = await fetch('/admin/guide/dismiss', { method: 'POST', credentials: 'same-origin', redirect: 'error', - headers: {'X-CSRF-Token': dialog.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000), + headers: {'X-CSRF-Token': guide.dataset.guideCsrf, 'Accept': 'application/json'}, signal: AbortSignal.timeout(15000), }); if (!response.ok) throw new Error(); - dialog.dataset.guidePending = 'false'; - dialog.close(); + guide.dataset.guidePending = 'false'; + hide(); } catch (_failure) { - error.textContent = 'Не удалось сохранить закрытие подсказок. Попробуйте ещё раз или продолжите без сохранения: при следующем открытии страницы подсказки могут появиться снова.'; - error.hidden = false; - unsaved.hidden = false; + error.textContent = 'Не удалось сохранить закрытие. Повторите попытку или продолжите без сохранения.'; + error.hidden = unsaved.hidden = false; + unsaved.focus(); } finally { saving = false; - dialog.removeAttribute('aria-busy'); + guide.removeAttribute('aria-busy'); close.disabled = next.disabled = false; back.disabled = index === 0; } } - trigger?.addEventListener('click', event => { event.preventDefault(); open(); }); - back.addEventListener('click', () => { index -= 1; showStep(); }); + + trigger?.addEventListener('click', event => { + if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; + event.preventDefault(); + if (saving) return; + goTo(0, location.pathname); + }); + back.addEventListener('click', () => goTo(index - 1)); next.addEventListener('click', () => { if (index === steps.length - 1) finish(); - else { index += 1; showStep(); } + else goTo(index + 1); }); close.addEventListener('click', finish); - unsaved.addEventListener('click', () => { if (!saving) dialog.close(); }); - dialog.addEventListener('cancel', event => { event.preventDefault(); finish(); }); - dialog.addEventListener('close', () => trigger?.focus()); - if (dialog.dataset.guidePending === 'true') open(); + unsaved.addEventListener('click', hide); + document.addEventListener('keydown', event => { + if (event.key !== 'Escape' || event.defaultPrevented || guide.hidden || document.querySelector('dialog[open]')) return; + event.preventDefault(); + finish(); + }); + main.addEventListener('submit', event => { + const form = event.target; + if (guide.hidden || !steps[index].path || form.method !== 'get' || event.submitter?.hasAttribute('formaction')) return; + const action = new URL(form.action); + if (action.origin !== location.origin || action.pathname !== location.pathname) return; + const original = form.getAttribute('action'); + action.hash = hashFor(steps[index]); + form.action = action; + setTimeout(() => { + if (original === null) form.removeAttribute('action'); + else form.setAttribute('action', original); + }); + }); + + const requested = steps.findIndex(step => hashFor(step) === location.hash && (!step.path || step.path === location.pathname)); + if (requested >= 0) { + index = requested; + showStep(); + } else if (guide.dataset.guidePending === 'true') { + history.replaceState(history.state, '', hashFor(steps[0])); + showStep(false); + } })(); diff --git a/app/templates/base.html b/app/templates/base.html index 27ae68e..1d74b3a 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -30,13 +30,15 @@
+ {% if current_user and not current_user.must_change_password and not guide_page|default(false) %} + {% include 'guide_walkthrough.html' %} + {% endif %} {% block content %}{% endblock %}
{% if current_user and not current_user.must_change_password and not guide_page|default(false) %} - {% include 'guide_dialog.html' %} {% endif %} {% block scripts %}{% endblock %} diff --git a/app/templates/council.html b/app/templates/council.html index 0484638..f3497a6 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -84,9 +84,9 @@ {{ candidate.q1_count }}Q1 {{ candidate.q2_count }}Q2 {{ candidate.k1_count }}К1 - {{ candidate.total_count }}ВСЕГО (предв.) + {{ candidate.total_count }}ВСЕГО (предв.) {{ candidate.db_count }}ЛК сотрудника ВШЭ - {{ candidate.count }}Все публикации + {{ candidate.count }}Все публикации diff --git a/app/templates/council_publications.html b/app/templates/council_publications.html index fec208b..c89f878 100644 --- a/app/templates/council_publications.html +++ b/app/templates/council_publications.html @@ -26,8 +26,8 @@

{{ article.employee_name }}

{{ article.title }}{{ article.year or 'Год не указан' }}{% set field = 'year' %}{% set label = 'год публикации' %}{% include 'council_warning.html' %}

{{ article.display_type }}{% set field = 'type' %}{% set label = 'тип материала' %}{% include 'council_warning.html' %}

-

Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}

-

Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}

+

Журнал: {{ article.journals|join(' / ') or 'не указан' }}{% set field = 'journal' %}{% set label = 'журнал' %}{% include 'council_warning.html' %} · Квартиль: {{ article.edit_quartile or 'не указан или неоднозначен' }}{% set field = 'quartile' %}{% set label = 'квартиль и основание зачёта' %}{% include 'council_warning.html' %}

+

Специальности журнала: {{ article.edit_specialty_codes|join(", ") or "связь не установлена" }}{% if article.manual_specialties %} · ручная связь{% endif %}

{% else %}
  • Статьи не найдены. Попробуйте изменить фильтры.
  • {% endfor %} diff --git a/app/templates/guide.html b/app/templates/guide.html index 61fb31c..f16e094 100644 --- a/app/templates/guide.html +++ b/app/templates/guide.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} {% block title %}Как пользоваться · MIEM Employees{% endblock %} {% block content %} -
    +

    Как пользоваться сайтом

    {% include 'guide_content.html' %}
    diff --git a/app/templates/guide_dialog.html b/app/templates/guide_dialog.html deleted file mode 100644 index 78897f5..0000000 --- a/app/templates/guide_dialog.html +++ /dev/null @@ -1,24 +0,0 @@ - -
    -
    -

    Как пользоваться сайтом

    - -
    -

    - {% include 'guide_content.html' %} - - -
    - - -
    -
    -
    -{% if request.state.guide_pending|default(false) %} -
    -{% endif %} diff --git a/app/templates/guide_walkthrough.html b/app/templates/guide_walkthrough.html new file mode 100644 index 0000000..746971b --- /dev/null +++ b/app/templates/guide_walkthrough.html @@ -0,0 +1,24 @@ + +{% if request.state.guide_pending|default(false) %} + +{% endif %} diff --git a/app/version.py b/app/version.py index 6a65aac..47dcf9f 100644 --- a/app/version.py +++ b/app/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "1.5.1" -FRONTEND_VERSION = "1.5.1" -BACKEND_VERSION = "1.5.1" +APP_VERSION = "1.6.0" +FRONTEND_VERSION = "1.6.0" +BACKEND_VERSION = "1.6.0" diff --git a/pyproject.toml b/pyproject.toml index 4d7c7ee..5e12cb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "miem-workers" -version = "1.5.1" +version = "1.6.0" description = "MIEM employees parser, admin API, and web admin" requires-python = ">=3.11" dependencies = [ diff --git a/tests/browser_users.py b/tests/browser_users.py index e46db73..09f0e69 100644 --- a/tests/browser_users.py +++ b/tests/browser_users.py @@ -100,16 +100,14 @@ def main(): modal.get_by_label('Новый пароль', exact=True).fill('Reader personal passphrase 123') modal.get_by_label('Повторите новый пароль', exact=True).fill('Reader personal passphrase 123') modal.get_by_role('button', name='Сохранить новый пароль', exact=True).click() - guide = page.get_by_role('dialog', name='Как пользоваться сайтом') + guide = page.locator('[data-guide]') expect(guide).to_be_visible() - expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_focused() + expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible() for width in (1440, 320): page.set_viewport_size({'width': width, 'height': 900}) assert guide.evaluate('node => node.scrollWidth <= node.clientWidth') assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') page.screenshot(path=str(output / f'guide-first-{width}.png')) - page.keyboard.press('Tab') - assert page.evaluate('document.querySelector("[data-guide-dialog]").contains(document.activeElement)') page.route('**/admin/guide/dismiss', lambda route: route.fulfill(status=503, body='Temporary failure')) guide.get_by_role('button', name='Закрыть подсказки', exact=True).click() expect(guide.locator('[data-guide-error]')).to_be_visible() @@ -126,20 +124,66 @@ def main(): expect(guide).not_to_be_visible() page.get_by_role('link', name='Как пользоваться', exact=True).click() expect(guide).to_be_visible() - guide.get_by_role('button', name='Далее', exact=True).click() - expect(guide.get_by_role('heading', name='Найдите сотрудника и откройте профиль')).to_be_visible() - guide.get_by_role('button', name='Назад', exact=True).click() - expect(guide.get_by_role('heading', name='Выберите задачу', exact=True)).to_be_visible() - for number in range(1, 7): - expect(guide.locator('[data-guide-progress]')).to_have_text(f'Шаг {number} из 6') - expect(guide.locator('[data-guide-step]:visible')).to_have_count(1) - if number == 4: - expect(guide).to_contain_text('ВСЕГО (предв.)') - page.screenshot(path=str(output / 'guide-counts-mobile.png')) - if number < 6: - guide.get_by_role('button', name='Далее', exact=True).click() + go_next = guide.get_by_role('button', name='Далее', exact=True) + go_back = guide.get_by_role('button', name='Назад', exact=True) + tips = page.locator('.guide__tip:visible') + go_next.click() + expect(guide.get_by_role('heading', name='Найдите сотрудника', exact=True)).to_be_focused() + assert page.url.endswith('/admin/directory#guide-employee-search') + expect(tips).to_have_count(3) + search = page.get_by_role('textbox', name='ФИО или ссылка', exact=True) + expect(search).to_have_accessible_description('Введите фамилию, часть ФИО или ссылку на профиль ВШЭ.') + search.fill('Сотрудник') + page.get_by_role('button', name='Применить фильтры', exact=True).click() + expect(tips).to_have_count(3) + assert page.url.endswith('#guide-employee-search') + go_back.click() + expect(guide.get_by_role('heading', name='Познакомьтесь с Workers')).to_be_visible() + for _ in range(5): + go_next.click() + assert page.url.endswith('/admin/council#guide-council-filters') + expect(tips).to_have_count(3) + for width in (1440, 320): + page.set_viewport_size({'width': width, 'height': 900}) + assert page.evaluate('''() => { + const tips = [...document.querySelectorAll('.guide__tip')]; + const boxes = tips.map(tip => tip.getBoundingClientRect()); + const fields = [...document.querySelectorAll('.council__search input:not([type=hidden]), .council__search select')]; + const overlaps = (a, b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top; + return tips.every(tip => tip.scrollWidth <= tip.clientWidth && tip.scrollHeight <= tip.clientHeight) + && boxes.every((box, i) => boxes.slice(i + 1).every(other => !overlaps(box, other)) + && fields.every(field => !overlaps(box, field.getBoundingClientRect()))) + && document.documentElement.scrollWidth <= innerWidth; + }''') + guide.evaluate("node => node.scrollIntoView({block: 'start'})") + page.screenshot(path=str(output / f'guide-council-{width}.png')) + go_back.click() + assert page.url.endswith('/admin/directory#guide-employee-profile') + go_next.click() + go_next.click() + go_next.click() + expect(tips).to_have_count(3) + expect(page.locator('[data-guide-count="total"] .guide__tip')).to_contain_text('Уникальные работы') + page.screenshot(path=str(output / 'guide-counts-mobile.png')) + go_next.click() + expect(tips).to_have_count(2) + go_next.click() + assert page.url.endswith('/admin/council/publications#guide-publication-search') + go_next.click() + expect(tips).to_have_count(3) + page.locator('[data-council-article]').click() + page.get_by_role('button', name='Редактировать статью: Статья для проверки', exact=True).click() + guide_editor = page.get_by_role('dialog', name='Редактирование статьи') + expect(guide_editor.locator('.guide__tip')).to_have_count(3) + expect(guide_editor.get_by_role('combobox', name='Квартиль', exact=True)).to_have_accessible_description( + 'Правка затронет статьи этого журнала за тот же год. Если год неизвестен, поле недоступно.' + ) + page.keyboard.press('Escape') + expect(guide_editor).not_to_be_visible() + expect(guide).to_be_visible() guide.get_by_role('button', name='Завершить подсказки', exact=True).click() expect(guide).not_to_be_visible() + expect(tips).to_have_count(0) no_js = browser.new_context(java_script_enabled=False) nojs_page = no_js.new_page() nojs_page.goto(base_url + '/admin/login') @@ -156,7 +200,6 @@ def main(): nojs_page.get_by_role('button', name='Завершить подсказки', exact=True).click() expect(nojs_page.get_by_role('link', name='Открыть подсказки', exact=True)).to_have_count(0) no_js.close() - expect(page.get_by_text('Пароль изменён. Другие сессии завершены.', exact=True)).to_be_visible() expect(page.get_by_role('link', name='Пользователи', exact=True)).to_have_count(0) page.get_by_role('link', name='Обзор', exact=True).click() expect(page.get_by_role('button', name='Запустить парсинг', exact=True)).to_have_count(0) diff --git a/tests/test_api.py b/tests/test_api.py index f97c474..3430922 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -18,7 +18,7 @@ def test_health_returns_versions(): response = TestClient(app).get("/api/health") assert response.status_code == 200 - assert response.json()["backend_version"] == "1.5.1" + assert response.json()["backend_version"] == "1.6.0" def test_api_employees_and_stats_require_admin_session(): diff --git a/tests/test_council.py b/tests/test_council.py index 4d4b2c8..a2e7cda 100644 --- a/tests/test_council.py +++ b/tests/test_council.py @@ -230,7 +230,7 @@ def test_public_page_data_minimization_and_safe_links(db_session, export): assert "javascript:" not in fragment.text and "private@example.org" not in fragment.text assert "javascript:" not in response.text and "private@example.org" not in response.text assert "/admin/employees/" not in response.text and "/admin/logout" not in response.text - assert "Backend 1.5.1 · Frontend 1.5.1" in response.text + assert "Backend 1.6.0 · Frontend 1.6.0" in response.text assert "Вход в систему" in response.text assert client.get("/admin/directory", follow_redirects=False).status_code in (302, 303, 401, 403) assert "Сотрудники не найдены" in client.get("/admin/council?q=missing").text diff --git a/tests/test_users.py b/tests/test_users.py index 3f23185..4ed9316 100644 --- a/tests/test_users.py +++ b/tests/test_users.py @@ -307,11 +307,11 @@ def test_guide_first_password_dismissal_and_repeat_change(accounts, role): guide = client.get('/admin/guide') parsed = BeautifulSoup(guide.text, 'html.parser') assert len(parsed.select('[data-guide-step]')) == 6 - assert not parsed.select('[data-guide-dialog]') + assert not parsed.select('[data-guide]') assert 'Прочитайте показатели сотрудника' in guide.text and 'Проверьте строку публикации' in guide.text assert ('управлять доступом' in guide.text) == (role == 'admin') assert 'не ограничивает «ВСЕГО»' in guide.text - csrf = BeautifulSoup(page.text, 'html.parser').select_one('[data-guide-dialog]')['data-guide-csrf'] + csrf = BeautifulSoup(page.text, 'html.parser').select_one('[data-guide]')['data-guide-csrf'] assert client.post('/admin/guide/dismiss', headers={'X-CSRF-Token': 'wrong'}, follow_redirects=False).status_code == 403 assert 'data-guide-pending="true"' in client.get('/admin/directory').text for _ in range(2): @@ -331,7 +331,7 @@ def test_guide_first_password_dismissal_and_repeat_change(accounts, role): def test_guide_access_and_no_javascript_completion(accounts): client, db, settings, _, user, _ = accounts - assert 'data-guide-dialog' not in client.get('/admin/council').text + assert not BeautifulSoup(client.get('/admin/council').text, 'html.parser').select('[data-guide]') assert client.get('/admin/guide', follow_redirects=False).headers['location'] == '/admin/login' user.must_change_password = True db.commit() -- 2.54.0 From d9420db50c5ac51d0c14e96bd2554a2ece6e7416 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 11 Nov 2026 14:12:07 +0300 Subject: [PATCH 2/4] fix: preserve guide progress across navigation --- app/static/guide.js | 88 ++++++++++++++++------------ app/templates/council.html | 4 +- app/templates/guide_walkthrough.html | 3 +- app/version.py | 6 +- pyproject.toml | 2 +- tests/browser_users.py | 32 ++++++++-- tests/test_api.py | 2 +- tests/test_council.py | 2 +- 8 files changed, 86 insertions(+), 53 deletions(-) diff --git a/app/static/guide.js b/app/static/guide.js index 956e92c..1c77ed1 100644 --- a/app/static/guide.js +++ b/app/static/guide.js @@ -25,7 +25,7 @@ ]}, {id: 'employee-profile', path: directory, title: 'Откройте карточку сотрудника', region: '.directory__header', tips: [ ['[data-columns-open]', 'Выберите столбцы: например, должности, контакты и число публикаций.'], - ['.directory__help', 'Нажмите на ФИО в таблице, чтобы открыть контакты, степени, публикации и другие данные профиля.'], + ['.directory-table__cell[data-column="full_name"] a', 'Нажмите на ФИО, чтобы открыть контакты, степени и публикации сотрудника.'], ]}, {id: 'council-filters', path: council, title: 'Задайте параметры подбора', region: '.council__search', tips: [ ['#council-branch', 'Отрасль определяет публикационный минимум для доктора наук. Варианты зависят от специальности ВАК.'], @@ -42,6 +42,11 @@ ['[data-guide-count="total"]', 'Уникальные работы Q1/Q2/К1/RSCI. Для докторов учитывается также К2.', 'inside'], ['[data-guide-count="all"]', 'Публикации из ЛК и выгрузки ВШЭ за все годы, без дублей.', 'inside'], ]}, + {id: 'council-sources', path: council, title: 'Сравните счётчики публикаций', region: '.council__list-heading', + intro: 'В раскрытой карточке можно проверить источник записи, DOI и основания зачёта.', tips: [ + ['[data-guide-count="q1"]', 'Одна работа может входить сразу в несколько счётчиков Q1, Q2 и К1.', 'inside'], + ['[data-guide-count="hse"]', 'Здесь только публикации из личного кабинета сотрудника ВШЭ за все годы.', 'inside'], + ]}, {id: 'council-export', path: council, title: 'Проверьте расчёт и выгрузите список', region: '.council__notice', tips: [ ['.council__notice', 'Здесь описаны период оценки, минимумы по отраслям и правила учёта работ.'], ['[formaction="/admin/council/export.xlsx"]', 'В XLSX попадут все найденные сотрудники и их публикации, включая другие страницы списка.'], @@ -68,13 +73,15 @@ const heading = guide.querySelector('#guide-title'); const progress = guide.querySelector('[data-guide-progress]'); const intro = guide.querySelector('[data-guide-intro]'); + const reference = guide.querySelector('[data-guide-reference]'); const back = guide.querySelector('[data-guide-back]'); const next = guide.querySelector('[data-guide-next]'); const close = guide.querySelector('[data-guide-close]'); const error = guide.querySelector('[data-guide-error]'); const unsaved = guide.querySelector('[data-guide-unsaved]'); - const hashFor = step => '#guide-' + step.id; + const storageKey = 'workers-guide:' + guide.dataset.guideUser; let index = 0; + let paused = false; let saving = false; let removeTips = []; @@ -119,19 +126,24 @@ function showStep(focus = true) { const step = steps[index]; + paused = Boolean(step.path && step.path !== location.pathname); clearTips(); - const region = step.region && main.querySelector(step.region); + const region = !paused && step.region && main.querySelector(step.region); if (region) region.before(guide); else main.prepend(guide); - heading.textContent = step.title; + heading.textContent = paused ? 'Продолжите обучение' : step.title; progress.textContent = `Шаг ${index + 1} из ${steps.length}`; - intro.textContent = step.intro || ''; - intro.hidden = !step.intro; + intro.textContent = paused ? step.title + '.' : step.intro || ''; + intro.hidden = !intro.textContent; + reference.hidden = index !== 0; error.hidden = unsaved.hidden = true; + back.hidden = paused; back.disabled = index === 0; - next.textContent = index === steps.length - 1 ? 'Завершить подсказки' : 'Далее'; - step.tips?.forEach((tip, number) => addTip(tip, number)); - step.editorTips?.forEach((tip, number) => addTip(tip, 'editor-' + number, true)); + next.textContent = paused ? 'Продолжить' : index === steps.length - 1 ? 'Завершить подсказки' : 'Далее'; + if (!paused) { + step.tips?.forEach((tip, number) => addTip(tip, number)); + step.editorTips?.forEach((tip, number) => addTip(tip, 'editor-' + number, true)); + } guide.hidden = false; if (focus) { heading.focus({preventScroll: true}); @@ -140,20 +152,20 @@ } function goTo(number, path = steps[number].path || '/admin/account') { - const step = steps[number]; + sessionStorage.setItem(storageKey, steps[number].id); if (path !== location.pathname) { - location.assign(path + hashFor(step)); + location.assign(path); return; } index = number; - history.replaceState(history.state, '', hashFor(step)); showStep(); } - function hide() { + function hide(remember = true) { clearTips(); guide.hidden = true; - history.replaceState(history.state, '', location.pathname + location.search); + if (remember) sessionStorage.setItem(storageKey, 'closed'); + else sessionStorage.removeItem(storageKey); trigger?.focus(); } @@ -195,36 +207,34 @@ }); back.addEventListener('click', () => goTo(index - 1)); next.addEventListener('click', () => { - if (index === steps.length - 1) finish(); + if (paused) goTo(index); + else if (index === steps.length - 1) finish(); else goTo(index + 1); }); close.addEventListener('click', finish); - unsaved.addEventListener('click', hide); - document.addEventListener('keydown', event => { - if (event.key !== 'Escape' || event.defaultPrevented || guide.hidden || document.querySelector('dialog[open]')) return; + unsaved.addEventListener('click', () => hide(false)); + guide.addEventListener('keydown', event => { + if (event.key !== 'Escape' || event.defaultPrevented) return; event.preventDefault(); finish(); }); - main.addEventListener('submit', event => { - const form = event.target; - if (guide.hidden || !steps[index].path || form.method !== 'get' || event.submitter?.hasAttribute('formaction')) return; - const action = new URL(form.action); - if (action.origin !== location.origin || action.pathname !== location.pathname) return; - const original = form.getAttribute('action'); - action.hash = hashFor(steps[index]); - form.action = action; - setTimeout(() => { - if (original === null) form.removeAttribute('action'); - else form.setAttribute('action', original); - }); - }); - - const requested = steps.findIndex(step => hashFor(step) === location.hash && (!step.path || step.path === location.pathname)); - if (requested >= 0) { - index = requested; - showStep(); - } else if (guide.dataset.guidePending === 'true') { - history.replaceState(history.state, '', hashFor(steps[0])); - showStep(false); + function restore(allowAutoStart) { + const active = sessionStorage.getItem(storageKey); + const requested = steps.findIndex(step => step.id === active); + if (requested >= 0) { + index = requested; + showStep(!allowAutoStart || index > 0); + } else if (allowAutoStart && active !== 'closed' && guide.dataset.guidePending === 'true') { + sessionStorage.setItem(storageKey, steps[0].id); + showStep(false); + } else { + clearTips(); + guide.hidden = true; + } } + + window.addEventListener('pageshow', event => { + if (event.persisted) restore(false); + }); + restore(true); })(); diff --git a/app/templates/council.html b/app/templates/council.html index f3497a6..a2b4de9 100644 --- a/app/templates/council.html +++ b/app/templates/council.html @@ -81,11 +81,11 @@ {{ candidate.name }}{{ candidate.degree }}{% if candidate.dismissed %} · Уволен{% endif %}{{ candidate.assessment_status }} · подтверждено {{ candidate.confirmed_count }} - {{ candidate.q1_count }}Q1 + {{ candidate.q1_count }}Q1 {{ candidate.q2_count }}Q2 {{ candidate.k1_count }}К1 {{ candidate.total_count }}ВСЕГО (предв.) - {{ candidate.db_count }}ЛК сотрудника ВШЭ + {{ candidate.db_count }}ЛК сотрудника ВШЭ {{ candidate.count }}Все публикации diff --git a/app/templates/guide_walkthrough.html b/app/templates/guide_walkthrough.html index 746971b..6af8763 100644 --- a/app/templates/guide_walkthrough.html +++ b/app/templates/guide_walkthrough.html @@ -1,4 +1,4 @@ - {% if request.state.guide_pending|default(false) %} -