mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-04 14:45:41 +00:00
fix(sidebar): require admin role for Schedule task and debounce search input (#1243)
The right-click Schedule task menu item and its keyboard shortcut were gated
only on isPaid, but the backend write routes under /api/scheduled-tasks
enforce requireAdmin + requirePaid on every action. Non-admin Skipper or
Admiral users would see the menu item and hit a 403 on click. The frontend
now mirrors the backend by gating Schedule task on isPaid && isAdmin so the
affordance only renders for users whose action will actually succeed.
Also adds a 120ms keystroke debounce to the sidebar search input. The
useStackListState filter rebuild was previously running on every keystroke
because <Command shouldFilter={false}> disables cmdk's own filter and the
existing 250ms timer only debounces state-invalidate events. Visible input
stays immediate via local state; the debounced emit drives the filter pass.
Adds a regression guard that /api/stacks/statuses is short-circuited by the
remote-node proxy (covers the sidebar status poll path) and updates the
sidebar feature docs to reflect the admin role requirement on Schedule task.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, act, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { Command } from '@/components/ui/command';
|
||||
import { SidebarSearch } from '../SidebarSearch';
|
||||
|
||||
function renderInsideCommand(props: { value: string; onValueChange: (v: string) => void }) {
|
||||
return render(
|
||||
<Command shouldFilter={false}>
|
||||
<SidebarSearch {...props} />
|
||||
</Command>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('SidebarSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('reflects typing immediately in the input but does not emit until the debounce window closes', () => {
|
||||
const onValueChange = vi.fn();
|
||||
const { getByPlaceholderText } = renderInsideCommand({ value: '', onValueChange });
|
||||
const input = getByPlaceholderText('Search stacks...') as HTMLInputElement;
|
||||
|
||||
act(() => {
|
||||
fireEvent.input(input, { target: { value: 'n' } });
|
||||
fireEvent.input(input, { target: { value: 'ng' } });
|
||||
fireEvent.input(input, { target: { value: 'ngi' } });
|
||||
fireEvent.input(input, { target: { value: 'ngin' } });
|
||||
fireEvent.input(input, { target: { value: 'nginx' } });
|
||||
});
|
||||
|
||||
expect(input.value).toBe('nginx');
|
||||
expect(onValueChange).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(120);
|
||||
});
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledTimes(1);
|
||||
expect(onValueChange).toHaveBeenLastCalledWith('nginx');
|
||||
});
|
||||
|
||||
it('does not clobber in-flight typing when the parent value echoes back the previous debounced emit', () => {
|
||||
const onValueChange = vi.fn();
|
||||
const { getByPlaceholderText, rerender } = renderInsideCommand({ value: '', onValueChange });
|
||||
const input = getByPlaceholderText('Search stacks...') as HTMLInputElement;
|
||||
|
||||
act(() => {
|
||||
fireEvent.input(input, { target: { value: 'web' } });
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(120);
|
||||
});
|
||||
expect(onValueChange).toHaveBeenLastCalledWith('web');
|
||||
|
||||
// Parent now propagates 'web' back as the controlled value.
|
||||
rerender(
|
||||
<Command shouldFilter={false}>
|
||||
<SidebarSearch value="web" onValueChange={onValueChange} />
|
||||
</Command>,
|
||||
);
|
||||
|
||||
// User keeps typing before the parent's echo settles.
|
||||
act(() => {
|
||||
fireEvent.input(input, { target: { value: 'web-api' } });
|
||||
});
|
||||
|
||||
// The echo of 'web' must not overwrite 'web-api' on the input.
|
||||
expect(input.value).toBe('web-api');
|
||||
});
|
||||
|
||||
it('adopts an external reset of the parent value (e.g., clear)', () => {
|
||||
const onValueChange = vi.fn();
|
||||
const { getByPlaceholderText, rerender } = renderInsideCommand({ value: 'old-query', onValueChange });
|
||||
const input = getByPlaceholderText('Search stacks...') as HTMLInputElement;
|
||||
expect(input.value).toBe('old-query');
|
||||
|
||||
rerender(
|
||||
<Command shouldFilter={false}>
|
||||
<SidebarSearch value="" onValueChange={onValueChange} />
|
||||
</Command>,
|
||||
);
|
||||
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user