refactor: move shared utilities to lib/shared/ sub-packages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
87
frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts
Normal file
87
frontend/src/lib/shared/hooks/useTypeahead.svelte.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
const { createTypeahead } = await import('../useTypeahead.svelte');
|
||||
|
||||
describe('createTypeahead', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('starts with empty query and closed dropdown', () => {
|
||||
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
|
||||
expect(ta.query).toBe('');
|
||||
expect(ta.isOpen).toBe(false);
|
||||
expect(ta.results).toEqual([]);
|
||||
expect(ta.loading).toBe(false);
|
||||
});
|
||||
|
||||
it('setQuery updates query and opens dropdown', async () => {
|
||||
const fetchUrl = vi.fn().mockResolvedValue([{ id: '1', name: 'Foo' }]);
|
||||
const ta = createTypeahead({ fetchUrl });
|
||||
ta.setQuery('foo');
|
||||
expect(ta.query).toBe('foo');
|
||||
expect(ta.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('setQuery triggers debounced fetch and populates results', async () => {
|
||||
const fetchUrl = vi.fn().mockResolvedValue([{ id: '1', name: 'Foo' }]);
|
||||
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
|
||||
ta.setQuery('foo');
|
||||
expect(fetchUrl).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(fetchUrl).toHaveBeenCalledWith('foo');
|
||||
expect(ta.results).toEqual([{ id: '1', name: 'Foo' }]);
|
||||
});
|
||||
|
||||
it('close() resets isOpen', () => {
|
||||
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
|
||||
ta.setQuery('foo');
|
||||
expect(ta.isOpen).toBe(true);
|
||||
ta.close();
|
||||
expect(ta.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('select(item) calls onSelect and closes dropdown', () => {
|
||||
const onSelect = vi.fn();
|
||||
const ta = createTypeahead({
|
||||
fetchUrl: vi.fn().mockResolvedValue([]),
|
||||
onSelect
|
||||
});
|
||||
ta.setQuery('foo');
|
||||
ta.select({ id: '1', name: 'Foo' });
|
||||
expect(onSelect).toHaveBeenCalledWith({ id: '1', name: 'Foo' });
|
||||
expect(ta.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('debounce coalesces rapid setQuery calls', async () => {
|
||||
const fetchUrl = vi.fn().mockResolvedValue([]);
|
||||
const ta = createTypeahead({ fetchUrl, debounceMs: 300 });
|
||||
ta.setQuery('f');
|
||||
ta.setQuery('fo');
|
||||
ta.setQuery('foo');
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(fetchUrl).toHaveBeenCalledTimes(1);
|
||||
expect(fetchUrl).toHaveBeenCalledWith('foo');
|
||||
});
|
||||
|
||||
it('fetch error logs to console.error and sets results to empty', async () => {
|
||||
const fetchUrl = vi.fn().mockRejectedValue(new Error('network error'));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const ta = createTypeahead({ fetchUrl, debounceMs: 0 });
|
||||
ta.setQuery('foo');
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
expect(ta.results).toEqual([]);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('setActiveIndex updates activeIndex', () => {
|
||||
const ta = createTypeahead({ fetchUrl: vi.fn().mockResolvedValue([]) });
|
||||
expect(ta.activeIndex).toBe(-1);
|
||||
ta.setActiveIndex(2);
|
||||
expect(ta.activeIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
77
frontend/src/lib/shared/hooks/useTypeahead.svelte.ts
Normal file
77
frontend/src/lib/shared/hooks/useTypeahead.svelte.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
type Options<T> = {
|
||||
fetchUrl: (query: string) => Promise<T[]>;
|
||||
onSelect?: (item: T) => void;
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
||||
export function createTypeahead<T>(options: Options<T>) {
|
||||
const { fetchUrl, onSelect, debounceMs = 300 } = options;
|
||||
|
||||
let query = $state('');
|
||||
let results: T[] = $state([]);
|
||||
let isOpen = $state(false);
|
||||
let loading = $state(false);
|
||||
let activeIndex = $state(-1);
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
function setQuery(q: string) {
|
||||
query = q;
|
||||
isOpen = true;
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(async () => {
|
||||
loading = true;
|
||||
try {
|
||||
results = await fetchUrl(q);
|
||||
} catch (e) {
|
||||
console.error('typeahead fetch error', e);
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}, debounceMs);
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
|
||||
function setActiveIndex(idx: number) {
|
||||
activeIndex = idx;
|
||||
}
|
||||
|
||||
function select(item: T) {
|
||||
onSelect?.(item);
|
||||
close();
|
||||
}
|
||||
|
||||
/** Directly populate results without going through the debounce (e.g. on-focus preload). */
|
||||
function openWith(items: T[]) {
|
||||
results = items;
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
return {
|
||||
get query() {
|
||||
return query;
|
||||
},
|
||||
get results() {
|
||||
return results;
|
||||
},
|
||||
get isOpen() {
|
||||
return isOpen;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get activeIndex() {
|
||||
return activeIndex;
|
||||
},
|
||||
setQuery,
|
||||
setActiveIndex,
|
||||
close,
|
||||
select,
|
||||
openWith
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Capture the beforeNavigate callback so tests can simulate navigation events
|
||||
let registeredBeforeNavigate:
|
||||
| ((nav: { cancel: () => void; to: { url: { href: string } } | null }) => void)
|
||||
| null = null;
|
||||
|
||||
const mockGoto = vi.fn();
|
||||
|
||||
vi.mock('$app/navigation', () => ({
|
||||
beforeNavigate: vi.fn((fn: typeof registeredBeforeNavigate) => {
|
||||
registeredBeforeNavigate = fn;
|
||||
}),
|
||||
goto: mockGoto
|
||||
}));
|
||||
|
||||
const { createUnsavedWarning } = await import('../useUnsavedWarning.svelte');
|
||||
|
||||
function simulateNavigate(href: string | null = '/somewhere') {
|
||||
const cancel = vi.fn();
|
||||
registeredBeforeNavigate?.({
|
||||
cancel,
|
||||
to: href ? { url: { href } } : null
|
||||
});
|
||||
return cancel;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
registeredBeforeNavigate = null;
|
||||
mockGoto.mockClear();
|
||||
});
|
||||
|
||||
describe('createUnsavedWarning', () => {
|
||||
it('isDirty starts false', () => {
|
||||
const w = createUnsavedWarning();
|
||||
expect(w.isDirty).toBe(false);
|
||||
});
|
||||
|
||||
it('markDirty sets isDirty to true', () => {
|
||||
const w = createUnsavedWarning();
|
||||
w.markDirty();
|
||||
expect(w.isDirty).toBe(true);
|
||||
});
|
||||
|
||||
it('markDirty hides any existing warning banner', () => {
|
||||
const w = createUnsavedWarning();
|
||||
// Simulate a navigation event that showed the banner
|
||||
w.markDirty();
|
||||
simulateNavigate();
|
||||
expect(w.showUnsavedWarning).toBe(true);
|
||||
// Typing again should hide the banner (form input re-triggers markDirty)
|
||||
w.markDirty();
|
||||
expect(w.showUnsavedWarning).toBe(false);
|
||||
});
|
||||
|
||||
it('beforeNavigate cancels and shows banner when dirty', () => {
|
||||
const w = createUnsavedWarning();
|
||||
w.markDirty();
|
||||
const cancel = simulateNavigate('/admin/users');
|
||||
expect(cancel).toHaveBeenCalled();
|
||||
expect(w.showUnsavedWarning).toBe(true);
|
||||
});
|
||||
|
||||
it('beforeNavigate stores the target URL', () => {
|
||||
const w = createUnsavedWarning();
|
||||
w.markDirty();
|
||||
simulateNavigate('/admin/users');
|
||||
expect(w.discardTarget).toBe('/admin/users');
|
||||
});
|
||||
|
||||
it('beforeNavigate does not cancel when not dirty', () => {
|
||||
createUnsavedWarning();
|
||||
const cancel = simulateNavigate('/admin/users');
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('discard resets state and navigates to target', () => {
|
||||
const w = createUnsavedWarning();
|
||||
w.markDirty();
|
||||
simulateNavigate('/admin/tags');
|
||||
w.discard();
|
||||
expect(w.isDirty).toBe(false);
|
||||
expect(w.showUnsavedWarning).toBe(false);
|
||||
expect(mockGoto).toHaveBeenCalledWith('/admin/tags');
|
||||
});
|
||||
|
||||
it('clearOnSuccess resets isDirty and warning', () => {
|
||||
const w = createUnsavedWarning();
|
||||
w.markDirty();
|
||||
simulateNavigate('/somewhere');
|
||||
w.clearOnSuccess();
|
||||
expect(w.isDirty).toBe(false);
|
||||
expect(w.showUnsavedWarning).toBe(false);
|
||||
});
|
||||
});
|
||||
46
frontend/src/lib/shared/hooks/useUnsavedWarning.svelte.ts
Normal file
46
frontend/src/lib/shared/hooks/useUnsavedWarning.svelte.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { beforeNavigate, goto } from '$app/navigation';
|
||||
|
||||
export function createUnsavedWarning() {
|
||||
let isDirty = $state(false);
|
||||
let showUnsavedWarning = $state(false);
|
||||
let discardTarget: string | null = $state(null);
|
||||
|
||||
beforeNavigate(({ cancel, to }) => {
|
||||
if (isDirty) {
|
||||
cancel();
|
||||
showUnsavedWarning = true;
|
||||
discardTarget = to?.url.href ?? null;
|
||||
}
|
||||
});
|
||||
|
||||
function markDirty() {
|
||||
isDirty = true;
|
||||
showUnsavedWarning = false;
|
||||
}
|
||||
|
||||
function discard() {
|
||||
isDirty = false;
|
||||
showUnsavedWarning = false;
|
||||
if (discardTarget) goto(discardTarget);
|
||||
}
|
||||
|
||||
function clearOnSuccess() {
|
||||
isDirty = false;
|
||||
showUnsavedWarning = false;
|
||||
}
|
||||
|
||||
return {
|
||||
get isDirty() {
|
||||
return isDirty;
|
||||
},
|
||||
get showUnsavedWarning() {
|
||||
return showUnsavedWarning;
|
||||
},
|
||||
get discardTarget() {
|
||||
return discardTarget;
|
||||
},
|
||||
markDirty,
|
||||
discard,
|
||||
clearOnSuccess
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user