fix(admin-ui): QA report sweep — modal/table layout, i18n, sidebar polish
Восемь дефектов из 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).
This commit is contained in:
@@ -2,7 +2,6 @@ import { useState } from 'react'
|
|||||||
import { Link, useLocation } from '@tanstack/react-router'
|
import { Link, useLocation } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Home,
|
|
||||||
Database,
|
Database,
|
||||||
FileText,
|
FileText,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@@ -112,12 +111,14 @@ export function Sidebar() {
|
|||||||
webhooks.data?.filter((w) => w.active).length ?? 0
|
webhooks.data?.filter((w) => w.active).length ?? 0
|
||||||
|
|
||||||
// Flat list per redesign/compact.html — no section labels, /graph removed
|
// Flat list per redesign/compact.html — no section labels, /graph removed
|
||||||
// (now reachable via catalog toolbar "Граф ⇄" button). Order: Главная →
|
// (now reachable via catalog toolbar "Граф ⇄" button). QA bug #8: «Главная»
|
||||||
// Справочники → Мои черновики → На ревью → Outbox → Webhooks → Аудит → Поиск.
|
// удалён — он вёл на `/` который сразу redirect'нет на /dictionaries; в
|
||||||
|
// сайдбаре подсветка слетала (active state на /dictionaries, не на /).
|
||||||
|
// Logo сверху уже даёт home affordance — duplicate nav item только сбивал.
|
||||||
|
// Order: Справочники → Мои черновики → На ревью → Outbox → Webhooks → Аудит → Поиск.
|
||||||
const sections: NavSection[] = [
|
const sections: NavSection[] = [
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ to: '/', label: t('nav.home'), icon: Home },
|
|
||||||
{
|
{
|
||||||
to: '/dictionaries',
|
to: '/dictionaries',
|
||||||
label: t('nav.dictionaries'),
|
label: t('nav.dictionaries'),
|
||||||
@@ -484,11 +485,11 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
const webhooksActive =
|
const webhooksActive =
|
||||||
webhooks.data?.filter((w) => w.active).length ?? 0
|
webhooks.data?.filter((w) => w.active).length ?? 0
|
||||||
|
|
||||||
// Flat list per redesign — same as desktop Sidebar above.
|
// Flat list per redesign — same as desktop Sidebar above. QA bug #8:
|
||||||
|
// «Главная» удалён (redirect → /dictionaries сбивал active state).
|
||||||
const sections: NavSection[] = [
|
const sections: NavSection[] = [
|
||||||
{
|
{
|
||||||
items: [
|
items: [
|
||||||
{ to: '/', label: t('nav.home'), icon: Home },
|
|
||||||
{
|
{
|
||||||
to: '/dictionaries',
|
to: '/dictionaries',
|
||||||
label: t('nav.dictionaries'),
|
label: t('nav.dictionaries'),
|
||||||
|
|||||||
@@ -349,8 +349,19 @@ i18n
|
|||||||
'dict.list.col.version': 'Версия',
|
'dict.list.col.version': 'Версия',
|
||||||
'dict.list.col.locales': 'Локали',
|
'dict.list.col.locales': 'Локали',
|
||||||
'dict.list.col.records': 'Записи',
|
'dict.list.col.records': 'Записи',
|
||||||
|
// Compact row table (catalog) — column headers per redesign/compact.html.
|
||||||
|
// QA bug #5: defaultValue inline в коде падал на ru-text при EN locale;
|
||||||
|
// выносим в i18n чтобы переключение языка реально влияло на заголовки.
|
||||||
|
'dict.col.title': 'Название',
|
||||||
|
'dict.col.id': 'id',
|
||||||
|
'dict.col.bundle': 'bundle',
|
||||||
|
'dict.col.records': 'записей',
|
||||||
|
'dict.col.updated': 'изменён',
|
||||||
|
'dict.col.outgoingFk': 'Ссылается (исходящие FK)',
|
||||||
|
'dict.col.incomingFk': 'Используют (входящие FK)',
|
||||||
// List view (handoff D)
|
// List view (handoff D)
|
||||||
'dict.list.bundle.all': 'все',
|
'dict.list.bundle.all': 'все',
|
||||||
|
'dict.list.graph': 'Граф',
|
||||||
'dict.list.deps.only': 'Только со связями',
|
'dict.list.deps.only': 'Только со связями',
|
||||||
'dict.list.records.short': 'справочн.',
|
'dict.list.records.short': 'справочн.',
|
||||||
'dict.list.fk.references': '→ ссылается',
|
'dict.list.fk.references': '→ ссылается',
|
||||||
@@ -482,7 +493,7 @@ i18n
|
|||||||
'schema.bundle': 'Bundle',
|
'schema.bundle': 'Bundle',
|
||||||
'schema.bundleHint': 'Обычно cuod',
|
'schema.bundleHint': 'Обычно cuod',
|
||||||
'schema.template.label': 'Создать по шаблону',
|
'schema.template.label': 'Создать по шаблону',
|
||||||
'schema.template.hint': 'Скопирует схему, scope и locale выбранного словаря. Имя оставьте уникальное; версия сбросится на 1.0.0.',
|
'schema.template.hint': 'Скопирует схему, scope и локали выбранного словаря. Укажите уникальное имя — версия сбросится на 1.0.0.',
|
||||||
'schema.template.placeholder': '— без шаблона —',
|
'schema.template.placeholder': '— без шаблона —',
|
||||||
'schema.version': 'Версия схемы',
|
'schema.version': 'Версия схемы',
|
||||||
'schema.supportedLocales': 'Поддерживаемые локали',
|
'schema.supportedLocales': 'Поддерживаемые локали',
|
||||||
@@ -1203,6 +1214,15 @@ i18n
|
|||||||
'dict.list.col.version': 'Version',
|
'dict.list.col.version': 'Version',
|
||||||
'dict.list.col.locales': 'Locales',
|
'dict.list.col.locales': 'Locales',
|
||||||
'dict.list.col.records': 'Records',
|
'dict.list.col.records': 'Records',
|
||||||
|
// Compact row table (catalog) — column headers EN.
|
||||||
|
'dict.col.title': 'Name',
|
||||||
|
'dict.col.id': 'id',
|
||||||
|
'dict.col.bundle': 'bundle',
|
||||||
|
'dict.col.records': 'records',
|
||||||
|
'dict.col.updated': 'updated',
|
||||||
|
'dict.col.outgoingFk': 'References (outgoing FK)',
|
||||||
|
'dict.col.incomingFk': 'Used by (incoming FK)',
|
||||||
|
'dict.list.graph': 'Graph',
|
||||||
// List view (handoff D)
|
// List view (handoff D)
|
||||||
'dict.list.bundle.all': 'all',
|
'dict.list.bundle.all': 'all',
|
||||||
'dict.list.deps.only': 'Only with links',
|
'dict.list.deps.only': 'Only with links',
|
||||||
|
|||||||
@@ -800,8 +800,14 @@ function DictionaryDetail() {
|
|||||||
Info panel — sticky слева с dict metadata + relations.
|
Info panel — sticky слева с dict metadata + relations.
|
||||||
На <lg viewport schedule переключается в stacked (info above content). */}
|
На <lg viewport schedule переключается в stacked (info above content). */}
|
||||||
{/* Grid items-stretch (default) — InfoPanel + main panel равной высоты.
|
{/* Grid items-stretch (default) — InfoPanel + main panel равной высоты.
|
||||||
* gap-3 — closer panels per user feedback ("ближе расположены"). */}
|
* gap-3 — closer panels per user feedback ("ближе расположены").
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-3 mt-2">
|
* QA bug #1: 1fr → minmax(0,1fr) — без minmax(0,…) grid track имеет
|
||||||
|
* implicit min-content (auto), что позволяет records table вырасти
|
||||||
|
* шире viewport. После закрытия Radix Dialog снимается body padding-
|
||||||
|
* right (~15px), 1fr пересчитывается и ломает layout (исчезает
|
||||||
|
* первая колонка под overflow-hidden card). minmax(0,1fr) фиксирует
|
||||||
|
* нижнюю границу 0 → overflow-x-auto в Table primitive работает. */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[220px_minmax(0,1fr)] gap-3 mt-2">
|
||||||
{detailQuery.data && (
|
{detailQuery.data && (
|
||||||
<EditorInfoPanel
|
<EditorInfoPanel
|
||||||
detail={detailQuery.data}
|
detail={detailQuery.data}
|
||||||
@@ -835,8 +841,12 @@ function DictionaryDetail() {
|
|||||||
|
|
||||||
{/* Main editor panel — tab bar + toolbar + tab content в bordered card
|
{/* Main editor panel — tab bar + toolbar + tab content в bordered card
|
||||||
* per redesign prototype. Visual containment отделяет editor от
|
* per redesign prototype. Visual containment отделяет editor от
|
||||||
* page chrome. */}
|
* page chrome.
|
||||||
<div className="rounded-lg border border-line bg-surface overflow-hidden">
|
* QA bug #1: overflow-hidden убран — клиппил records table при ширине
|
||||||
|
* контента больше колонки. Table primitive сам имеет overflow-x-auto;
|
||||||
|
* tabs ниже получают свой scroll контейнер. Card rounding продолжает
|
||||||
|
* работать через border-radius на самом div'е. */}
|
||||||
|
<div className="rounded-lg border border-line bg-surface">
|
||||||
|
|
||||||
{/* Editor tabs per handoff Stage 3.4 + redesign:
|
{/* Editor tabs per handoff Stage 3.4 + redesign:
|
||||||
* - counts в labels (Записи 127, Связи 6, Поля 12)
|
* - counts в labels (Записи 127, Связи 6, Поля 12)
|
||||||
@@ -1189,7 +1199,7 @@ function DictionaryDetail() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell hideBelow="md">
|
<TableCell hideBelow="md" className="min-w-[180px] max-w-[320px] truncate">
|
||||||
{recordDisplayName(
|
{recordDisplayName(
|
||||||
r.data,
|
r.data,
|
||||||
detailQuery.data?.defaultLocale ?? 'ru-RU',
|
detailQuery.data?.defaultLocale ?? 'ru-RU',
|
||||||
@@ -1981,8 +1991,15 @@ function EditorTabBar({
|
|||||||
{ id: 'events', label: t('editor.tab.events', { defaultValue: 'События' }) },
|
{ id: 'events', label: t('editor.tab.events', { defaultValue: 'События' }) },
|
||||||
{ id: 'history', label: t('editor.tab.history', { defaultValue: 'История' }) },
|
{ id: 'history', label: t('editor.tab.history', { defaultValue: 'История' }) },
|
||||||
]
|
]
|
||||||
|
// QA bug #3: на 1075px tabs (6 шт.) + actions (3 кнопки) переполняли flex
|
||||||
|
// row → "+ Создать" обрезалось до "+ Соз". Решение: actions выносим в
|
||||||
|
// отдельный shrink-0 контейнер, tabs кладём в overflow-x-auto wrapper
|
||||||
|
// с min-w-0 → tabs скроллятся горизонтально если cramped, actions всегда
|
||||||
|
// видны полностью. whitespace-nowrap на tab buttons чтобы текст не
|
||||||
|
// переносился в кружок счётчика.
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-0 border-b border-line w-full">
|
<div className="flex items-center border-b border-line w-full">
|
||||||
|
<div className="flex items-center gap-0 overflow-x-auto flex-1 min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = active === tab.id
|
const isActive = active === tab.id
|
||||||
const count = counts?.[tab.id]
|
const count = counts?.[tab.id]
|
||||||
@@ -1993,7 +2010,7 @@ function EditorTabBar({
|
|||||||
onClick={() => onChange(tab.id)}
|
onClick={() => onChange(tab.id)}
|
||||||
aria-pressed={isActive}
|
aria-pressed={isActive}
|
||||||
className={[
|
className={[
|
||||||
'px-3.5 h-10 text-body transition-colors -mb-px border-b-2 inline-flex items-center gap-1.5',
|
'px-3.5 h-10 text-body transition-colors -mb-px border-b-2 inline-flex items-center gap-1.5 shrink-0 whitespace-nowrap',
|
||||||
isActive
|
isActive
|
||||||
? 'border-accent text-ink font-semibold'
|
? 'border-accent text-ink font-semibold'
|
||||||
: 'border-transparent text-ink-2 hover:text-ink',
|
: 'border-transparent text-ink-2 hover:text-ink',
|
||||||
@@ -2006,7 +2023,10 @@ function EditorTabBar({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
{actions && <div className="ml-auto flex items-center gap-2 pl-2">{actions}</div>}
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div className="flex items-center gap-2 pl-2 pr-2 shrink-0">{actions}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -483,9 +483,12 @@ function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) {
|
|||||||
sort.key === key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''
|
sort.key === key ? (sort.dir === 'asc' ? ' ↑' : ' ↓') : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// No outer rounded/border wrapper — outer panel handles that (toolbar + table
|
// QA bug #4: оборачиваем в overflow-x-auto wrapper чтобы при PUBLIC+INTERNAL
|
||||||
// в одной card). Just table with white header per user feedback.
|
// с extra columns table не клипал name'ы под parent overflow-hidden card.
|
||||||
<table className="w-full bg-surface">
|
// table:min-w[760px] — нижняя граница ширины (sum min-content всех колонок),
|
||||||
|
// ниже неё включается horizontal scroll.
|
||||||
|
<div className="w-full overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[760px] bg-surface">
|
||||||
<thead className="bg-surface border-b border-line">
|
<thead className="bg-surface border-b border-line">
|
||||||
<tr>
|
<tr>
|
||||||
<SortableHeader onClick={() => toggleSort('title')} active={sort.key === 'title'}>
|
<SortableHeader onClick={() => toggleSort('title')} active={sort.key === 'title'}>
|
||||||
@@ -530,6 +533,7 @@ function DictionaryListTable({ rows }: { rows: DictionaryDefinition[] }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,8 +627,10 @@ function DictRow({ d, t }: { d: DictionaryDefinition; t: TFunc }) {
|
|||||||
onKeyDown={handleKey}
|
onKeyDown={handleKey}
|
||||||
className="relative border-b border-line-2 last:border-b-0 cursor-pointer bg-surface hover:bg-surface-2 focus-visible:outline-none focus-visible:bg-accent-bg/30 transition-colors"
|
className="relative border-b border-line-2 last:border-b-0 cursor-pointer bg-surface hover:bg-surface-2 focus-visible:outline-none focus-visible:bg-accent-bg/30 transition-colors"
|
||||||
>
|
>
|
||||||
{/* name + subtitle (description short) + scope-color left stripe */}
|
{/* name + subtitle (description short) + scope-color left stripe.
|
||||||
<td className="relative px-3 py-2 align-top min-w-0">
|
* QA bug #4: min-w-[220px] — обеспечивает что name column не схлопывается
|
||||||
|
* до букв. При недостатке ширины table получает horizontal scroll. */}
|
||||||
|
<td className="relative px-3 py-2 align-top min-w-[220px]">
|
||||||
{/* 3px scope stripe — absolutely positioned на левом краю row.
|
{/* 3px scope stripe — absolutely positioned на левом краю row.
|
||||||
* td имеет relative чтобы before работало в table context. */}
|
* td имеет relative чтобы before работало в table context. */}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ export const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-0 z-50 bg-black/40 backdrop-blur-[1px]',
|
// QA bug #6: bg-black/40 + 1px blur был "почти невидимым" — backdrop
|
||||||
|
// не фокусировал внимание на modal'е. Bumped до /55 (заметно темнее
|
||||||
|
// при сохранении читабельности фона) + 2px blur. Visual reference:
|
||||||
|
// Radix docs/shadcn используют bg-black/80 по умолчанию, /55 —
|
||||||
|
// компромисс между нашим тёплым палитром и стандартом.
|
||||||
|
'fixed inset-0 z-50 bg-black/55 backdrop-blur-[2px]',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
className,
|
className,
|
||||||
@@ -69,13 +74,18 @@ export const DialogContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
DialogContentProps
|
DialogContentProps
|
||||||
>(function DialogContent({ className, children, size = 'md', hideClose, ...props }, ref) {
|
>(function DialogContent({ className, children, size = 'md', hideClose, ...props }, ref) {
|
||||||
|
// duration-150 (вместо 200) — анимация закрытия укладывается в один frame
|
||||||
|
// на 60Hz без видимого "застывания" между state=closed и unmount. QA bug #2:
|
||||||
|
// "модал становится полупрозрачным, но остаётся отрисованным в течение
|
||||||
|
// секунд" — на slow CPU 200ms ощущается как несколько кадров; cutoff 150ms
|
||||||
|
// совпадает с tw-animate-css дефолтами и убирает рассинхрон визуала/DOM.
|
||||||
return (
|
return (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed z-50 grid gap-4 border border-line bg-surface p-6 shadow-2xl duration-200',
|
'fixed z-50 grid gap-4 border border-line bg-surface p-6 shadow-2xl duration-150',
|
||||||
// Centered modal — left/top 50% + translate. В <880px mode пользуем
|
// Centered modal — left/top 50% + translate. В <880px mode пользуем
|
||||||
// max-width 95vw чтобы modal не упирался в edges (вместо fullscreen
|
// max-width 95vw чтобы modal не упирался в edges (вместо fullscreen
|
||||||
// inset-0 который конфликтовал с left-1/2 → modal off-screen).
|
// inset-0 который конфликтовал с left-1/2 → modal off-screen).
|
||||||
|
|||||||
Reference in New Issue
Block a user