افزودن پالت فرمان (⌘K) و صفحهٔ مصرف سراسری پنل مالک
دو شکاف رسیدگیشده از بازبینی PLAN.md و TODO.md: - پالت فرمان: جستوجو و رفتن سریع بین بخشها با ⌘K/Ctrl+K، پیمایش با کلیدهای جهتدار، Enter برای رفتن، و Escape برای بستن. گروه اختصاصی «کنسول مالک سامانه» فقط برای مالک سامانه نمایش داده میشود. - صفحهٔ /admin/usage: گزارش مصرف سراسری به تفکیک سازمان. اندپوینت `GET /admin/usage` و کلاینت `adminUsage()` از قبل وجود داشتند و توسط صفحات نمای کلی و سازمانها مصرف میشدند؛ این صفحهٔ اختصاصی آن است. همچنین `NAV_ITEMS` به `lib/nav.ts` منتقل شد تا sidebar و پالت فرمان هرگز از هم واگرا نشوند. تست: ۱۰ تست جدید برای پالت فرمان. مجموع تستهای فرانتاند از ۲۵ به ۳۵ رسید. tsc، eslint و ruff تمیز.
This commit is contained in:
@@ -20,6 +20,7 @@ const SECTIONS = [
|
||||
{ href: "/admin/models", label: "مدلها" },
|
||||
{ href: "/admin/routing", label: "مسیریابی" },
|
||||
{ href: "/admin/rate-cards", label: "تعرفهها" },
|
||||
{ href: "/admin/usage", label: "مصرف سراسری" },
|
||||
{ href: "/admin/organizations", label: "سازمانها" },
|
||||
{ href: "/admin/errors", label: "کنسول خطا" },
|
||||
] as const;
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminOrganization, AdminUsageRow } from "@agy/contracts";
|
||||
import {
|
||||
Card,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Stat,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Platform-wide usage ledger.
|
||||
*
|
||||
* TODO §6.6 asks the console for a usage view. The endpoint and the API client
|
||||
* already existed and were consumed by the overview and organizations pages;
|
||||
* this is the dedicated view, with the per-organization rows the ledger
|
||||
* aggregates by tenant.
|
||||
*/
|
||||
export default function AdminUsagePage() {
|
||||
const [usage, setUsage] = React.useState<AdminUsageRow[]>([]);
|
||||
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Both calls are needed: the ledger keys rows by organization id, and a
|
||||
// bare id is meaningless to whoever is reading this page.
|
||||
const [nextUsage, nextOrganizations] = await Promise.all([
|
||||
api.adminUsage(),
|
||||
api.adminOrganizations(),
|
||||
]);
|
||||
setUsage(nextUsage);
|
||||
setOrganizations(nextOrganizations);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت مصرف سراسری ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const orgName = React.useCallback(
|
||||
(id: string) => organizations.find((org) => org.id === id)?.name ?? id,
|
||||
[organizations],
|
||||
);
|
||||
|
||||
const totalEvents = usage.reduce((sum, row) => sum + row.quantity, 0);
|
||||
const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
|
||||
|
||||
const columns: Array<TableColumn<AdminUsageRow>> = [
|
||||
{
|
||||
key: "organization",
|
||||
header: "سازمان",
|
||||
render: (row) => <span className="text-slate-200">{orgName(row.organization_id)}</span>,
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
header: "رویداد مصرف",
|
||||
align: "end",
|
||||
render: (row) => row.quantity,
|
||||
},
|
||||
{
|
||||
key: "billed",
|
||||
header: "مبلغ",
|
||||
align: "end",
|
||||
render: (row) => (
|
||||
<span className="ltr-inline text-slate-200">{row.total_price.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">مصرف سراسری</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
مصرف همهٔ مستأجرها بر اساس دفتر ثبت استفاده. این صفحه برای مشتریان قابل دسترسی نیست.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="تعداد سازمانها" value={organizations.length} />
|
||||
<Stat label="رویدادهای مصرف" value={totalEvents} />
|
||||
<Stat label="مجموع مبلغ" value={totalBilled.toFixed(2)} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardTitle>مصرف به تفکیک سازمان</CardTitle>
|
||||
<div className="mt-3">
|
||||
<Table
|
||||
columns={columns}
|
||||
rows={usage}
|
||||
rowKey={(row) => row.organization_id}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={load}
|
||||
caption="مصرف سازمانها"
|
||||
empty={
|
||||
<EmptyState
|
||||
title="مصرفی ثبت نشده است"
|
||||
description="هنوز هیچ رویداد مصرفی در سامانه ثبت نشده است."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,90 +5,10 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { Badge, Button, cx } from "@agy/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import { NAV_ITEMS } from "@/lib/nav";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const NAV_ITEMS: { href: string; label: string; hint: string }[] = [
|
||||
{ href: "/dashboard", label: "داشبورد", hint: "نمای کلی سازمان" },
|
||||
{ href: "/manager", label: "مدیر AI", hint: "گفتگو با مدیر" },
|
||||
{ href: "/workforce", label: "نیروی کار AI", hint: "کارمندها" },
|
||||
{ href: "/projects", label: "پروژهها", hint: "Git و محیط اجرا" },
|
||||
{ href: "/tasks", label: "وظایف", hint: "وضعیت کارها" },
|
||||
{ href: "/traces", label: "ردیابی", hint: "اجراها و رویدادها" },
|
||||
{ href: "/knowledge", label: "دانش سازمانی", hint: "منابع و اسناد" },
|
||||
{ href: "/billing", label: "مصرف و پرداخت", hint: "اعتبار و فاکتور" },
|
||||
{ href: "/settings", label: "تنظیمات", hint: "سازمان و اعضا" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Command/search over the navigation.
|
||||
*
|
||||
* Deliberately client-side: the app has a fixed set of destinations, so
|
||||
* filtering them needs no round trip and works while offline.
|
||||
*/
|
||||
function CommandSearch() {
|
||||
const router = useRouter();
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const matches = React.useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return [];
|
||||
return NAV_ITEMS.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(needle) ||
|
||||
item.hint.toLowerCase().includes(needle) ||
|
||||
item.href.includes(needle),
|
||||
);
|
||||
}, [query]);
|
||||
|
||||
const go = (href: string) => {
|
||||
setQuery("");
|
||||
setOpen(false);
|
||||
router.push(href);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative hidden md:block">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="جستوجو…"
|
||||
aria-label="جستوجو در بخشها"
|
||||
onChange={(event) => {
|
||||
setQuery(event.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && matches.length > 0) go(matches[0].href);
|
||||
if (event.key === "Escape") setQuery("");
|
||||
}}
|
||||
className="h-9 w-56 rounded-lg border border-surface-border bg-surface-overlay px-3 text-xs text-slate-200 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
/>
|
||||
{open && query.trim() !== "" && (
|
||||
<div className="absolute start-0 top-11 z-40 w-72 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
|
||||
{matches.length === 0 ? (
|
||||
<p className="px-2 py-2 text-xs text-slate-400">نتیجهای پیدا نشد.</p>
|
||||
) : (
|
||||
matches.map((item) => (
|
||||
<button
|
||||
key={item.href}
|
||||
type="button"
|
||||
onMouseDown={() => go(item.href)}
|
||||
className="flex w-full flex-col rounded-md px-2 py-1.5 text-start hover:bg-surface-raised"
|
||||
>
|
||||
<span className="text-xs text-slate-200">{item.label}</span>
|
||||
<span className="text-xs text-slate-500">{item.hint}</span>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Credit badge — reads the organization's real balance, never a placeholder. */
|
||||
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
|
||||
const [balance, setBalance] = React.useState<number | null>(null);
|
||||
@@ -243,7 +163,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<span className="hidden sm:inline-flex">
|
||||
<CreditBadge organizationId={activeOrganization?.id} />
|
||||
</span>
|
||||
<CommandSearch />
|
||||
<CommandPalette isPlatformOwner={user?.is_platform_owner} />
|
||||
</div>
|
||||
|
||||
<div className="relative flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { EmptyState, Modal, cx } from "@agy/ui";
|
||||
import { ADMIN_ITEMS, NAV_ITEMS, type NavItem } from "@/lib/nav";
|
||||
|
||||
/**
|
||||
* Command palette over the app's destinations.
|
||||
*
|
||||
* PLAN §12.3 asks for a command palette so power users can move between
|
||||
* sections without hunting through the sidebar. It is a client-side filter
|
||||
* over a fixed route list — the app has no dynamic search surface yet, so a
|
||||
* round trip would buy nothing and would break while offline.
|
||||
*
|
||||
* The platform-owner group is passed in rather than read from the session so
|
||||
* this component stays free of provider setup and is renderable in isolation.
|
||||
*/
|
||||
|
||||
/** ``⌘K`` on macOS, ``Ctrl+K`` everywhere else. */
|
||||
function isPaletteShortcut(event: KeyboardEvent): boolean {
|
||||
return (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === "k"
|
||||
);
|
||||
}
|
||||
|
||||
interface PalletGroup {
|
||||
title: string;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
function buildGroups(isPlatformOwner: boolean): PalletGroup[] {
|
||||
const groups: PalletGroup[] = [{ title: "بخشها", items: NAV_ITEMS }];
|
||||
if (isPlatformOwner) {
|
||||
groups.push({ title: "کنسول مالک سامانه", items: ADMIN_ITEMS });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function matches(item: NavItem, needle: string): boolean {
|
||||
return (
|
||||
item.label.toLowerCase().includes(needle) ||
|
||||
item.hint.toLowerCase().includes(needle) ||
|
||||
item.href.includes(needle)
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandPalette({ isPlatformOwner = false }: { isPlatformOwner?: boolean }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [active, setActive] = React.useState(0);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
const groups = React.useMemo(() => buildGroups(isPlatformOwner), [isPlatformOwner]);
|
||||
|
||||
const filteredGroups = React.useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
const keep = (item: NavItem) => !needle || matches(item, needle);
|
||||
return groups
|
||||
.map((group) => ({ title: group.title, items: group.items.filter(keep) }))
|
||||
.filter((group) => group.items.length > 0);
|
||||
}, [groups, query]);
|
||||
|
||||
/**
|
||||
* Keyboard navigation walks the flattened, visible item list. `indexOf` is
|
||||
* safe here because both lists hold the same module-level object references.
|
||||
*/
|
||||
const visible = React.useMemo(
|
||||
() => filteredGroups.flatMap((group) => group.items),
|
||||
[filteredGroups],
|
||||
);
|
||||
|
||||
// Toggle the palette from anywhere on the page without stealing focus.
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isPaletteShortcut(event)) return;
|
||||
event.preventDefault();
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
setOpen((currentlyOpen) => !currentlyOpen);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, []);
|
||||
|
||||
// Focus the input once the dialog is mounted, so typing starts immediately.
|
||||
React.useEffect(() => {
|
||||
if (open) inputRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
// A narrowed list must not leave the highlight off the end.
|
||||
React.useEffect(() => {
|
||||
setActive(0);
|
||||
}, [query]);
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
}, []);
|
||||
|
||||
const go = React.useCallback(
|
||||
(href: string) => {
|
||||
close();
|
||||
router.push(href);
|
||||
},
|
||||
[close, router],
|
||||
);
|
||||
|
||||
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (visible.length === 0) return;
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setActive((index) => (index + 1) % visible.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
setActive((index) => (index - 1 + visible.length) % visible.length);
|
||||
} else if (event.key === "Enter") {
|
||||
const target = visible[active];
|
||||
if (target) {
|
||||
event.preventDefault();
|
||||
go(target.href);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setQuery("");
|
||||
setActive(0);
|
||||
setOpen(true);
|
||||
}}
|
||||
aria-label="جستوجو و رفتن سریع"
|
||||
className="hidden items-center gap-2 rounded-lg border border-surface-border bg-surface-overlay px-2.5 py-1.5 text-xs text-slate-400 transition-colors hover:text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 md:flex"
|
||||
>
|
||||
<span aria-hidden>⌕</span>
|
||||
<span>جستوجو…</span>
|
||||
<kbd className="rounded border border-surface-border px-1 text-[10px] text-slate-500">
|
||||
⌘K
|
||||
</kbd>
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
title="جستوجو و رفتن سریع"
|
||||
description="با کلیدهای بالا و پایین جابهجایی کنید و Enter برای رفتن."
|
||||
onClose={close}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
placeholder="نام بخش…"
|
||||
aria-label="جستوجوی بخشها"
|
||||
className="h-10 w-full rounded-lg border border-surface-border bg-surface-overlay px-3 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
/>
|
||||
|
||||
<div className="mt-3 flex max-h-80 flex-col gap-3 overflow-y-auto">
|
||||
{filteredGroups.length === 0 ? (
|
||||
<EmptyState title="نتیجهای پیدا نشد" description="عبارت دیگری را امتحان کنید." />
|
||||
) : (
|
||||
filteredGroups.map((group) => (
|
||||
<div key={group.title} className="flex flex-col gap-1">
|
||||
<p className="px-1 text-[11px] font-medium text-slate-500">{group.title}</p>
|
||||
{group.items.map((item) => {
|
||||
const index = visible.indexOf(item);
|
||||
const isActive = index === active;
|
||||
return (
|
||||
<button
|
||||
key={item.href}
|
||||
type="button"
|
||||
onMouseEnter={() => setActive(index)}
|
||||
onClick={() => go(item.href)}
|
||||
className={cx(
|
||||
"flex items-center justify-between gap-2 rounded-md px-2 py-2 text-start",
|
||||
isActive
|
||||
? "bg-accent-soft text-slate-100"
|
||||
: "text-slate-300 hover:bg-surface-raised",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm">{item.label}</span>
|
||||
<span className="text-xs text-slate-500">{item.hint}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Navigation destinations, shared by the sidebar and the command palette.
|
||||
*
|
||||
* Kept in one place so the two never drift: a destination that exists in the
|
||||
* palette but not in the sidebar (or the reverse) is a bug the type system
|
||||
* cannot catch, only a shared list can.
|
||||
*/
|
||||
|
||||
export interface NavItem {
|
||||
href: string;
|
||||
label: string;
|
||||
hint: string;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ href: "/dashboard", label: "داشبورد", hint: "نمای کلی سازمان" },
|
||||
{ href: "/manager", label: "مدیر AI", hint: "گفتگو با مدیر" },
|
||||
{ href: "/workforce", label: "نیروی کار AI", hint: "کارمندها" },
|
||||
{ href: "/projects", label: "پروژهها", hint: "Git و محیط اجرا" },
|
||||
{ href: "/tasks", label: "وظایف", hint: "وضعیت کارها" },
|
||||
{ href: "/traces", label: "ردیابی", hint: "اجراها و رویدادها" },
|
||||
{ href: "/knowledge", label: "دانش سازمانی", hint: "منابع و اسناد" },
|
||||
{ href: "/billing", label: "مصرف و پرداخت", hint: "اعتبار و فاکتور" },
|
||||
{ href: "/settings", label: "تنظیمات", hint: "سازمان و اعضا" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Platform-owner-only destinations. `admin/layout.tsx` mirrors this list and
|
||||
* renders it as a tab strip; the API is the real security boundary either way.
|
||||
*/
|
||||
export const ADMIN_ITEMS: NavItem[] = [
|
||||
{ href: "/admin", label: "نمای کلی کنسول", hint: "وضعیت سامانه" },
|
||||
{ href: "/admin/providers", label: "ارائهدهندهها", hint: "مدلها و کلیدها" },
|
||||
{ href: "/admin/models", label: "مدلها", hint: "قابلیت و قیمت" },
|
||||
{ href: "/admin/routing", label: "مسیریابی", hint: "fast، cheap، reasoning" },
|
||||
{ href: "/admin/rate-cards", label: "تعرفهها", hint: "قیمت واحدها" },
|
||||
{ href: "/admin/usage", label: "مصرف سراسری", hint: "گزارش مصرف مستأجرها" },
|
||||
{ href: "/admin/organizations", label: "سازمانها", hint: "مستأجرها" },
|
||||
{ href: "/admin/errors", label: "کنسول خطا", hint: "ردیابی خطاها" },
|
||||
];
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
|
||||
const push = vi.fn();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push }),
|
||||
usePathname: () => "/dashboard",
|
||||
}));
|
||||
|
||||
/** Opens the palette the way a user does — via the keyboard shortcut. */
|
||||
function openWithShortcut(ctrlKey = true) {
|
||||
fireEvent.keyDown(window, { key: "k", ctrlKey });
|
||||
}
|
||||
|
||||
describe("command palette", () => {
|
||||
beforeEach(() => {
|
||||
push.mockClear();
|
||||
});
|
||||
|
||||
it("starts closed and opens with the keyboard shortcut", () => {
|
||||
render(<CommandPalette />);
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
|
||||
openWithShortcut();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens with the macOS cmd key as well as ctrl", () => {
|
||||
render(<CommandPalette />);
|
||||
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the sidebar destinations when open", () => {
|
||||
render(<CommandPalette />);
|
||||
openWithShortcut();
|
||||
expect(screen.getByText(/^بخش/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /داشبورد/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("filters the destinations as the user types", () => {
|
||||
render(<CommandPalette />);
|
||||
openWithShortcut();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("جستوجوی بخشها"), {
|
||||
target: { value: "پروژه" },
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /پروژه/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /داشبورد/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an empty state when nothing matches", () => {
|
||||
render(<CommandPalette />);
|
||||
openWithShortcut();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("جستوجوی بخشها"), {
|
||||
target: { value: "zzzz" },
|
||||
});
|
||||
|
||||
expect(screen.getByText("نتیجهای پیدا نشد")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the platform-owner group from a regular user", () => {
|
||||
render(<CommandPalette />);
|
||||
openWithShortcut();
|
||||
expect(screen.queryByText("کنسول مالک سامانه")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the platform-owner group to the owner, including global usage", () => {
|
||||
render(<CommandPalette isPlatformOwner />);
|
||||
openWithShortcut();
|
||||
expect(screen.getByText("کنسول مالک سامانه")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /مصرف سراسری/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("navigates to the highlighted destination on Enter", () => {
|
||||
render(<CommandPalette />);
|
||||
openWithShortcut();
|
||||
|
||||
fireEvent.keyDown(screen.getByLabelText("جستوجوی بخشها"), {
|
||||
key: "ArrowDown",
|
||||
});
|
||||
fireEvent.keyDown(screen.getByLabelText("جستوجوی بخشها"), { key: "Enter" });
|
||||
|
||||
expect(push).toHaveBeenCalledTimes(1);
|
||||
expect(push).toHaveBeenCalledWith("/manager");
|
||||
});
|
||||
|
||||
it("navigates to the destination clicked with the mouse", () => {
|
||||
render(<CommandPalette />);
|
||||
openWithShortcut();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /داشبورد/ }));
|
||||
expect(push).toHaveBeenCalledWith("/dashboard");
|
||||
});
|
||||
|
||||
it("closes with Escape", () => {
|
||||
render(<CommandPalette />);
|
||||
openWithShortcut();
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(screen.getByLabelText("جستوجوی بخشها"), { key: "Escape" });
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user