mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-17 07:37:15 +00:00
fc08b53395
* feat(ui): add global command palette (Ctrl+K) for fast navigation and search * fix(ui): address review feedback for shortcut listener, i18n parity, and search deep links * fix(ui): resolve search routing, translation keys, and palette state reset * fix(ui): improve command palette styling and sidebar transitions * fix(ui): address review feedback for typecheck, codegen, debouncing, and state reset * fix(ui): resolve effect state update warning and debounce reset in command palette * fix(ui): address review feedback for stale client search results and theme action * style(ui): apply oxfmt formatting to command palette and tests * fix(deps): update js-yaml override to resolve audit advisory * docs(api): sync the docs OpenAPI copy with the new InboundOption fields Adding Network/Security to InboundOption regenerated frontend/public/openapi.json, but docs/public/openapi.json is a hand-kept copy of that file and nothing checks it: make verify never reaches docs/, and docs-ci.yml fires only on docs/**. The two files were byte-identical on main and had diverged here, so the published API reference described a response shape the panel no longer returns. Regenerating the MDX under docs/content/docs/en/reference/api/ produced no change — the schema is read from the JSON at render time. * fix(ui): unnest the command palette row control and label its shortcut The palette row was a <button> wrapping the copy-subscription <button>. Nested interactive content is invalid HTML and React 19 logs two errors for it on every client result. The row is now a role="button" div using activateOnKey, the pattern the rest of the panel already uses, with line-height pinned so dropping the UA button style does not grow every row. Its keydown handler ignores events bubbling from the nested button: activateOnKey preventDefaults Enter, which would otherwise cancel the browser's Enter-to-click on the copy button and navigate instead. The sidebar chip hardcoded the Mac glyph while the handler accepts Ctrl as well, so Linux and Windows operators were shown a key they do not have; it now picks the modifier from the platform. Also restores the comment on ClientsPage's debouncedSearch that the deep-link change removed — the code it explains is unchanged.
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
|
|
import { MemoryRouter } from 'react-router';
|
|
|
|
import CommandPalette from '@/components/command-palette/CommandPalette';
|
|
import { commandPaletteStore } from '@/components/command-palette/useCommandPalette';
|
|
import { HttpUtil, Msg } from '@/utils';
|
|
import { renderWithProviders } from './test-utils';
|
|
|
|
function renderPalette() {
|
|
return renderWithProviders(
|
|
<MemoryRouter>
|
|
<CommandPalette />
|
|
</MemoryRouter>,
|
|
);
|
|
}
|
|
|
|
describe('CommandPalette component', () => {
|
|
beforeEach(() => {
|
|
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
|
act(() => {
|
|
commandPaletteStore.close();
|
|
});
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('does not render when closed', () => {
|
|
renderPalette();
|
|
expect(screen.queryByRole('dialog')).toBeNull();
|
|
});
|
|
|
|
it('renders and focuses input when opened via store', async () => {
|
|
renderPalette();
|
|
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
expect(screen.getByRole('dialog')).toBeTruthy();
|
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
|
expect(input).toBeTruthy();
|
|
await waitFor(() => {
|
|
expect(document.activeElement).toBe(input);
|
|
});
|
|
});
|
|
|
|
it('toggles open and closed with Ctrl+K and Escape keyboard shortcuts', () => {
|
|
renderPalette();
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', code: 'KeyK', ctrlKey: true }));
|
|
});
|
|
expect(commandPaletteStore.getSnapshot()).toBe(true);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
|
});
|
|
expect(commandPaletteStore.getSnapshot()).toBe(false);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ن', code: 'KeyK', ctrlKey: true }));
|
|
});
|
|
expect(commandPaletteStore.getSnapshot()).toBe(true);
|
|
|
|
act(() => {
|
|
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
|
});
|
|
expect(commandPaletteStore.getSnapshot()).toBe(false);
|
|
});
|
|
|
|
it('closes when clicking backdrop', () => {
|
|
renderPalette();
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const backdrop = screen.getByRole('presentation');
|
|
fireEvent.click(backdrop);
|
|
|
|
expect(commandPaletteStore.getSnapshot()).toBe(false);
|
|
});
|
|
|
|
it('navigates items with ArrowDown and ArrowUp', () => {
|
|
renderPalette();
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
|
const items = document.querySelectorAll('.command-palette-item');
|
|
expect(items.length).toBeGreaterThan(0);
|
|
|
|
expect(items[0]?.classList.contains('active')).toBe(true);
|
|
|
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
|
const updatedItems = document.querySelectorAll('.command-palette-item');
|
|
expect(updatedItems[1]?.classList.contains('active')).toBe(true);
|
|
|
|
fireEvent.keyDown(input, { key: 'ArrowUp' });
|
|
const reupdatedItems = document.querySelectorAll('.command-palette-item');
|
|
expect(reupdatedItems[0]?.classList.contains('active')).toBe(true);
|
|
});
|
|
|
|
it('filters items when typing a search query', async () => {
|
|
renderPalette();
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
|
fireEvent.change(input, { target: { value: 'settings' } });
|
|
|
|
expect(screen.getAllByText(/Panel Settings/i).length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('resets query on close and does not persist query on reopen', async () => {
|
|
renderPalette();
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const input = screen.getByPlaceholderText(/Type a command or search/i) as HTMLInputElement;
|
|
fireEvent.change(input, { target: { value: 'settings' } });
|
|
expect(input.value).toBe('settings');
|
|
|
|
act(() => {
|
|
commandPaletteStore.close();
|
|
});
|
|
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const reopenedInput = screen.getByPlaceholderText(
|
|
/Type a command or search/i,
|
|
) as HTMLInputElement;
|
|
expect(reopenedInput.value).toBe('');
|
|
});
|
|
|
|
it('does not show spinning loader on whitespace-only input', () => {
|
|
renderPalette();
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
|
fireEvent.change(input, { target: { value: ' ' } });
|
|
|
|
expect(document.querySelector('.command-palette-search-icon.spinning')).toBeNull();
|
|
});
|
|
|
|
it('does not display stale client rows when a new search query is being fetched', async () => {
|
|
let resolveBob: ((val: Msg<{ items: unknown[] }>) => void) | undefined;
|
|
const bobPromise = new Promise<Msg<{ items: unknown[] }>>((resolve) => {
|
|
resolveBob = resolve;
|
|
});
|
|
|
|
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
|
if (url.includes('/panel/api/inbounds/options')) {
|
|
return new Msg(true, '', []);
|
|
}
|
|
if (url.includes('search=ali')) {
|
|
return new Msg(true, '', {
|
|
items: [
|
|
{
|
|
id: 1,
|
|
email: 'alice@example.com',
|
|
totalGB: 1000,
|
|
enable: true,
|
|
traffic: { up: 100, down: 200, total: 1000 },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
if (url.includes('search=bob')) {
|
|
return bobPromise as Promise<Msg<unknown>>;
|
|
}
|
|
return new Msg(true, '', {});
|
|
});
|
|
|
|
renderPalette();
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
|
|
|
// Type 'ali' and wait for Alice to appear after debounce
|
|
fireEvent.change(input, { target: { value: 'ali' } });
|
|
await waitFor(
|
|
() => {
|
|
expect(screen.getByText('alice@example.com')).toBeTruthy();
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Now type 'bob'
|
|
fireEvent.change(input, { target: { value: 'bob' } });
|
|
|
|
// Alice must vanish immediately upon new input
|
|
await waitFor(() => {
|
|
expect(screen.queryByText('alice@example.com')).toBeNull();
|
|
});
|
|
|
|
// Wait past the 300ms debounce interval while bob fetch is still pending
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
|
|
// Stale Alice row must STILL not be rendered
|
|
expect(screen.queryByText('alice@example.com')).toBeNull();
|
|
|
|
// Now resolve bob
|
|
act(() => {
|
|
resolveBob?.(
|
|
new Msg(true, '', {
|
|
items: [
|
|
{
|
|
id: 2,
|
|
email: 'bob@example.com',
|
|
totalGB: 500,
|
|
enable: true,
|
|
traffic: { up: 50, down: 100, total: 500 },
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
});
|
|
|
|
await waitFor(
|
|
() => {
|
|
expect(screen.getByText('bob@example.com')).toBeTruthy();
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
expect(screen.queryByText('alice@example.com')).toBeNull();
|
|
});
|
|
|
|
it('does not re-trigger loading when adding trailing whitespace to settled query', async () => {
|
|
const getSpy = vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
|
if (url.includes('/panel/api/inbounds/options')) return new Msg(true, '', []);
|
|
return new Msg(true, '', { items: [] });
|
|
});
|
|
|
|
renderPalette();
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
|
fireEvent.change(input, { target: { value: 'abc' } });
|
|
|
|
await waitFor(
|
|
() => {
|
|
const calls = getSpy.mock.calls.filter((c) => String(c[0]).includes('search=abc')).length;
|
|
expect(calls).toBe(1);
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
// Add trailing whitespace
|
|
fireEvent.change(input, { target: { value: 'abc ' } });
|
|
await new Promise((resolve) => setTimeout(resolve, 350));
|
|
|
|
// No extra search call because trimmed query has not changed
|
|
const callsAfterAbcSpace = getSpy.mock.calls.filter((c) =>
|
|
String(c[0]).includes('search=abc'),
|
|
).length;
|
|
expect(callsAfterAbcSpace).toBe(1);
|
|
expect(document.querySelector('.command-palette-search-icon.spinning')).toBeNull();
|
|
});
|
|
|
|
it('keeps the row secondary action independent of the row control', async () => {
|
|
vi.spyOn(HttpUtil, 'post').mockImplementation(
|
|
async (url: string) =>
|
|
new Msg(true, '', url.includes('/setting/all') ? { subURI: 'https://sub.example/' } : {}),
|
|
);
|
|
vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string) => {
|
|
if (url.includes('/panel/api/inbounds/options')) return new Msg(true, '', []);
|
|
if (url.includes('search=ali')) {
|
|
return new Msg(true, '', {
|
|
items: [
|
|
{
|
|
id: 1,
|
|
email: 'alice@example.com',
|
|
subId: 'sub123',
|
|
enable: true,
|
|
totalGB: 0,
|
|
traffic: { up: 100, down: 200, total: 0 },
|
|
},
|
|
],
|
|
});
|
|
}
|
|
return new Msg(true, '', {});
|
|
});
|
|
|
|
renderPalette();
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
|
fireEvent.change(input, { target: { value: 'ali' } });
|
|
await waitFor(
|
|
() => {
|
|
expect(screen.getByText('alice@example.com')).toBeTruthy();
|
|
},
|
|
{ timeout: 2000 },
|
|
);
|
|
|
|
const copyBtn = document.querySelector('.command-palette-action-btn');
|
|
expect(copyBtn).toBeTruthy();
|
|
expect(copyBtn?.parentElement?.closest('button')).toBeNull();
|
|
|
|
// Enter on the copy button must not also fire the row's own action.
|
|
fireEvent.keyDown(copyBtn as Element, { key: 'Enter' });
|
|
expect(commandPaletteStore.getSnapshot()).toBe(true);
|
|
});
|
|
|
|
it('renders a single theme action item without duplicates', () => {
|
|
renderPalette();
|
|
act(() => {
|
|
commandPaletteStore.open();
|
|
});
|
|
|
|
const input = screen.getByPlaceholderText(/Type a command or search/i);
|
|
fireEvent.change(input, { target: { value: 'theme' } });
|
|
|
|
const themeItems = screen.getAllByText(/Theme/i);
|
|
expect(themeItems.length).toBe(1);
|
|
});
|
|
});
|