Files
EventHubFrontAdmin/src/components/CommandPalette.tsx
T
aleksey 87c6b55e81
CI / test (push) Failing after 14m2s
CI / push-image (push) Has been skipped
CI / ci-done (push) Failing after 0s
feat(admin): Playwright E2E каркас — TESTIDS, login/shell/roles, CI mock. Refs EventHub/EventHubFrontAdmin#33 #34 #35
2026-07-14 22:18:34 +03:00

175 lines
6.8 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Search } from 'lucide-react';
import { useAuthStore } from '@/store/authStore';
import { NAV_GROUPS, filterNavGroupsByRole, canAccessRoute } from '@/config/adminAccess';
import {
Dialog,
DialogContent,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
type PaletteItem = {
id: string;
label: string;
group: string;
path: string;
};
interface CommandPaletteProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/** Looks like EventHub entity ids (base64url-ish). */
const ENTITY_ID_RE = /^[A-Za-z0-9_-]{16,40}$/;
export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const role = useAuthStore((s) => s.user?.role);
const [query, setQuery] = useState('');
const [activeIndex, setActiveIndex] = useState(0);
const items = useMemo<PaletteItem[]>(() => {
const groups = filterNavGroupsByRole(NAV_GROUPS, role);
const routes = groups.flatMap((g) =>
g.items.map((item) => ({
id: item.key,
label: t(item.label),
group: t(g.label),
path: item.key,
}))
);
routes.push(
{ id: '/profile', label: t('nav.profile'), group: t('navGroup.account'), path: '/profile' },
{ id: '/dashboard', label: t('nav.dashboard'), group: t('navGroup.overview'), path: '/dashboard' }
);
const seen = new Set<string>();
return routes.filter((r) => {
if (seen.has(r.path)) return false;
seen.add(r.path);
return true;
});
}, [role, t]);
const filtered = useMemo(() => {
const q = query.trim();
const qLower = q.toLowerCase();
const navHits = !qLower
? items
: items.filter(
(item) =>
item.label.toLowerCase().includes(qLower) ||
item.group.toLowerCase().includes(qLower) ||
item.path.toLowerCase().includes(qLower)
);
if (!ENTITY_ID_RE.test(q)) return navHits;
const jumps: PaletteItem[] = [];
const pushIf = (path: string, labelKey: string, id: string) => {
if (canAccessRoute(path.split('?')[0], role)) {
jumps.push({
id,
label: `${t(labelKey)} · ${q}`,
group: t('layout.paletteJumpGroup'),
path,
});
}
};
pushIf(`/inbox/reports?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenReportInbox', `jump-report-inbox-${q}`);
pushIf(`/reports/${encodeURIComponent(q)}`, 'layout.paletteOpenReport', `jump-report-${q}`);
pushIf(`/inbox/tickets?selected=${encodeURIComponent(q)}`, 'layout.paletteOpenTicketInbox', `jump-ticket-inbox-${q}`);
pushIf(`/tickets/${encodeURIComponent(q)}`, 'layout.paletteOpenTicket', `jump-ticket-${q}`);
pushIf(`/events/${encodeURIComponent(q)}`, 'layout.paletteOpenEvent', `jump-event-${q}`);
pushIf(`/users/${encodeURIComponent(q)}`, 'layout.paletteOpenUser', `jump-user-${q}`);
return [...jumps, ...navHits];
}, [items, query, role, t]);
useEffect(() => {
setActiveIndex(0);
}, [query, open]);
useEffect(() => {
if (!open) setQuery('');
}, [open]);
const go = useCallback(
(path: string) => {
onOpenChange(false);
navigate(path);
},
[navigate, onOpenChange]
);
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, Math.max(filtered.length - 1, 0)));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && filtered[activeIndex]) {
e.preventDefault();
go(filtered[activeIndex].path);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-testid="command-palette"
className="gap-0 overflow-hidden p-0 sm:max-w-lg"
aria-describedby={undefined}
>
<DialogTitle className="sr-only">{t('layout.paletteTitle')}</DialogTitle>
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 text-muted-foreground" />
<Input
data-testid="palette-input"
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder={t('layout.palettePlaceholder')}
className="h-12 border-0 shadow-none focus-visible:ring-0"
/>
</div>
<ul className="max-h-72 overflow-auto py-2" role="listbox">
{filtered.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-muted-foreground">
{t('common.nothingFound')}
</li>
) : (
filtered.map((item, index) => (
<li key={item.id}>
<button
type="button"
role="option"
aria-selected={index === activeIndex}
className={cn(
'flex w-full items-center justify-between px-4 py-2 text-left text-sm',
index === activeIndex ? 'bg-accent' : 'hover:bg-muted/60'
)}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => go(item.path)}
>
<span className="font-medium">{item.label}</span>
<span className="text-xs text-muted-foreground">{item.group}</span>
</button>
</li>
))
)}
</ul>
<div className="border-t px-3 py-2 text-[11px] text-muted-foreground">
{t('layout.paletteHints')}
</div>
</DialogContent>
</Dialog>
);
}