UNPKG

@furystack/shades-common-components

Version:

Common UI components for FuryStack Shades

687 lines (542 loc) 26.2 kB
import { using } from '@furystack/utils' import { describe, expect, it, vi } from 'vitest' import { CollectionService } from './collection-service.js' type TestEntry = { foo: number; name?: string } const createTestEntries = (): TestEntry[] => [ { foo: 1, name: 'alpha' }, { foo: 2, name: 'beta' }, { foo: 3, name: 'gamma' }, ] const createKeyboardEvent = (key: string, options: Partial<KeyboardEvent> = {}): KeyboardEvent => { return { key, preventDefault: vi.fn(), ...options, } as unknown as KeyboardEvent } const createMouseEvent = (options: Partial<MouseEvent> = {}): MouseEvent => { return { ctrlKey: false, shiftKey: false, ...options, } as unknown as MouseEvent } describe('CollectionService', () => { describe('Selection', () => { it('Should add and remove selection', () => { const testEntries = createTestEntries() using(new CollectionService<TestEntry>({}), (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<TestEntry>({}) 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<TestEntry>({ 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<TestEntry>({ 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<TestEntry>({ 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<TestEntry>({ 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<TestEntry>({ 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<TestEntry>({}), (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<TestEntry>({ 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<TestEntry>({ 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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({ 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<TestEntry>({ 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<TestEntry>({}), (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<TestEntry>({ 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<TestEntry>({ 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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>({}), (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<TestEntry>(), (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<TestEntry>(), (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<TestEntry>(), (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) }) }) }) })