Files
mdm-ordinis/ordinis-admin-ui/src/components/audit/UserPicker.tsx
T
Zimin A.N. 3f05741ed1 feat: /audit поле «Пользователь» — typeahead picker из user-cache
Backend: GET /api/v1/admin/users (INTERNAL+ scope) — list cached юзеров
из user_display_cache. UserDisplayService.listAll(limit) сортирует по
preferred_username, hard-cap 1000. Контроллер: UserDisplayController.list().

Frontend:
- queries.ts → useAllUsers() / allUsersQuery (5min stale, 403/404 → []).
- components/audit/UserPicker.tsx (новый, ~140 строк) — cmdk-powered
  combobox: trigger показывает username (или UUID prefix fallback),
  popover c поиском по preferredUsername/email/UUID. X сбрасывает selection.
- routes/audit.tsx — TextInput → UserPicker.

UX: раньше юзер вручную копипастил UUID из audit-row'а; теперь поиск
по имени/email типа `solov` → находит `solovyev.da`.
2026-05-25 16:48:41 +03:00

153 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import * as React from 'react'
import { useMemo, useState } from 'react'
import { CaretDownIcon, MagnifyingGlassIcon, UserIcon, XIcon } from '@phosphor-icons/react'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from 'cmdk'
import { Popover, PopoverTrigger, PopoverContent, FieldLabel } from '@/ui'
import { useAllUsers, type UserListItem } from '@/api/queries'
import { cn } from '@/lib/utils'
/**
* Single-value user picker — cmdk-powered combobox для audit-фильтра
* «Пользователь». Заменяет ручной TextInput на typeahead по username/email/UUID.
*
* <p>Источник: GET /api/v1/admin/users (cached user_display table).
* INTERNAL+ scope требуется на backend; non-INTERNAL получит пустой
* список и падает на input fallback (поле остаётся редактируемым).
*
* <p>UX: trigger показывает username (или UUID prefix как fallback);
* X сбрасывает selection inline. В popover'е — поиск по subname/email/UUID.
*/
const labelFor = (u: UserListItem): string =>
u.preferredUsername || u.name || `${u.sub.slice(0, 8)}`
const secondaryFor = (u: UserListItem): string | null => {
if (u.email && u.email !== u.preferredUsername) return u.email
return null
}
export type UserPickerProps = {
value: string | undefined
onChange: (sub: string | undefined) => void
label?: React.ReactNode
placeholder?: string
disabled?: boolean
}
export function UserPicker({ value, onChange, label, placeholder, disabled }: UserPickerProps) {
const [open, setOpen] = useState(false)
const usersQ = useAllUsers()
const usersBySub = useMemo(() => {
const m = new Map<string, UserListItem>()
for (const u of usersQ.data ?? []) m.set(u.sub, u)
return m
}, [usersQ.data])
const selected = value ? usersBySub.get(value) : undefined
const triggerLabel = useMemo(() => {
if (!value) return placeholder ?? 'Любой'
if (selected) return labelFor(selected)
// Value есть, но юзера нет в кеше — показываем UUID prefix.
return `${value.slice(0, 8)}`
}, [value, selected, placeholder])
// Поиск: substring по preferredUsername / name / email / sub. cmdk
// делает client-side фильтрацию по value атрибуту CommandItem'а;
// концатенируем все поля в один searchable string.
const searchableValue = (u: UserListItem): string =>
[u.preferredUsername, u.name, u.email, u.sub].filter(Boolean).join(' ')
const clear = (e: React.MouseEvent) => {
e.stopPropagation()
onChange(undefined)
}
return (
<div className="flex flex-col gap-1">
{label && <FieldLabel>{label}</FieldLabel>}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className={cn(
'flex items-center justify-between gap-2 h-9 w-full rounded-md border border-line bg-surface px-3 text-body text-ink',
'hover:border-line-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
)}
>
<span className="flex items-center gap-2 flex-1 min-w-0">
<UserIcon size={14} weight="regular" className="shrink-0 text-mute" />
<span className={cn('truncate text-left', !value && 'text-mute')}>
{triggerLabel}
</span>
</span>
<span className="flex items-center gap-1 shrink-0">
{value && (
<button
type="button"
aria-label="Сбросить пользователя"
onClick={clear}
className="p-0.5 rounded hover:bg-line/40 text-mute hover:text-ink"
>
<XIcon size={12} weight="bold" />
</button>
)}
<CaretDownIcon size={12} weight="regular" className="text-mute" />
</span>
</button>
</PopoverTrigger>
<PopoverContent
align="start"
className="p-0 w-[min(95vw,360px)]"
>
<Command className="bg-surface rounded-md">
<div className="flex items-center gap-2 px-2 border-b border-line">
<MagnifyingGlassIcon size={14} weight="regular" className="text-mute shrink-0" />
<CommandInput
placeholder="Поиск по имени, email или UUID…"
className="h-9 w-full bg-transparent outline-none text-body text-ink placeholder:text-mute"
/>
</div>
<CommandList className="max-h-72 overflow-y-auto py-1">
<CommandEmpty className="px-3 py-2 text-cell text-mute">
{usersQ.isLoading ? 'Загрузка…' : 'Пользователи не найдены'}
</CommandEmpty>
<CommandGroup>
{(usersQ.data ?? []).map((u) => {
const sec = secondaryFor(u)
return (
<CommandItem
key={u.sub}
value={searchableValue(u)}
onSelect={() => {
onChange(u.sub)
setOpen(false)
}}
className={cn(
'flex items-center gap-2 px-2.5 py-1.5 rounded cursor-pointer text-body',
value === u.sub
? 'bg-accent-bg text-accent'
: 'text-ink hover:bg-surface-2 aria-selected:bg-surface-2',
)}
>
<UserIcon size={14} weight="regular" className="shrink-0 text-mute" />
<span className="flex-1 min-w-0">
<span className="block truncate">{labelFor(u)}</span>
{sec && (
<span className="block truncate text-cap text-mute">{sec}</span>
)}
</span>
</CommandItem>
)
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)
}