@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
687 lines (542 loc) • 26.2 kB
text/typescript
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)
})
})
})
})