UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

542 lines 29.8 kB
import { using } from '@furystack/utils'; import { describe, expect, it, vi } from 'vitest'; import { CollectionService } from './collection-service.js'; const createTestEntries = () => [ { foo: 1, name: 'alpha' }, { foo: 2, name: 'beta' }, { foo: 3, name: 'gamma' }, ]; const createKeyboardEvent = (key, options = {}) => { return { key, preventDefault: vi.fn(), ...options, }; }; const createMouseEvent = (options = {}) => { return { ctrlKey: false, shiftKey: false, ...options, }; }; describe('CollectionService', () => { describe('Selection', () => { it('Should add and remove selection', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (collectionService) => { collectionService.data.setValue({ count: 3, entries: testEntries }); testEntries.forEach((entry) => { expect(collectionService.isSelected(entry)).toBe(false); }); collectionService.addToSelection(testEntries[0]); expect(collectionService.isSelected(testEntries[0])).toBe(true); expect(collectionService.isSelected(testEntries[1])).toBe(false); expect(collectionService.isSelected(testEntries[2])).toBe(false); collectionService.removeFromSelection(testEntries[0]); expect(collectionService.isSelected(testEntries[0])).toBe(false); collectionService.toggleSelection(testEntries[1]); expect(collectionService.isSelected(testEntries[1])).toBe(true); }); }); }); describe('Disposal', () => { it('Should dispose all observables', () => { const service = new CollectionService({}); const dataSpy = vi.spyOn(service.data, Symbol.dispose); const selectionSpy = vi.spyOn(service.selection, Symbol.dispose); const searchTermSpy = vi.spyOn(service.searchTerm, Symbol.dispose); const hasFocusSpy = vi.spyOn(service.hasFocus, Symbol.dispose); const focusedEntrySpy = vi.spyOn(service.focusedEntry, Symbol.dispose); service[Symbol.dispose](); expect(dataSpy).toHaveBeenCalled(); expect(selectionSpy).toHaveBeenCalled(); expect(searchTermSpy).toHaveBeenCalled(); expect(hasFocusSpy).toHaveBeenCalled(); expect(focusedEntrySpy).toHaveBeenCalled(); }); it('Should dispose the data subscription when idField is set', () => { const service = new CollectionService({ idField: 'foo' }); const entries = createTestEntries(); service.data.setValue({ count: 3, entries }); service.focusedEntry.setValue(entries[1]); expect(service.focusedEntry.getValue()).toBe(entries[1]); const dataSpy = vi.spyOn(service.data, Symbol.dispose); service[Symbol.dispose](); expect(dataSpy).toHaveBeenCalled(); expect(() => service.data.setValue({ count: 0, entries: [] })).toThrowError('Observable already disposed'); }); }); describe('idField auto-reconciliation', () => { it('Should reconcile focusedEntry when data changes', () => { const oldEntries = createTestEntries(); const newEntries = createTestEntries(); using(new CollectionService({ idField: 'foo' }), (service) => { service.data.setValue({ count: 3, entries: oldEntries }); service.focusedEntry.setValue(oldEntries[1]); service.data.setValue({ count: 3, entries: newEntries }); expect(service.focusedEntry.getValue()).toBe(newEntries[1]); expect(service.focusedEntry.getValue()).not.toBe(oldEntries[1]); }); }); it('Should reconcile selection when data changes', () => { const oldEntries = createTestEntries(); const newEntries = createTestEntries(); using(new CollectionService({ idField: 'foo' }), (service) => { service.data.setValue({ count: 3, entries: oldEntries }); service.selection.setValue([oldEntries[0], oldEntries[2]]); service.data.setValue({ count: 3, entries: newEntries }); const selection = service.selection.getValue(); expect(selection[0]).toBe(newEntries[0]); expect(selection[1]).toBe(newEntries[2]); }); }); it('Should clear focusedEntry if the entry is removed from data', () => { const oldEntries = createTestEntries(); const newEntries = [{ ...oldEntries[0] }, { ...oldEntries[2] }]; using(new CollectionService({ idField: 'foo' }), (service) => { service.data.setValue({ count: 3, entries: oldEntries }); service.focusedEntry.setValue(oldEntries[1]); service.data.setValue({ count: 2, entries: newEntries }); expect(service.focusedEntry.getValue()).toBeUndefined(); }); }); it('Should remove stale selection entries when data changes', () => { const oldEntries = createTestEntries(); const newEntries = [{ ...oldEntries[0] }, { ...oldEntries[2] }]; using(new CollectionService({ idField: 'foo' }), (service) => { service.data.setValue({ count: 3, entries: oldEntries }); service.selection.setValue([...oldEntries]); service.data.setValue({ count: 2, entries: newEntries }); const selection = service.selection.getValue(); expect(selection.length).toBe(2); expect(selection[0]).toBe(newEntries[0]); expect(selection[1]).toBe(newEntries[1]); }); }); it('Should not reconcile when idField is not provided', () => { const oldEntries = createTestEntries(); const newEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: oldEntries }); service.focusedEntry.setValue(oldEntries[1]); service.selection.setValue([oldEntries[0], oldEntries[2]]); service.data.setValue({ count: 3, entries: newEntries }); expect(service.focusedEntry.getValue()).toBe(oldEntries[1]); const selection = service.selection.getValue(); expect(selection[0]).toBe(oldEntries[0]); expect(selection[1]).toBe(oldEntries[2]); }); }); it('Should not update focusedEntry if the reference already matches', () => { const entries = createTestEntries(); using(new CollectionService({ idField: 'foo' }), (service) => { service.data.setValue({ count: 3, entries }); service.focusedEntry.setValue(entries[0]); const spy = vi.spyOn(service.focusedEntry, 'setValue'); service.data.setValue({ count: 3, entries }); expect(spy).not.toHaveBeenCalled(); }); }); it('Should keep selection and keyboard navigation working after data refresh', () => { const oldEntries = createTestEntries(); const newEntries = createTestEntries(); using(new CollectionService({ idField: 'foo' }), (service) => { service.data.setValue({ count: 3, entries: oldEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(oldEntries[0]); service.data.setValue({ count: 3, entries: newEntries }); service.handleKeyDown(createKeyboardEvent('*')); expect(service.selection.getValue().length).toBe(3); service.handleKeyDown(createKeyboardEvent('ArrowDown')); expect(service.focusedEntry.getValue()).toBe(newEntries[1]); service.handleKeyDown(createKeyboardEvent('Insert')); expect(service.selection.getValue()).not.toContain(newEntries[1]); expect(service.focusedEntry.getValue()).toBe(newEntries[2]); }); }); }); describe('handleKeyDown', () => { it('Should do nothing when hasFocus is false', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(false); service.focusedEntry.setValue(testEntries[0]); service.handleKeyDown(createKeyboardEvent(' ')); expect(service.selection.getValue()).toEqual([]); }); }); describe('Space key', () => { it('Should toggle selection on focused entry', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[1]); const ev = createKeyboardEvent(' '); service.handleKeyDown(ev); expect(ev.preventDefault).toHaveBeenCalled(); expect(service.selection.getValue()).toEqual([testEntries[1]]); service.handleKeyDown(createKeyboardEvent(' ')); expect(service.selection.getValue()).toEqual([]); }); }); it('Should do nothing when no entry is focused', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(undefined); service.handleKeyDown(createKeyboardEvent(' ')); expect(service.selection.getValue()).toEqual([]); }); }); }); describe('* key (invert selection)', () => { it('Should invert selection', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.selection.setValue([testEntries[0], testEntries[2]]); service.handleKeyDown(createKeyboardEvent('*')); expect(service.selection.getValue()).toEqual([testEntries[1]]); }); }); }); describe('+ key (select all)', () => { it('Should select all entries', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.handleKeyDown(createKeyboardEvent('+')); expect(service.selection.getValue()).toEqual(testEntries); }); }); }); describe('- key (deselect all)', () => { it('Should deselect all entries', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.selection.setValue([testEntries[0], testEntries[1]]); service.handleKeyDown(createKeyboardEvent('-')); expect(service.selection.getValue()).toEqual([]); }); }); }); describe('Insert key', () => { it('Should toggle selection and move focus to next entry', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[0]); service.handleKeyDown(createKeyboardEvent('Insert')); expect(service.selection.getValue()).toEqual([testEntries[0]]); expect(service.focusedEntry.getValue()).toBe(testEntries[1]); }); }); it('Should deselect if already selected and move focus', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[1]); service.selection.setValue([testEntries[1]]); service.handleKeyDown(createKeyboardEvent('Insert')); expect(service.selection.getValue()).toEqual([]); expect(service.focusedEntry.getValue()).toBe(testEntries[2]); }); }); it('Should do nothing when no entry is focused', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(undefined); service.handleKeyDown(createKeyboardEvent('Insert')); expect(service.selection.getValue()).toEqual([]); }); }); }); describe('Arrow keys', () => { it('Should move focus to the previous entry on ArrowUp', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[2]); const ev = createKeyboardEvent('ArrowUp'); service.handleKeyDown(ev); expect(ev.preventDefault).toHaveBeenCalled(); expect(service.focusedEntry.getValue()).toBe(testEntries[1]); }); }); it('Should not preventDefault ArrowUp at the first entry', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[0]); const ev = createKeyboardEvent('ArrowUp'); service.handleKeyDown(ev); expect(ev.preventDefault).not.toHaveBeenCalled(); expect(service.focusedEntry.getValue()).toBe(testEntries[0]); }); }); it('Should move focus to the next entry on ArrowDown', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[0]); const ev = createKeyboardEvent('ArrowDown'); service.handleKeyDown(ev); expect(ev.preventDefault).toHaveBeenCalled(); expect(service.focusedEntry.getValue()).toBe(testEntries[1]); }); }); it('Should not preventDefault ArrowDown at the last entry', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[2]); const ev = createKeyboardEvent('ArrowDown'); service.handleKeyDown(ev); expect(ev.preventDefault).not.toHaveBeenCalled(); expect(service.focusedEntry.getValue()).toBe(testEntries[2]); }); }); it('Should not handle arrow keys when focusedEntry is undefined', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(undefined); const evDown = createKeyboardEvent('ArrowDown'); service.handleKeyDown(evDown); expect(evDown.preventDefault).not.toHaveBeenCalled(); const evUp = createKeyboardEvent('ArrowUp'); service.handleKeyDown(evUp); expect(evUp.preventDefault).not.toHaveBeenCalled(); }); }); }); describe('Home key', () => { it('Should focus the first entry and preventDefault', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[2]); const ev = createKeyboardEvent('Home'); service.handleKeyDown(ev); expect(ev.preventDefault).toHaveBeenCalled(); expect(service.focusedEntry.getValue()).toBe(testEntries[0]); }); }); }); describe('End key', () => { it('Should focus the last entry and preventDefault', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[0]); const ev = createKeyboardEvent('End'); service.handleKeyDown(ev); expect(ev.preventDefault).toHaveBeenCalled(); expect(service.focusedEntry.getValue()).toBe(testEntries[2]); }); }); }); describe('Escape key', () => { it('Should clear search term and selection', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.selection.setValue([testEntries[0], testEntries[1]]); service.searchTerm.setValue('test'); service.handleKeyDown(createKeyboardEvent('Escape')); expect(service.searchTerm.getValue()).toBe(''); expect(service.selection.getValue()).toEqual([]); }); }); }); describe('Character search', () => { it('Should search by character when searchField is configured', () => { const testEntries = createTestEntries(); using(new CollectionService({ searchField: 'name' }), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.handleKeyDown(createKeyboardEvent('b')); expect(service.searchTerm.getValue()).toBe('b'); expect(service.focusedEntry.getValue()).toBe(testEntries[1]); // 'beta' }); }); it('Should accumulate search characters', () => { const testEntries = createTestEntries(); using(new CollectionService({ searchField: 'name' }), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.handleKeyDown(createKeyboardEvent('a')); expect(service.focusedEntry.getValue()).toBe(testEntries[0]); // 'alpha' service.handleKeyDown(createKeyboardEvent('l')); expect(service.searchTerm.getValue()).toBe('al'); expect(service.focusedEntry.getValue()).toBe(testEntries[0]); // still 'alpha' }); }); it('Should not search when searchField is not configured', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.handleKeyDown(createKeyboardEvent('b')); expect(service.searchTerm.getValue()).toBe(''); expect(service.focusedEntry.getValue()).toBeUndefined(); }); }); it('Should set focusedEntry to undefined when no match found', () => { const testEntries = createTestEntries(); using(new CollectionService({ searchField: 'name' }), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.focusedEntry.setValue(testEntries[0]); service.handleKeyDown(createKeyboardEvent('z')); expect(service.searchTerm.getValue()).toBe('z'); expect(service.focusedEntry.getValue()).toBeUndefined(); }); }); it('Should ignore multi-character keys', () => { const testEntries = createTestEntries(); using(new CollectionService({ searchField: 'name' }), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.hasFocus.setValue(true); service.handleKeyDown(createKeyboardEvent('Shift')); expect(service.searchTerm.getValue()).toBe(''); }); }); }); }); describe('handleRowClick', () => { it('Should update focusedEntry on click', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.handleRowClick(testEntries[1], createMouseEvent()); expect(service.focusedEntry.getValue()).toBe(testEntries[1]); }); }); describe('Ctrl+click', () => { it('Should add entry to selection with Ctrl+click', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.selection.setValue([testEntries[0]]); service.handleRowClick(testEntries[1], createMouseEvent({ ctrlKey: true })); expect(service.selection.getValue()).toEqual([testEntries[0], testEntries[1]]); }); }); it('Should remove entry from selection with Ctrl+click if already selected', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.selection.setValue([testEntries[0], testEntries[1]]); service.handleRowClick(testEntries[0], createMouseEvent({ ctrlKey: true })); expect(service.selection.getValue()).toEqual([testEntries[1]]); }); }); }); describe('Shift+click', () => { it('Should select range from last focused to clicked entry (forward)', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.focusedEntry.setValue(testEntries[0]); service.handleRowClick(testEntries[2], createMouseEvent({ shiftKey: true })); expect(service.selection.getValue()).toContain(testEntries[0]); expect(service.selection.getValue()).toContain(testEntries[1]); expect(service.selection.getValue()).toContain(testEntries[2]); }); }); it('Should select range from last focused to clicked entry (backward)', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); service.focusedEntry.setValue(testEntries[2]); service.handleRowClick(testEntries[0], createMouseEvent({ shiftKey: true })); expect(service.selection.getValue()).toContain(testEntries[0]); expect(service.selection.getValue()).toContain(testEntries[1]); expect(service.selection.getValue()).toContain(testEntries[2]); }); }); it('Should append to existing selection with Shift+click', () => { const testEntries = createTestEntries(); const extraEntry = { foo: 4, name: 'delta' }; const allEntries = [...testEntries, extraEntry]; using(new CollectionService({}), (service) => { service.data.setValue({ count: 4, entries: allEntries }); service.selection.setValue([extraEntry]); service.focusedEntry.setValue(testEntries[0]); service.handleRowClick(testEntries[1], createMouseEvent({ shiftKey: true })); expect(service.selection.getValue()).toContain(extraEntry); expect(service.selection.getValue()).toContain(testEntries[0]); expect(service.selection.getValue()).toContain(testEntries[1]); }); }); }); }); describe('handleRowDoubleClick', () => { it('Should not throw when no subscriber is configured', () => { const testEntries = createTestEntries(); using(new CollectionService({}), (service) => { service.data.setValue({ count: 3, entries: testEntries }); expect(() => service.handleRowDoubleClick(testEntries[0])).not.toThrow(); }); }); }); describe('EventHub integration', () => { it('Should allow subscribing to onRowClick via EventHub', () => { const testEntries = createTestEntries(); const handler = vi.fn(); using(new CollectionService(), (service) => { service.addListener('onRowClick', handler); service.data.setValue({ count: 3, entries: testEntries }); service.handleRowClick(testEntries[1], createMouseEvent()); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(testEntries[1]); }); }); it('Should allow subscribing to onRowDoubleClick via EventHub', () => { const testEntries = createTestEntries(); const handler = vi.fn(); using(new CollectionService(), (service) => { service.addListener('onRowDoubleClick', handler); service.data.setValue({ count: 3, entries: testEntries }); service.handleRowDoubleClick(testEntries[2]); expect(handler).toHaveBeenCalledTimes(1); expect(handler).toHaveBeenCalledWith(testEntries[2]); }); }); it('Should support multiple subscribers for the same event', () => { const testEntries = createTestEntries(); const handler1 = vi.fn(); const handler2 = vi.fn(); using(new CollectionService(), (service) => { service.addListener('onRowClick', handler1); service.addListener('onRowClick', handler2); service.data.setValue({ count: 3, entries: testEntries }); service.handleRowClick(testEntries[0], createMouseEvent()); expect(handler1).toHaveBeenCalledTimes(1); expect(handler2).toHaveBeenCalledTimes(1); }); }); }); }); //# sourceMappingURL=collection-service.spec.js.map