feat(ui): batch UI polish — auth/lang to sidebar, search h-7, info overflow, graph zoom
User feedback batch — multiple iterations of UX refinement.
Layout shifts:
- TopBar right cluster: AuthBadge + LanguageSwitch переехали в Sidebar
footer per user ("вход в сайдбар перенесем", "ru/en тоже в сайд баре").
TopBar теперь: breadcrumb + search + Docs + VersionBadge + ThemeSwitch
only. Освобождает место + concentrates user/lang controls в одном месте.
- Sidebar footer: новая структура — Lang toggle (Язык [RU/EN]) + Auth block
(avatar+name+logout authed, Sign-in CTA anonymous) + collapse button.
Refactored из inline JSX в named subcomponents (AuthedUserBlock,
SignInCta, SidebarFooter).
Sizing / spacing:
- TopBar SearchInput: h-9 → h-7 to match LangSwitch/ThemeSwitch (+ Sign-in
кнопка). User: "поиск по справочникам высоту выровняй". Override via
`!h-7 text-cell` className на SearchInput.
- TopBar: h-14 → h-11 (slimmer). Sidebar logo block matches h-11.
Catalog search:
- Catalog page input в toolbar — restored для on-page filter ("Catalog
search никак не работает" → проверил, работает; добавил TopBar context-
aware behavior где TopBar input на /dictionaries route синхронизируется
с URL ?q= live).
- TopBar search context-aware: catalog mode = live filter; else = submit
→ /search (records JSONB search).
InfoPanel:
- Description: `text-body text-ink-2 leading-relaxed` → +
`break-words overflow-hidden`. Long slash-separated values
(LZW/Deflate/JPEG2000/ZSTD/LERC) теперь wrap'ятся внутри panel вместо
overflow за границы (user: "описание убежало за границы панели").
- Container: + `min-w-0 overflow-hidden` для proper flex shrinking.
Graph:
- Zoom controls overlay (+/-/1:1/N%) + mouse wheel zoom toward cursor +
drag pan по empty space. Per user: "Граф связей еще не зумируется".
WorkflowBanner:
- Moved в InfoPanel banner slot (compact flex-col layout) — free
horizontal space выше editor + concentrates status info в left rail.
Auth:
- AuthBadge: primary Button → compact h-7 outline button. Matches
TopBar toolbar control style.
- RequireAuth: убран visible amber error banner "Авторизация недоступна:
Failed to fetch" — silent fall-through к anonymous mode (user feedback).
- routes/index.tsx: beforeLoad skip redirect если есть `?code=` в URL
(OIDC callback fix). HomeIndex компонент рендерит null + redirect на
/dictionaries после auth complete.
LanguageSwitch:
- h-8 + items-stretch + inner items-center — matches ThemeSwitch height.
- Later moved entirely в Sidebar footer (h-7 button only) per user.
This commit is contained in:
@@ -11,7 +11,6 @@ import {
|
||||
IconButton,
|
||||
LoadingBlock,
|
||||
Modal,
|
||||
Panel,
|
||||
SearchInput,
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -48,7 +47,7 @@ import { AoiPickerDialog, type AoiResult } from '@/components/geo/AoiPickerDialo
|
||||
import { TimeTravelModal } from '@/components/timetravel/TimeTravelModal'
|
||||
import { nowIsoLocal } from '@/lib/dates'
|
||||
import { recordDisplayName } from '@/lib/locales'
|
||||
import { SCOPE_BORDER_TOP, SCOPE_DOT, SCOPE_ORDER } from '@/lib/scope-style'
|
||||
import { SCOPE_BORDER_TOP } from '@/lib/scope-style'
|
||||
|
||||
/**
|
||||
* URL search params для filter state — share-friendly, persist между F5.
|
||||
@@ -224,18 +223,9 @@ function DictionaryDetail() {
|
||||
})
|
||||
}
|
||||
|
||||
const toggleScope = (scope: DataScope) => {
|
||||
const next = new Set(scopeFilter)
|
||||
if (next.has(scope)) next.delete(scope)
|
||||
else next.add(scope)
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
scopes: next.size > 0 ? Array.from(next).join(',') : undefined,
|
||||
}),
|
||||
replace: true,
|
||||
})
|
||||
}
|
||||
// toggleScope удалён вместе с scope chips в records toolbar
|
||||
// (заменены на RecordColumnFilters status/operator dropdowns).
|
||||
// scopeFilter URL param остаётся read-only — backwards-compat shared links.
|
||||
|
||||
const handleAoiApply = (result: AoiResult) => {
|
||||
setAoi(result)
|
||||
@@ -258,6 +248,7 @@ function DictionaryDetail() {
|
||||
}
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setColumnFilters({})
|
||||
void navigate({
|
||||
search: (prev) => ({ ...prev, q: undefined, scopes: undefined }),
|
||||
replace: true,
|
||||
@@ -276,13 +267,25 @@ function DictionaryDetail() {
|
||||
return Array.from(set).sort((a, b) => a - b)
|
||||
}, [recordsResult.data])
|
||||
|
||||
// Column filters — per-column value match (e.g. status='operational',
|
||||
// operator='Роскосмос'). Auto-derived из schema enum/x-references properties.
|
||||
// Replaces прошлый scope chips filter в records toolbar per user feedback.
|
||||
const [columnFilters, setColumnFilters] = useState<Record<string, string>>({})
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
const raw = recordsResult.data ?? []
|
||||
if (raw.length === 0) return raw
|
||||
const q = search.trim().toLowerCase()
|
||||
const scopes = scopeFilter
|
||||
const colKeys = Object.entries(columnFilters)
|
||||
return raw.filter((r) => {
|
||||
if (scopes.size > 0 && !scopes.has(r.dataScope)) return false
|
||||
// Column filters — exact match по data[field].
|
||||
for (const [field, value] of colKeys) {
|
||||
if (!value) continue
|
||||
const v = (r.data as Record<string, unknown>)[field]
|
||||
if (String(v ?? '') !== value) return false
|
||||
}
|
||||
if (q.length === 0) return true
|
||||
// Search по businessKey + JSON.stringify(data) — простой,
|
||||
// покрывает локализованные поля, code, описания. Stringify
|
||||
@@ -291,9 +294,12 @@ function DictionaryDetail() {
|
||||
const haystack = JSON.stringify(r.data).toLowerCase()
|
||||
return haystack.includes(q)
|
||||
})
|
||||
}, [recordsResult.data, search, scopeFilter])
|
||||
}, [recordsResult.data, search, scopeFilter, columnFilters])
|
||||
|
||||
const filtersActive = search.length > 0 || scopeFilter.size > 0
|
||||
const filtersActive =
|
||||
search.length > 0 ||
|
||||
scopeFilter.size > 0 ||
|
||||
Object.values(columnFilters).some(Boolean)
|
||||
const filteredCount = filteredRecords.length
|
||||
|
||||
// Client-side pagination — типичный словарь до 100 записей, server-side
|
||||
@@ -608,11 +614,19 @@ function DictionaryDetail() {
|
||||
[Sidebar (220px global)] [InfoPanel 220px] [main content]
|
||||
Info panel — sticky слева с dict metadata + relations.
|
||||
На <lg viewport schedule переключается в stacked (info above content). */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-4 lg:gap-6 mt-2">
|
||||
{/* Grid items-stretch (default) — InfoPanel + main panel равной высоты.
|
||||
* gap-3 — closer panels per user feedback ("ближе расположены"). */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[220px_1fr] gap-3 mt-2">
|
||||
{detailQuery.data && (
|
||||
<EditorInfoPanel
|
||||
detail={detailQuery.data}
|
||||
recordCount={totalRecords}
|
||||
banner={
|
||||
// WorkflowBanner живёт в InfoPanel per user feedback ("плашки
|
||||
// пусть в левой панели будут") — освобождает horizontal space
|
||||
// editor'а и concentrates status info в одном месте.
|
||||
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
|
||||
}
|
||||
actions={{
|
||||
onTimeTravel: () => setTimeTravelOpen((v) => !v),
|
||||
timeTravelActive: Boolean(timeTravelAt),
|
||||
@@ -631,10 +645,8 @@ function DictionaryDetail() {
|
||||
)}
|
||||
|
||||
<div className="min-w-0 space-y-4">
|
||||
{/* WorkflowBanner — status (live/review/draft) above tab bar per handoff Screen 2. */}
|
||||
{detailQuery.data && (
|
||||
<WorkflowBanner detail={detailQuery.data} pendingCount={pendingByKey.size} />
|
||||
)}
|
||||
{/* WorkflowBanner перенесён в InfoPanel banner slot — освобождает
|
||||
* horizontal space здесь, concentrates все status notices в leftpanel. */}
|
||||
|
||||
{/* Main editor panel — tab bar + toolbar + tab content в bordered card
|
||||
* per redesign prototype. Visual containment отделяет editor от
|
||||
@@ -794,8 +806,12 @@ function DictionaryDetail() {
|
||||
|
||||
{recordsResult.data && recordsResult.data.length > 0 && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex-1 min-w-[280px] max-w-md">
|
||||
{/* Records toolbar — attached к table panel (tinted top section,
|
||||
* border-b separator от table body). User feedback: "бар таблицы
|
||||
* не должен быть оторван от нее" — applied к catalog в MR 88,
|
||||
* теперь и в editor records tab. */}
|
||||
<div className="flex flex-wrap items-center gap-3 bg-surface-2 border-b border-line px-3 py-2">
|
||||
<div className="flex-1 min-w-[240px] max-w-md">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
@@ -803,31 +819,16 @@ function DictionaryDetail() {
|
||||
aria-label={t('dict.filter.searchPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" role="group" aria-label={t('dict.filter.scopeLabel')}>
|
||||
{SCOPE_ORDER.map((s) => {
|
||||
const active = scopeFilter.has(s)
|
||||
return (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => toggleScope(s)}
|
||||
aria-pressed={active}
|
||||
className={[
|
||||
'text-cap inline-flex items-center gap-1.5 px-2.5 py-1 rounded-sm border transition-colors',
|
||||
active
|
||||
? 'border-accent bg-accent/8 text-accent'
|
||||
: 'border-line bg-white text-ink-2 hover:border-ink/40',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${SCOPE_DOT[s]}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{t(`dict.list.section.${s}`)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Column filter dropdowns per redesign — auto-detect filterable
|
||||
* columns (status, enum-typed fields). User feedback: "фильтр в
|
||||
* справочнике по статусу скорее нужен чем по scope". Scope chips
|
||||
* убраны — scope viewable в SCOPE column, less useful as filter. */}
|
||||
<RecordColumnFilters
|
||||
schema={detailQuery.data?.schemaJson}
|
||||
records={recordsResult.data ?? []}
|
||||
value={columnFilters}
|
||||
onChange={setColumnFilters}
|
||||
/>
|
||||
{filtersActive && (
|
||||
<div className="flex items-center gap-2 text-cell text-ink-2">
|
||||
<span>
|
||||
@@ -856,7 +857,8 @@ function DictionaryDetail() {
|
||||
onClear={clearSelection}
|
||||
/>
|
||||
)}
|
||||
<Panel>
|
||||
{/* No <Panel> wrap — outer editor panel handles border. Direct
|
||||
* table → continuous visual flow toolbar→table в one card. */}
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
@@ -976,7 +978,6 @@ function DictionaryDetail() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Panel>
|
||||
{totalPages > 1 && (
|
||||
<RecordsPagination
|
||||
page={page}
|
||||
@@ -2032,3 +2033,111 @@ function HistoryTabContent({ detail }: { detail?: import('@/api/client').Diction
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* RecordColumnFilters — auto-derive filterable columns из schema +
|
||||
* records data, render as compact select dropdowns "status ▾", "operator ▾".
|
||||
*
|
||||
* <p>Filterable columns детектятся по двум сигналам:
|
||||
* <ol>
|
||||
* <li>schema property имеет `enum` array → use enum values as options</li>
|
||||
* <li>иначе walk records, собирай уникальные значения для top non-i18n
|
||||
* string properties (status, operator, type). Если cardinality
|
||||
* (unique count) ≤ 12 — это reasonable filter; иначе skip (нужен search).</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Replaces прошлый scope filter chips per user feedback ("фильтр в
|
||||
* справочнике по статусу скорее нужен чем по scope").
|
||||
*/
|
||||
function RecordColumnFilters({
|
||||
schema,
|
||||
records,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
schema?: import('@/api/client').JsonSchema
|
||||
records: FlattenedRecord[]
|
||||
value: Record<string, string>
|
||||
onChange: (v: Record<string, string>) => void
|
||||
}) {
|
||||
const filters = useMemo(() => {
|
||||
if (!schema?.properties) return []
|
||||
const out: { field: string; label: string; options: string[] }[] = []
|
||||
for (const [field, prop] of Object.entries(schema.properties)) {
|
||||
// Skip localized & FK fields — слишком много unique values.
|
||||
if (prop['x-localized']) continue
|
||||
// Enum-typed → use schema enum values.
|
||||
if (Array.isArray(prop.enum) && prop.enum.length > 0 && prop.enum.length <= 20) {
|
||||
out.push({
|
||||
field,
|
||||
label: field,
|
||||
options: prop.enum.map((e) => String(e)).sort(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
// Skip типы где dropdown не имеет смысла:
|
||||
// - date/date-time/email/url: высокая cardinality, нужен picker/search
|
||||
// - numbers (integer/number): range filter полезнее dropdown
|
||||
if (
|
||||
prop.format === 'date' ||
|
||||
prop.format === 'date-time' ||
|
||||
prop.format === 'email' ||
|
||||
prop.format === 'url' ||
|
||||
prop.type === 'integer' ||
|
||||
prop.type === 'number' ||
|
||||
prop.type === 'boolean' ||
|
||||
prop.type === 'array' ||
|
||||
prop.type === 'object'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
// FK (x-references) → use ALL unique values from records (target_dict
|
||||
// values typically have low cardinality at usage site).
|
||||
const isFk = typeof prop['x-references'] === 'string'
|
||||
// String-typed без enum но с low cardinality в records.
|
||||
if (prop.type === 'string' || isFk) {
|
||||
const seen = new Set<string>()
|
||||
for (const r of records) {
|
||||
const v = (r.data as Record<string, unknown>)[field]
|
||||
if (v !== null && v !== undefined && v !== '') {
|
||||
seen.add(String(v))
|
||||
if (seen.size > 12) break
|
||||
}
|
||||
}
|
||||
if (seen.size > 0 && seen.size <= 12) {
|
||||
out.push({
|
||||
field,
|
||||
label: field,
|
||||
options: Array.from(seen).sort(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.slice(0, 3) // Cap 3 filters max — не растянуть toolbar.
|
||||
}, [schema, records])
|
||||
|
||||
if (filters.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{filters.map((f) => (
|
||||
<select
|
||||
key={f.field}
|
||||
value={value[f.field] ?? ''}
|
||||
onChange={(e) =>
|
||||
onChange({ ...value, [f.field]: e.target.value })
|
||||
}
|
||||
className="h-8 px-2 rounded-md border border-line bg-surface text-cell text-ink-2 hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent cursor-pointer"
|
||||
aria-label={`Filter by ${f.label}`}
|
||||
>
|
||||
<option value="">{f.label} ▾</option>
|
||||
{f.options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user