1. reviews.tsx: ReviewDrawer звал useLiveRecord для ЛЮБОГО черновика,
включая CREATE. У CREATE-черновика live-записи нет по определению →
GET /dictionaries/{id}/records/{key} → 404 (красный шум в консоли на
каждый CREATE-draft). Теперь для CREATE запрос не запускается, блок
«текущая версия» показывает «нет текущей версии».
2. modal.tsx: Modal рендерил DialogTitle только при наличии prop title.
Без title Radix писал в консоль «DialogContent requires a DialogTitle»
+ warning про aria-describedby. Теперь Modal всегда рендерит DialogTitle
(sr-only fallback) и гасит aria-describedby когда описания нет.
Принцип: клик по «Сохранить» не должен оставаться без реакции.
Раньше submit мог молча оборваться: AJV-ошибка ложилась через setError
на nested-путь (data.<field>.<locale>), а FieldError читает data.<field>
→ ошибка невидима, форма «не реагирует».
Добавлено:
- blockedMsg — состояние с причиной, рендерится видимым Alert сверху
формы при любой блокировке submit (validFrom/validTo, AJV).
- onInvalid — обработчик RHF handleSubmit на случай когда RHF сам
блокирует submit (required-поля); тоже ставит blockedMsg.
- scrollIntoView к началу формы — Alert гарантированно в зоне видимости.
Теперь Save либо сохраняет, либо показывает «Не удалось сохранить —
проверьте поля: …». Молчания не будет.
Симптом: «Сохранить» (create и edit) ничего не делал, ошибок не видно.
Причина: x-localized поле рендерит Controller на каждую локаль
(data.<field>.<locale>). RHF при сабмите реконструирует values из имён
зарегистрированных полей и материализует родительский объект даже когда
исходное значение было null/отсутствовало → optional поле (например
mission) превращалось в { "ru-RU": undefined, "en-US": undefined }.
AJV отвергал undefined-локали ("must be string"), submit() делал return
до onSubmit — запрос не уходил. Ошибка ложилась через setError на
data.<field>.<locale>, а FieldError читает data.<field> → невидима.
Фикс: pruneEmpty() в submit() рекурсивно убирает undefined и опустевшие
объекты из data перед AJV-валидацией и отправкой — нетронутое
optional-поле снова валидируется как «отсутствует». Не трогает
null/''/0/false.
Откат временной диагностики [ordinis-diag] из MR !251.
Regression-тест падает без фикса, проходит с фиксом.
ВРЕМЕННО — для поиска причины «Save ничего не делает». Откатить
после диагностики (все логи помечены [ordinis-diag]).
SchemaDrivenForm: console.warn в submit() — событие формы, RHF
onInvalid, результат AJV, ветки return, вызов onSubmit.
dictionaries.$name: логи в route.handleSubmit + createMut/updateMut
mutate + onSuccess/onError со статусом ответа.
Также добавлен постоянный регресс-тест: повторный submit после
исправления валидации вызывает onSubmit.
Top-left dark-glass input на странице /graph. Substring match
case-insensitive по name + displayName (admin помнит либо
technical id `antenna`, либо русское название `Антенны`).
UX:
- Matched nodes остаются в full opacity, остальные dim'ятся (0.25) —
как при hover-focus. Force-layout не трогаем (positions стабильны).
- При единственном match → matched node получает focused border
(orange accent + glow) — легко найти результат.
- Edges светятся только когда оба конца — match. Иначе dim.
- Counter chip `N из M` под input'ом + `ничего не найдено` для empty.
- Esc clears query и снимает focus. ✕-кнопка тоже clears (focus
остаётся на input).
- Search dominates над hover dim — non-matched nodes остаются dim
даже если hover их затрагивает.
i18n: graph.search.placeholder / .empty / .count для ru-RU и en-US.
Force-simulation не пересчитываем при изменении query — это намеренно.
Иначе nodes прыгали бы по экрану на каждый символ, ломая spatial
recognition (юзер уже привык где какой node лежит).
Восемь дефектов из QA-отчёта одним MR.
#1 — Records-table теряла первую колонку после закрытия Create-modal.
Корень: lg:grid-cols-[220px_1fr] без minmax(0,…) — 1fr имел implicit
min-content (=max-content), позволяя table вырасти шире viewport.
После закрытия Radix Dialog снималось body padding-right (~15px), grid
пересчитывался и клиппил колонку под overflow-hidden card.
Fix: grid-cols-[220px_minmax(0,1fr)] + убран overflow-hidden с card
(Table primitive имеет свой overflow-x-auto).
#2 — Esc закрывал modal с задержкой (визуал/DOM рассинхрон).
duration-200 на slow CPU ощущался как несколько кадров между state=closed
и unmount. Снижено до duration-150 — совпадает с tw-animate-css дефолтами.
#3 — «+ Создать» обрезалось до «+ Соз» на ~1075px на detail-странице.
EditorTabBar: 6 tabs + 3 кнопки actions в одном flex row без shrink-0
давили друг друга. Решение: tabs в overflow-x-auto wrapper с min-w-0,
actions в shrink-0 контейнере — tabs скроллятся, actions всегда видны
полностью. scrollbar скрыт визуально (height-консистентность).
#4 — Обрезка названий в records-table при PUBLIC+INTERNAL.
Добавлен div.overflow-x-auto вокруг table + min-w-[760px] на table +
min-w-[180px] max-w-[320px] truncate на name cell. Catalog table также
получает min-w-[220px] на name column.
#5 — Заголовки колонок и кнопка «Граф» не локализованы на EN.
defaultValue:'Название' inline в коде падал на ru-text при EN locale.
Вынесены в i18n: dict.col.title/id/bundle/records/updated/outgoingFk/
incomingFk + dict.list.graph для обеих локалей.
#6 — Modal backdrop почти невидим.
bg-black/40 + 1px blur → bg-black/55 + 2px blur. Заметно темнее при
сохранении читабельности фона, фокус на modal.
#7 — Неестественная подсказка в форме «Создать»:
«Имя оставьте уникальное; версия сбросится на 1.0.0» →
«Укажите уникальное имя — версия сбросится на 1.0.0».
#8 — «Главная» в сайдбаре сбивала active state.
Клик на «Главную» вёл на `/` → redirect на /dictionaries → подсветка
уезжала на «Справочники». Logo сверху уже даёт home affordance, поэтому
duplicate nav item удалён (desktop + mobile).
Last item из QA report backlog'а. Banner + empty-state hint показывают
aggregate count, но не помогают find конкретные записи которые скоро
изменятся. Badge per row даёт O(1) visual scan.
Backend (read-api):
- ScheduledRecordsSummary.scheduledBusinessKeys: List<String> (capped 500)
- Repo method findScheduledBusinessKeys (DISTINCT subquery)
- Single fetch вместо N+1 per-row queries
Frontend:
- scheduledByKey Set<string> построен из summary
- Badge variant=info «Запланировано» рядом с businessKey badge'ом
- Title attribute с full RU/EN tooltip
- Не блокирует layout — gap-1.5 с pendingReview badge'ем (могут быть оба)
i18n keys для ru/en — short label «Запланировано» / «Scheduled».
QA report cleanup (после P1/P2 ship'нутых в v2.31.4):
- **BUG-009**: Excel/SQL export форматы получили явный «скоро» badge поверх
opacity-only disabled state. Title attribute оставлен.
- **BUG-010**: «Доступ актора» (audit scope column) переименован в «Scope» —
компактнее, не переносится на 2 строки.
- **BUG-011**: Все audit table headers получили title attribute → hover
tooltip когда text truncate'ится («ИЗМЕНЕН…» → full «Изменения»).
- **UX-001**: Scope chips в /dictionaries теперь показывают ✓ glyph когда
selected → визуально clear что multi-select. Aria-label расширен:
«Фильтр по области (можно выбрать несколько)».
- **UX-002**: Breadcrumb segments получили title attribute → hover показывает
full label когда truncate'ится в narrow viewport.
- **UX-003**: При смене вкладки в редакторе справочника (records → relations
и т.п.) URL params q/scopes сбрасываются — раньше search фильтр оставался
применённым после возврата на records.
- **UX-004**: TimeTravel slider boundary метки получили 2-digit year:
«05.05.26» вместо «05.05» — disambiguates конец декабря / начало января.
Title attribute exposes full локальную дату.
- **UX-009**: «Удалить» button в RecordDrawer переименован в «Закрыть запись»
— same action как row context menu «Закрыть» (bitemporal close, не truly
delete). Consistent labeling across UI.
Form review после !224 показал что create mode имеет тот же баг что и edit:
SchemaDrivenForm без переданного defaultValues fallback'ит на '' для
validFrom, и пользователь видит пустое поле «Действует с».
Добавлен defaultValues={{ validFrom: nowIsoLocal() }} для create — consistent
UX с edit mode (!224 fix). Other formы (drafts, reviews, schema workflow) —
либо read-only либо без validFrom, clean.
User feedback: «крайняя дата должна быть исходя из реальной в этом
словаре». Раньше slider всегда extended до now + 30d даже без причины —
если нет scheduled записей на 18.05, slider всё равно тянулся туда,
пользователь блуждал по пустоте.
Теперь max = max(marks) — last real change point (record validFrom включая
upcoming scheduled, которые backend возвращает в /scheduled-summary).
Если все marks в прошлом — max=now (always reachable). Если есть future
scheduled на 18.05 — slider тянется ровно туда, не дальше.
Touches: TimeTravelPicker.tsx (inline editor) + TimeTravelModal.tsx (modal).
Три связанных UX бага после v2.31.0 release:
1. **TimeTravel marks**: после !220 slider extended до now+30d, но отметок в
future window не было → blind drag. Backend теперь возвращает
upcomingValidFroms (top 50) в /scheduled-summary; frontend merges как marks.
2. **Non-empty banner**: empty-state hint работает только когда список пуст.
Если currently active записи есть AND ещё запланированы — пользователь
не видит. Добавлен info banner над таблицей с тем же текстом + CTA.
3. **Editor "Действует с"**: edit mode оставлял поле пустым (bitemporal
policy «новая версия с now»). User feedback: «показывает ничего, а даты
заданы» — выглядит как баг. Prefill nowIsoLocal() — теперь юзер видит
конкретную дату как точку отсчёта.
MR !220 расширил range у TimeTravelPicker.tsx, но юзер видит другой компонент —
TimeTravelModal.tsx (fullscreen modal с компарисоном now vs picked moment).
У него своя range logic с max = now + pad (1 day), и фикс !220 туда не дотянулся.
User feedback: создал запись с validFrom=18.05, открыл time-travel modal — слайдер
14.05 → 16.05, не дотянуть до 18-го.
Apply same 30-day future window logic к TimeTravelModal так чтобы оба компонента
вели себя одинаково.
Fixes UX bug: пользователь создаёт запись с validFrom на будущую дату
(e.g. 18.05), открывает catalog 15.05 — пустая таблица, никаких хинтов
о том что запись существует.
Backend (read-api):
- New endpoint GET /api/v1/{dict}/records/scheduled-summary
- Returns {count, nearestValidFrom} для записей с validFrom > now AND
validTo > now в текущем scope view
- Single-aggregate JPA query (COUNT + MIN), respect scope filter same way
as listActive — INTERNAL scheduled не учитывается для PUBLIC consumer'а
Frontend:
- New useScheduledSummary hook + ScheduledRecordsSummary type
- Empty-state в dictionaries.$name now shows: "Запланировано N, ближайшая
на DD.MM [Перейти к этой дате]" с CTA который set'ает time-travel на
nearestValidFrom
- i18n keys для ru/en plural forms
Связан с MR !219 (TimeTravelPicker 30-day future window) — теперь slider
позволяет дойти до даты записи, и empty-state hint позволяет узнать что
запись существует.
Acts on the two "build now" items from the design review of the approval
workflow UX (other five went to TODOS.md). Plus the TODOS.md file itself,
authored during the same review.
1. **Inline "Создать черновик с моими изменениями" CTA**
(DictionaryEditorDialog + CreateSchemaDraftModal)
Was: when a maker edited an approval-required dict, Save disabled and a
warning told them to "close the editor and click 'Создать черновик схемы'
on the dict page." Three friction steps and a hunt for a button.
Now: the warning Alert carries an action button — "Создать черновик с
моими изменениями" — that opens CreateSchemaDraftModal in-place with the
maker's in-progress schemaJson pre-loaded. They add a reason and submit.
No retyping, no hunt.
Implementation:
- CreateSchemaDraftModal accepts optional `initialProposedSchema` prop.
Seed order: edit-mode draft → caller pre-fill → live HEAD.
- DictionaryEditorDialog mounts the modal inline and triggers it from the
Alert action. Closes the editor on successful create.
2. **Diff summary chips in ReviewDrawer**
Was: two raw JSON dumps side by side. Reviewer had to eyeball-diff long
records to find the actual edit.
Now: a row of summary chips above the panes — "+N added, −M removed,
~K changed" with the field names inline. Computed shallow client-side
(shallowDiffSummary helper) — ordinis records are flat-ish at the top
level, no need for a backend diff endpoint. Skipped for CLOSE op (no
proposed) and when both sides are null.
3. **Tightened drawer header + trimmed empty-state copy**
Was: single flex-wrap line with four spans (badge + bk + maker + ts) —
visually busy. Plus a verbose empty-state description: "Когда maker
отправит schema draft на ревью, он появится здесь."
Now: two-row layout (identity row on top, provenance row below) so the
eye lands on Badge + BK first. Empty-state copy shortened to "Очередь
пуста" / "Схемы на ревью появятся здесь."
Other five gaps (Мои tab, full state-coverage pass, DESIGN.md, a11y audit,
maker notifications) recorded in TODOS.md.
После !178 (record drawer + RecordHistoryDrawer + webhook detail) ещё
осталось 6 точек где раньше рендерился UUID. Все они в "истории
словаря" и tab "События":
1. ChangelogDiffModal:57 — header "автор: <name>" в diff modal.
Было: `{data.publishedBy.name}` (тип ChangelogPublisher.name —
docstring буквально говорит "Backend пока эхает sub").
Стало: <UserCell uuid={data.publishedBy.sub} />.
2. TimeTravelModal:686 — список изменений, имя автора рядом с датой.
3. TimeTravelModal:898 — RIGHT panel "История этой версии", dt/dd
pair с автором.
4. TimeTravelModal ComparePanel:414 — "· {author}" suffix,
author там DictionaryDetail.updatedBy (тоже UUID).
5. dictionaries.$name.tsx:2263 (events tab, EventsTabContent row) —
`{r.userId ?? 'anonymous'}` raw. Сменил на UserCell для не-null
userId, anonymous-fallback оставил без UserCell (нечего lookup'ать).
6. dictionaries.$name.tsx:2399 (history tab, HistoryTabContent entry)
— `{entry.publishedBy.name}` raw.
Всё через тот же useUserDisplay hook что и в !177 — backend
endpoint /admin/users/{sub}/display + JwtUserCaptureFilter cache.
Если sub нет в cache, UserCell fallback'ит на shortened UUID
(текущее поведение из !177).
ChangelogPublisher type comment в src/api/client.ts уже намекал что
.name это placeholder — useUserDisplay теперь реально резолвит.
MR !177 added /admin/users/{sub}/display + UserCell + useUserDisplay,
but only some renderers were migrated. Three places still rendered
raw UUID strings:
1. reviews.tsx:466 — record draft drawer ("Согласование draft" header)
showed `{draft.makerId}` raw. Tables on the same page already use
<UserCell uuid={d.makerId} />. Verified via prod staging:
soloviev.da approved a record draft today and the drawer header
showed the maker as "77ec2cc8-98e3-4d70-8037-94038fcbde67" instead
of the username.
2. RecordHistoryDrawer.tsx:110 — version history `updatedBy` raw.
3. webhooks.$id.tsx:235 — webhook subscription `createdBy` raw.
All three switched to <UserCell uuid={...} />, matching the existing
pattern. Added the import where missing. Wrapped each in
inline-flex so the UserCell sits next to the label cleanly.
No backend / API changes — same /admin/users/{sub}/display endpoint
populated by JwtUserCaptureFilter (MR !177).