@furystack/shades-common-components
Version:
Common UI components for FuryStack Shades
542 lines • 29.8 kB
JavaScript
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