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).
Phase 1d enhancement: 'Согласования' пункт сайдбара скрыт если у user'а
нет approver/reviewer ролей. Раньше показывался любому authenticated —
оператор без reviewer прав видел queue, кликал и получал пустую
страницу (или 403 если бы пробовал approve).
Visibility rules:
- 'Мои черновики' — auth (maker может быть любой)
- 'Согласования' — auth + (ordinis:schema:reviewer OR ordinis:record:reviewer
OR ordinis:admin super-role) — иначе hidden
- 'Outbox' / 'Webhooks' / 'Аудит' — auth (admin-y но backend не gated
отдельной ролью)
Новый NavItem.reviewerOnly flag — фильтр в visibleSections учитывает
оба flag'а (authRequired + reviewerOnly).
Применено и к desktop Sidebar и к MobileSidebar (DRY refactor — отдельный
task).
User feedback (screenshot): 'Approve' остался английским в RU локали +
'Отозвать' улетал на отдельную строку справа из-за ml-auto.
Fix:
- i18n.ts: 'workflow.schemaDraft.actions.approve' default 'Approve' →
'Одобрить'. EN остался 'Approve'.
- SchemaDraftDrawer.tsx: убрал ml-auto на withdraw button. Раньше это
force pushed его в конец строки → flex-wrap отправлял на новую строку
правым краем. Сейчас withdraw сидит сразу после reject в той же
flow-row, с danger color для визуальной distinction. На узком экране
всё ещё wrap'нется естественно (без 'плавающего' alignment).
Browser QA на staging показал что UUID actor'а render'ится raw во многих
местах кроме drawer (MR !158 — там был inline fix):
- /reviews Records tab — column AUTHOR
- /reviews Schemas tab — column AUTHOR
- /audit log — column USER
77ec2cc8-98e3-4d70-8037-94038fcbde67 — нечитаемо для оператора. Тот же
паттерн что я делал в MR !158, теперь shared helper:
src/lib/useUserDisplay.tsx:
- useUserDisplay(uuid) hook — returns { display, isMe, fullId }
- UserCell component — readonly cell с title=fullId tooltip
- Match current user (JWT sub claim) → 'Я • <preferred_username>'
- Other user → short UUID (8 chars) + full в title
- null/undefined → 'anonymous'
Применено:
- reviews.tsx:334,625 — records + schemas table AUTHOR cells
- audit.tsx:476 — USER cell
- SchemaDraftDrawer.tsx — refactor (был inline в MR !158 → теперь shared)
TODO Phase 1d: backend endpoint /admin/users/{sub}/display чтобы и для
не-current-user отображать username.
Sidebar:
- Outbox: бэйдж = outbox.pending (события queued, не отосланы). DLQ
отдельно — alarming но не actionable из бэйджа, видно на /outbox.
- Webhooks: бэйдж = count активных subscriptions (w.active === true).
User feedback: 'вебхуки активные есть а цифры с бэйджем нет'.
Audit log column header:
- 'Scope' → 'Доступ актора' (ru) / 'Actor scope' (en)
- Backend audit_log хранит userScope (claim из JWT актора), НЕ scope
изменённого ресурса. User confusion: 'правки в public были а тут
RESTRICTED' — потому что у пользователя scope-привилегия RESTRICTED.
Уточнённая label убирает путаницу.
- TODO: backend feature — добавить dictionaryScope в AuditEntry чтобы
показать обе ('Актор: RESTRICTED → Ресурс: PUBLIC' — двусторонний
audit trail для compliance).
Два бага в SchemaDraftDrawer (видны на review pending):
1. АВТОР показывал raw UUID (77ec2cc8-98e3-4d70-8037-...). Резолвим:
- Если maker = current user (JWT sub claim) → 'Я • <preferred_username>'
- Иначе → short UUID (первые 8 символов) с full в title attribute
- Стилизация: 'Я' — text-ink (читаемая), чужой — font-mono text-mute
- TODO Phase 1d: backend endpoint для resolve чужих sub → display name
2. Кнопка 'Отозвать' показывалась всем юзерам, не только автору.
- Backend всё равно 403'нет non-maker withdraw — но UI confusion:
reviewer видел кнопку которая всегда fail'ит.
- Gate: показываем только когда auth.user.profile.sub === draft.makerId.
- Reviewer теперь видит чистый action row (Approve / Запросить правки /
Отклонить), без danger-button мимо контекста.
Реальный случай: см. user screenshot на v2.16.x — Reviewer modal с UUID
автора и кнопкой 'Отозвать' справа внизу.
Бэйдж 'На ревью' в sidebar (desktop + mobile) был захардкожен в 0 —
TODO comment ждал cheap /drafts/pending/count endpoint. На самом деле
existing /admin/reviews/pending и /admin/schema-reviews/pending уже
возвращают paged response с totalElements/total в metadata — request
size=1 даёт нужный counter с минимальным payload (~1 строка + 4
metadata field'а).
Новый hook useReviewsBadgeCount():
- Параллельно дёргает обе queue (record + schema)
- Суммирует totalElements (record) + total (schema)
- Полл каждые 60s (lighter чем 30s queue refresh — badge не critical)
- Gated на auth.isAuthenticated (anonymous не должен дёргать admin endpoint'ы)
Sidebar.tsx (оба варианта — desktop + mobile MobileSidebar):
- reviewsCount: 0 → reviews.total
Per user feedback: «по кнопке что нового должно открываться модальное
окно с изменениями».
UpdateBanner кнопка «Что нового» (т9n key `updateBanner.docs`) ранее
открывала `/docs/` в новой вкладке — generic documentation page, не
contextual для «что именно в этом обновлении».
Теперь — открывает `WhatsNewModal` (тот же modal что показывает 🎁
button в TopBar) — public changelog с release notes сразу.
## Refactor
- **`WhatsNewModal.tsx` (NEW)** — extracted controlled-mode modal с
release notes. Props: `open`, `onClose`. Internal logic читает
changelog, tracks lastSeen в localStorage, на open mark'ает latest
как seen.
- **`WhatsNewButton.tsx`** — refactored: trigger button + use
`WhatsNewModal`. Поведение unchanged (🎁 icon с unread badge → click → modal).
- **`UpdateBanner.tsx`** — replaced `window.open(DOCS_URL)` на
`setWhatsNewOpen(true)` + render `<WhatsNewModal>`. DOCS_URL constant
removed (больше не используется).
## UX intent
Когда update detected:
1. User видит orange banner «Доступна новая версия v2.16.0»
2. Hover «Что нового» button → opens modal
3. User читает release notes (что добавлено, исправлено) ДО reload
4. Click «Обновить» → reload
Раньше «Что нового» вёл к generic docs page — user не знал ЧТО
изменилось, только что update есть. Modal даёт structured answer.
## Test plan
- [x] `pnpm tsc --noEmit` clean
- [x] `pnpm build` clean
- [x] `pnpm vitest run useChangelog.test` — 11/11 pass (parser logic
unchanged)
- [ ] Manual: симулировать update banner (mock useAppVersion
`updateAvailable=true`), click «Что нового» → modal opens
- [ ] Manual: 🎁 button в TopBar также opens same modal
- [ ] Manual: close modal → red dot disappears на 🎁 button
(localStorage updated)
Two improvements per user feedback ('по клику открывается редактирование и
в развернутом виде слева навигация'):
## 1. Row click → edit drawer
TableRow в /dictionaries/$name теперь имеет onClick={setEdit({kind:'edit',
record:r})} когда canMutate. cursor-pointer + bubbling guards:
- Checkbox cell и Actions cell имеют onClick stopPropagation чтобы
внутренние controls не открывали drawer случайно.
- Anonymous users (no canMutate) — row не clickable (read-only, no drawer).
## 2. Wide-mode left rail в drawer
SchemaDrivenForm в sections layout теперь умеет ResizeObserver-based detection
своей ширины. Когда контейнер ≥720px (drawer wide mode = 880px) → render'ит
180px sticky left rail с section list + per-section field counts. Click rail
item → smooth scroll к section anchor. Иначе chip strip сверху, как раньше.
Container-based (не viewport-based) чтобы narrow drawer на wide viewport не
рендерил rail (520px не fits 180+1fr grid).
Native <select> для FK references показывал label '<code> — <localized name>'
(например 'OPT_MIDRES_MS — Оптический мульти-резолюционный мульти-спектральный')
— текст бьётся на chevron в trigger area из-за тонкого contract'а browser-
specific overflow handling (наезжают per user screenshot 2026-05-11).
Fix:
- buildOption() обрезает name до MAX_FK_LABEL_CHARS=28 (code intact) +
'…' suffix когда combined превышает limit
- select сам получает 'truncate overflow-hidden whitespace-nowrap text-ellipsis'
как защита если browser native truncate не сработал
- pr-10 → pr-9 (38px) — точно совпадает с right-3 chevron position (12px)
+ ~16px icon + 8px gap, не overlap'aет.
stroke=5 в viewBox 256 при 28px render = 5*28/256 = 0.55px (subpixel,
почти невидимо). stroke=12 даёт ~1.31px real — чёткая линия. Заодно
переключил с --color-line-2 на --color-line чтобы немного контрастнее
на bg-surface.