diff --git a/src/features/GetFonts/api/filters/filters.ts b/src/features/GetFonts/api/filters/filters.ts new file mode 100644 index 0000000..a576a1b --- /dev/null +++ b/src/features/GetFonts/api/filters/filters.ts @@ -0,0 +1,85 @@ +/** + * Proxy API filters + * + * Fetches filter metadata from GlyphDiff proxy API. + * Provides type-safe response handling. + * + * @see https://api.glyphdiff.com/api/v1/filters + */ + +import { api } from '$shared/api/api'; + +const PROXY_API_URL = 'https://api.glyphdiff.com/api/v1/filters' as const; + +/** + * Filter metadata type from backend + */ +export interface FilterMetadata { + /** Filter ID (e.g., "providers", "categories", "subsets") */ + id: string; + + /** Display name (e.g., "Font Providers", "Categories", "Character Subsets") */ + name: string; + + /** Filter description */ + description: string; + + /** Filter type */ + type: 'enum' | 'string' | 'array'; + + /** Available filter options */ + options: FilterOption[]; +} + +/** + * Filter option type + */ +export interface FilterOption { + /** Option ID (e.g., "google", "serif", "latin") */ + id: string; + + /** Display name (e.g., "Google Fonts", "Serif", "Latin") */ + name: string; + + /** Option value (e.g., "google", "serif", "latin") */ + value: string; + + /** Number of fonts with this value */ + count: number; +} + +/** + * Proxy filters API response + */ +export interface ProxyFiltersResponse { + /** Array of filter metadata */ + filters: FilterMetadata[]; +} + +/** + * Fetch filters from proxy API + * + * @returns Promise resolving to array of filter metadata + * @throws ApiError when request fails + * + * @example + * ```ts + * // Fetch all filters + * const filters = await fetchProxyFilters(); + * + * console.log(filters); // [ + * // { id: "providers", name: "Font Providers", options: [...] }, + * // { id: "categories", name: "Categories", options: [...] }, + * // { id: "subsets", name: "Character Subsets", options: [...] } + * // ] + * ``` + */ +export async function fetchProxyFilters(): Promise { + const response = await api.get(PROXY_API_URL); + + if (!response.data || !Array.isArray(response.data)) { + throw new Error('Proxy API returned invalid response'); + } + + return response.data; +} diff --git a/src/features/GetFonts/api/index.ts b/src/features/GetFonts/api/index.ts new file mode 100644 index 0000000..56cc8f4 --- /dev/null +++ b/src/features/GetFonts/api/index.ts @@ -0,0 +1 @@ +export * from './filters/filters'; diff --git a/src/features/GetFonts/index.ts b/src/features/GetFonts/index.ts index 9f63121..6b3dff3 100644 --- a/src/features/GetFonts/index.ts +++ b/src/features/GetFonts/index.ts @@ -4,14 +4,16 @@ export { mapManagerToParams, } from './lib'; -export { - FONT_CATEGORIES, - FONT_PROVIDERS, - FONT_SUBSETS, -} from './model/const/const'; - export { filterManager } from './model/state/manager.svelte'; +export { + SORT_MAP, + SORT_OPTIONS, + type SortApiValue, + type SortOption, + sortStore, +} from './model/store/sortStore.svelte'; + export { FilterControls, Filters, diff --git a/src/features/GetFonts/lib/filterManager/filterManager.svelte.ts b/src/features/GetFonts/lib/filterManager/filterManager.svelte.ts index c32a5e6..4857efc 100644 --- a/src/features/GetFonts/lib/filterManager/filterManager.svelte.ts +++ b/src/features/GetFonts/lib/filterManager/filterManager.svelte.ts @@ -1,14 +1,40 @@ +/** + * Filter manager for font filtering + * + * Manages multiple filter groups (providers, categories, subsets) + * with debounced search input. Provides reactive state for filter + * selections and convenience methods for bulk operations. + * + * @example + * ```ts + * const manager = createFilterManager({ + * queryValue: '', + * groups: [ + * { id: 'providers', label: 'Provider', properties: [...] }, + * { id: 'categories', label: 'Category', properties: [...] } + * ] + * }); + * + * $: searchQuery = manager.debouncedQueryValue; + * $: hasFilters = manager.hasAnySelection; + * ``` + */ + import { createFilter } from '$shared/lib'; import { createDebouncedState } from '$shared/lib/helpers'; -import type { FilterConfig } from '../../model'; +import type { + FilterConfig, + FilterGroupConfig, +} from '../../model'; /** - * Create a filter manager instance. - * - Uses debounce to update search query for better performance. - * - Manages filter instances for each group. + * Creates a filter manager instance * - * @param config - Configuration for the filter manager. - * @returns - An instance of the filter manager. + * Manages multiple filter groups with debounced search. Each group + * contains filterable properties that can be selected/deselected. + * + * @param config - Configuration with query value and filter groups + * @returns Filter manager instance with reactive state and methods */ export function createFilterManager(config: FilterConfig) { const search = createDebouncedState(config.queryValue ?? ''); @@ -28,37 +54,68 @@ export function createFilterManager(config: FilterConfig< ); return { - // Getter for queryValue (immediate value for UI) + /** + * Replace all filter groups with new config + * Used when dynamic filter data loads from backend + */ + setGroups(newGroups: FilterGroupConfig[]) { + groups.length = 0; + groups.push( + ...newGroups.map(g => ({ + id: g.id, + label: g.label, + instance: createFilter({ properties: g.properties }), + })), + ); + }, + /** + * Current search query value (immediate, for UI binding) + * Updates instantly as user types + */ get queryValue() { return search.immediate; }, - // Setter for queryValue + /** + * Set the search query value + */ set queryValue(value) { search.immediate = value; }, - // Getter for queryValue (debounced value for logic) + /** + * Debounced search query value (for API calls) + * Updates after delay to reduce API requests + */ get debouncedQueryValue() { return search.debounced; }, - // Direct array reference (reactive) + /** + * All filter groups (reactive) + */ get groups() { return groups; }, - // Derived values + /** + * Whether any filter has an active selection + */ get hasAnySelection() { return hasAnySelection; }, - // Global action + /** + * Deselect all filters across all groups + */ deselectAllGlobal: () => { groups.forEach(group => group.instance.deselectAll()); }, - // Helper to get group by id + /** + * Get a specific filter group by ID + * @param id - Group identifier + */ getGroup: (id: string) => { return groups.find(g => g.id === id); }, diff --git a/src/features/GetFonts/lib/filterManager/filterManager.test.ts b/src/features/GetFonts/lib/filterManager/filterManager.test.ts new file mode 100644 index 0000000..731a011 --- /dev/null +++ b/src/features/GetFonts/lib/filterManager/filterManager.test.ts @@ -0,0 +1,784 @@ +import type { Property } from '$shared/lib'; +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createFilterManager } from './filterManager.svelte'; + +/** + * Test Suite for createFilterManager Helper Function + * + * This suite tests the filter manager logic including: + * - Debounced query state (immediate vs delayed) + * - Filter group creation and management + * - hasAnySelection derived state + * - getGroup() method + * - deselectAllGlobal() method + * + * Mocking Strategy: + * - We test the actual implementation without mocking createDebouncedState + * and createFilter since they are simple reactive helpers + * - For timing tests, we use vi.useFakeTimers() to control debounce delays + * + * NOTE: Svelte 5's $derived runs in microtasks, so we need to flush effects + * after state changes to test reactive behavior. This is a limitation of unit + * testing Svelte 5 reactive code in Node.js. + */ + +// Helper to flush Svelte effects (they run in microtasks) +async function flushEffects() { + await Promise.resolve(); + await Promise.resolve(); +} + +// Helper to create test properties +function createTestProperties(count: number, selectedIndices: number[] = []): Property[] { + return Array.from({ length: count }, (_, i) => ({ + id: `prop-${i}`, + name: `Property ${i}`, + value: `value-${i}`, + selected: selectedIndices.includes(i), + })); +} + +// Helper to create test filter groups +function createTestGroups(count: number, propertiesPerGroup = 3) { + return Array.from({ length: count }, (_, i) => ({ + id: `group-${i}`, + label: `Group ${i}`, + properties: createTestProperties(propertiesPerGroup), + })); +} + +describe('createFilterManager - Initialization', () => { + it('creates manager with empty query value', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(2), + }); + + expect(manager.queryValue).toBe(''); + expect(manager.debouncedQueryValue).toBe(''); + }); + + it('creates manager with initial query value', () => { + const manager = createFilterManager({ + queryValue: 'search term', + groups: createTestGroups(1), + }); + + expect(manager.queryValue).toBe('search term'); + expect(manager.debouncedQueryValue).toBe('search term'); + }); + + it('creates manager with undefined query value (defaults to empty string)', () => { + const manager = createFilterManager({ + groups: createTestGroups(1), + }); + + expect(manager.queryValue).toBe(''); + expect(manager.debouncedQueryValue).toBe(''); + }); + + it('creates filter groups for each config group', () => { + const groups = createTestGroups(3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + expect(manager.groups).toHaveLength(3); + expect(manager.groups[0].id).toBe('group-0'); + expect(manager.groups[1].id).toBe('group-1'); + expect(manager.groups[2].id).toBe('group-2'); + }); + + it('creates filter instances for each group', () => { + const groups = createTestGroups(2, 5); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + manager.groups.forEach(group => { + expect(group.instance).toBeDefined(); + expect(group.instance.properties).toHaveLength(5); + expect(typeof group.instance.toggleProperty).toBe('function'); + expect(typeof group.instance.selectAll).toBe('function'); + expect(typeof group.instance.deselectAll).toBe('function'); + }); + }); + + it('preserves group labels', () => { + const groups = [ + { id: 'providers', label: 'Providers', properties: createTestProperties(2) }, + { id: 'categories', label: 'Categories', properties: createTestProperties(3) }, + ]; + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + expect(manager.groups[0].label).toBe('Providers'); + expect(manager.groups[1].label).toBe('Categories'); + }); + + it('handles single group', () => { + const groups = createTestGroups(1); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + expect(manager.groups).toHaveLength(1); + expect(manager.groups[0].id).toBe('group-0'); + }); +}); + +describe('createFilterManager - Debounced Query', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('immediate query value updates instantly', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(1), + }); + + manager.queryValue = 'new search'; + + expect(manager.queryValue).toBe('new search'); + expect(manager.debouncedQueryValue).toBe(''); + }); + + it('debounced query value updates after default delay (300ms)', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(1), + }); + + manager.queryValue = 'search term'; + + expect(manager.debouncedQueryValue).toBe(''); + + vi.advanceTimersByTime(299); + expect(manager.debouncedQueryValue).toBe(''); + + vi.advanceTimersByTime(1); + expect(manager.debouncedQueryValue).toBe('search term'); + }); + + it('rapid query changes reset the debounce timer', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(1), + }); + + manager.queryValue = 'a'; + vi.advanceTimersByTime(100); + + manager.queryValue = 'ab'; + vi.advanceTimersByTime(100); + + manager.queryValue = 'abc'; + vi.advanceTimersByTime(100); + + expect(manager.debouncedQueryValue).toBe(''); + expect(manager.queryValue).toBe('abc'); + + vi.advanceTimersByTime(200); + expect(manager.debouncedQueryValue).toBe('abc'); + }); + + it('handles empty string in query', () => { + const manager = createFilterManager({ + queryValue: 'initial', + groups: createTestGroups(1), + }); + + manager.queryValue = ''; + vi.advanceTimersByTime(300); + + expect(manager.queryValue).toBe(''); + expect(manager.debouncedQueryValue).toBe(''); + }); + + it('preserves initial query value until changed', () => { + const manager = createFilterManager({ + queryValue: 'initial search', + groups: createTestGroups(1), + }); + + expect(manager.queryValue).toBe('initial search'); + expect(manager.debouncedQueryValue).toBe('initial search'); + + vi.advanceTimersByTime(500); + + expect(manager.queryValue).toBe('initial search'); + expect(manager.debouncedQueryValue).toBe('initial search'); + }); +}); + +describe('createFilterManager - hasAnySelection Derived State', () => { + it('returns false when no filters are selected', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(3, 3), + }); + + expect(manager.hasAnySelection).toBe(false); + }); + + it('returns true when one filter in one group is selected', () => { + const groups = createTestGroups(2, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + manager.groups[0].instance.selectProperty('prop-0'); + + // Verify underlying state changed + expect(manager.groups[0].instance.selectedCount).toBe(1); + // hasAnySelection derived state requires reactive environment + // This is tested in component/E2E tests + }); + + it('returns true when multiple filters across groups are selected', () => { + const groups = createTestGroups(3, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + manager.groups[0].instance.selectProperty('prop-0'); + manager.groups[1].instance.selectProperty('prop-1'); + manager.groups[2].instance.selectProperty('prop-2'); + + // Verify underlying state changed + expect(manager.groups[0].instance.selectedCount).toBe(1); + expect(manager.groups[1].instance.selectedCount).toBe(1); + expect(manager.groups[2].instance.selectedCount).toBe(1); + }); + + it('returns false after deselecting all filters', () => { + const groups = createTestGroups(2, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + manager.groups[0].instance.selectProperty('prop-0'); + expect(manager.groups[0].instance.selectedCount).toBe(1); + + manager.groups[0].instance.deselectAll(); + expect(manager.groups[0].instance.selectedCount).toBe(0); + }); + + it('reacts to selection changes in individual groups', () => { + const groups = createTestGroups(2, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + manager.groups[0].instance.selectProperty('prop-0'); + expect(manager.groups[0].instance.selectedCount).toBe(1); + + manager.groups[1].instance.selectProperty('prop-1'); + expect(manager.groups[1].instance.selectedCount).toBe(1); + + manager.groups[0].instance.deselectProperty('prop-0'); + expect(manager.groups[0].instance.selectedCount).toBe(0); + expect(manager.groups[1].instance.selectedCount).toBe(1); // Still selected + + manager.groups[1].instance.deselectProperty('prop-1'); + expect(manager.groups[1].instance.selectedCount).toBe(0); + }); + + it('handles groups with initially selected properties', () => { + const groups = [ + { + id: 'group-0', + label: 'Group 0', + properties: createTestProperties(3, [0, 1]), + }, + { + id: 'group-1', + label: 'Group 1', + properties: createTestProperties(3, []), + }, + ]; + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + expect(manager.hasAnySelection).toBe(true); + }); + + it('returns false when all groups are empty', () => { + const groups = [ + { id: 'group-0', label: 'Group 0', properties: [] }, + { id: 'group-1', label: 'Group 1', properties: [] }, + ]; + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + expect(manager.hasAnySelection).toBe(false); + }); +}); + +describe('createFilterManager - getGroup() Method', () => { + it('returns the correct group by ID', () => { + const groups = createTestGroups(3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + const group = manager.getGroup('group-1'); + + expect(group).toBeDefined(); + expect(group?.id).toBe('group-1'); + expect(group?.label).toBe('Group 1'); + }); + + it('returns undefined for non-existent group ID', () => { + const groups = createTestGroups(2); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + const group = manager.getGroup('non-existent'); + + expect(group).toBeUndefined(); + }); + + it('returns group with accessible filter instance', () => { + const groups = createTestGroups(2, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + const group = manager.getGroup('group-0'); + + expect(group?.instance).toBeDefined(); + expect(group?.instance.properties).toHaveLength(3); + + group?.instance.selectProperty('prop-0'); + expect(group?.instance.selectedProperties).toHaveLength(1); + }); + + it('returns first group when requested', () => { + const groups = createTestGroups(3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + const group = manager.getGroup('group-0'); + + expect(group?.id).toBe('group-0'); + expect(group?.label).toBe('Group 0'); + }); + + it('returns last group when requested', () => { + const groups = createTestGroups(5); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + const group = manager.getGroup('group-4'); + + expect(group?.id).toBe('group-4'); + expect(group?.label).toBe('Group 4'); + }); +}); + +describe('createFilterManager - deselectAllGlobal() Method', () => { + it('deselects all filters across all groups', () => { + const groups = createTestGroups(3, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + // Select some filters in each group + manager.groups[0].instance.selectProperty('prop-0'); + manager.groups[1].instance.selectProperty('prop-1'); + manager.groups[2].instance.selectProperty('prop-2'); + + expect(manager.groups[0].instance.selectedCount).toBe(1); + expect(manager.groups[1].instance.selectedCount).toBe(1); + expect(manager.groups[2].instance.selectedCount).toBe(1); + + manager.deselectAllGlobal(); + + expect(manager.groups[0].instance.selectedCount).toBe(0); + expect(manager.groups[1].instance.selectedCount).toBe(0); + expect(manager.groups[2].instance.selectedCount).toBe(0); + }); + + it('handles deselecting when nothing is selected', () => { + const groups = createTestGroups(2, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + expect(() => manager.deselectAllGlobal()).not.toThrow(); + + expect(manager.groups[0].instance.selectedCount).toBe(0); + expect(manager.groups[1].instance.selectedCount).toBe(0); + expect(manager.hasAnySelection).toBe(false); + }); + + it('handles deselecting with empty groups', () => { + const groups = [ + { id: 'group-0', label: 'Group 0', properties: [] }, + { id: 'group-1', label: 'Group 1', properties: [] }, + ]; + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + expect(() => manager.deselectAllGlobal()).not.toThrow(); + expect(manager.hasAnySelection).toBe(false); + }); + + it('can select filters after global deselect', () => { + const groups = createTestGroups(2, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + // Select and then deselect + manager.groups[0].instance.selectProperty('prop-0'); + expect(manager.groups[0].instance.selectedCount).toBe(1); + manager.deselectAllGlobal(); + expect(manager.groups[0].instance.selectedCount).toBe(0); + + // Select again + manager.groups[0].instance.selectProperty('prop-1'); + expect(manager.groups[0].instance.selectedCount).toBe(1); + }); + + it('handles partially selected groups', () => { + const groups = createTestGroups(3, 5); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + // Partial selection in each group + manager.groups[0].instance.selectProperty('prop-0'); + manager.groups[0].instance.selectProperty('prop-1'); + manager.groups[1].instance.selectProperty('prop-2'); + manager.groups[2].instance.selectProperty('prop-4'); + + expect(manager.groups[0].instance.selectedCount).toBe(2); + expect(manager.groups[1].instance.selectedCount).toBe(1); + expect(manager.groups[2].instance.selectedCount).toBe(1); + + manager.deselectAllGlobal(); + + expect(manager.groups[0].instance.selectedCount).toBe(0); + expect(manager.groups[1].instance.selectedCount).toBe(0); + expect(manager.groups[2].instance.selectedCount).toBe(0); + }); +}); + +describe('createFilterManager - Complex Scenarios', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('handles query changes and filter selections together', () => { + const groups = createTestGroups(2, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + manager.queryValue = 'search'; + manager.groups[0].instance.selectProperty('prop-0'); + + expect(manager.queryValue).toBe('search'); + expect(manager.groups[0].instance.selectedCount).toBe(1); + expect(manager.debouncedQueryValue).toBe(''); + + vi.advanceTimersByTime(300); + expect(manager.debouncedQueryValue).toBe('search'); + }); + + it('handles real-world filtering workflow', () => { + const groups = [ + { + id: 'categories', + label: 'Categories', + properties: [ + { id: 'sans', name: 'Sans Serif', value: 'sans-serif' }, + { id: 'serif', name: 'Serif', value: 'serif' }, + { id: 'display', name: 'Display', value: 'display' }, + ], + }, + { + id: 'subsets', + label: 'Subsets', + properties: [ + { id: 'latin', name: 'Latin', value: 'latin' }, + { id: 'cyrillic', name: 'Cyrillic', value: 'cyrillic' }, + ], + }, + ]; + + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + // Initial state + expect(manager.hasAnySelection).toBe(false); + + // Select a category + const categoryGroup = manager.getGroup('categories'); + categoryGroup?.instance.selectProperty('sans'); + expect(categoryGroup?.instance.selectedCount).toBe(1); + + // Type in search + manager.queryValue = 'roboto'; + expect(manager.queryValue).toBe('roboto'); + expect(manager.debouncedQueryValue).toBe(''); + + // Wait for debounce + vi.advanceTimersByTime(300); + expect(manager.debouncedQueryValue).toBe('roboto'); + + // Clear all filters + manager.deselectAllGlobal(); + expect(categoryGroup?.instance.selectedCount).toBe(0); + }); + + it('manages multiple independent filter groups correctly', () => { + const groups = createTestGroups(4, 5); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + // Select different filters in different groups + manager.groups[0].instance.selectProperty('prop-0'); + manager.groups[1].instance.selectAll(); + manager.groups[2].instance.selectProperty('prop-2'); + + expect(manager.groups[0].instance.selectedCount).toBe(1); + expect(manager.groups[1].instance.selectedCount).toBe(5); + expect(manager.groups[2].instance.selectedCount).toBe(1); + expect(manager.groups[3].instance.selectedCount).toBe(0); + + // Deselect all globally + manager.deselectAllGlobal(); + + manager.groups.forEach(group => { + expect(group.instance.selectedCount).toBe(0); + }); + }); + + it('handles toggle operations via getGroup', () => { + const groups = createTestGroups(2, 3); + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + const group = manager.getGroup('group-0'); + expect(group?.instance.selectedCount).toBe(0); + + group?.instance.toggleProperty('prop-0'); + expect(group?.instance.selectedCount).toBe(1); + + group?.instance.toggleProperty('prop-0'); + expect(group?.instance.selectedCount).toBe(0); + }); +}); + +describe('createFilterManager - Interface Compliance', () => { + it('exposes queryValue getter', () => { + const manager = createFilterManager({ + queryValue: 'test', + groups: createTestGroups(1), + }); + + expect(() => { + const _ = manager.queryValue; + }).not.toThrow(); + }); + + it('exposes queryValue setter', () => { + const manager = createFilterManager({ + queryValue: 'test', + groups: createTestGroups(1), + }); + + expect(() => { + manager.queryValue = 'new value'; + }).not.toThrow(); + }); + + it('exposes debouncedQueryValue getter', () => { + const manager = createFilterManager({ + queryValue: 'test', + groups: createTestGroups(1), + }); + + expect(() => { + const _ = manager.debouncedQueryValue; + }).not.toThrow(); + }); + + it('exposes groups getter', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(1), + }); + + expect(() => { + const _ = manager.groups; + }).not.toThrow(); + }); + + it('exposes hasAnySelection getter', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(1), + }); + + expect(() => { + const _ = manager.hasAnySelection; + }).not.toThrow(); + }); + + it('exposes getGroup method', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(1), + }); + + expect(typeof manager.getGroup).toBe('function'); + }); + + it('exposes deselectAllGlobal method', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(1), + }); + + expect(typeof manager.deselectAllGlobal).toBe('function'); + }); + + it('does not expose debouncedQueryValue setter', () => { + const manager = createFilterManager({ + queryValue: '', + groups: createTestGroups(1), + }); + + // TypeScript should prevent this, but we can check the runtime behavior + expect(manager).not.toHaveProperty('set debouncedQueryValue'); + }); +}); + +describe('createFilterManager - Edge Cases', () => { + it('handles single property groups', () => { + const groups: Array<{ + id: string; + label: string; + properties: Property[]; + }> = [ + { + id: 'single-prop-group', + label: 'Single Property', + properties: [{ id: 'only', name: 'Only', value: 'only-value' }], + }, + ]; + + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + expect(manager.groups).toHaveLength(1); + expect(manager.groups[0].instance.properties).toHaveLength(1); + expect(manager.hasAnySelection).toBe(false); + + manager.groups[0].instance.selectProperty('only'); + expect(manager.groups[0].instance.selectedCount).toBe(1); + }); + + it('handles groups with duplicate property IDs (same ID, different groups)', () => { + const groups = [ + { + id: 'group-0', + label: 'Group 0', + properties: [{ id: 'same-id', name: 'Same 0', value: 'value-0' }], + }, + { + id: 'group-1', + label: 'Group 1', + properties: [{ id: 'same-id', name: 'Same 1', value: 'value-1' }], + }, + ]; + + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + // Each group should have its own filter instance + expect(manager.groups[0].instance.properties[0].id).toBe('same-id'); + expect(manager.groups[1].instance.properties[0].id).toBe('same-id'); + + // Selecting in one group should not affect the other + manager.groups[0].instance.selectProperty('same-id'); + expect(manager.groups[0].instance.selectedCount).toBe(1); + expect(manager.groups[1].instance.selectedCount).toBe(0); + }); + + it('handles initially selected properties in groups', () => { + const groups = [ + { + id: 'preselected', + label: 'Preselected', + properties: createTestProperties(3, [0, 2]), + }, + ]; + + const manager = createFilterManager({ + queryValue: '', + groups, + }); + + expect(manager.hasAnySelection).toBe(true); + expect(manager.groups[0].instance.selectedCount).toBe(2); + }); +}); diff --git a/src/features/GetFonts/model/index.ts b/src/features/GetFonts/model/index.ts index 077b26e..36844c9 100644 --- a/src/features/GetFonts/model/index.ts +++ b/src/features/GetFonts/model/index.ts @@ -3,4 +3,13 @@ export type { FilterGroupConfig, } from './types/filter'; +export { filtersStore } from './state/filters.svelte'; export { filterManager } from './state/manager.svelte'; + +export { + SORT_MAP, + SORT_OPTIONS, + type SortApiValue, + type SortOption, + sortStore, +} from './store/sortStore.svelte'; diff --git a/src/features/GetFonts/model/state/filters.svelte.ts b/src/features/GetFonts/model/state/filters.svelte.ts index 5882d1f..055e0d0 100644 --- a/src/features/GetFonts/model/state/filters.svelte.ts +++ b/src/features/GetFonts/model/state/filters.svelte.ts @@ -15,8 +15,8 @@ * ``` */ -import { fetchProxyFilters } from '$entities/Font/api/proxy/filters'; -import type { FilterMetadata } from '$entities/Font/api/proxy/filters'; +import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters'; +import type { FilterMetadata } from '$features/GetFonts/api/filters/filters'; import { queryClient } from '$shared/api/queryClient'; import { type QueryKey, @@ -32,9 +32,6 @@ import { * Provides reactive access to filter data */ class FiltersStore { - /** Cleanup function for effects */ - cleanup: () => void; - /** TanStack Query result state */ protected result = $state>({} as any); @@ -54,13 +51,6 @@ class FiltersStore { this.observer.subscribe(r => { this.result = r; }); - - // Sync Svelte state changes -> TanStack Query options - this.cleanup = $effect.root(() => { - $effect(() => { - this.observer.setOptions(this.getOptions()); - }); - }); } /** @@ -119,10 +109,10 @@ class FiltersStore { } /** - * Clean up effects and observers + * Clean up observer subscription */ destroy() { - this.cleanup(); + this.observer.destroy(); } } diff --git a/src/features/GetFonts/model/state/manager.svelte.ts b/src/features/GetFonts/model/state/manager.svelte.ts index dd35e6f..143e045 100644 --- a/src/features/GetFonts/model/state/manager.svelte.ts +++ b/src/features/GetFonts/model/state/manager.svelte.ts @@ -1,39 +1,39 @@ +/** + * Filter manager singleton + * + * Creates filterManager with empty groups initially, then reactively + * populates groups when filtersStore loads data from backend. + */ + import { createFilterManager } from '../../lib/filterManager/filterManager.svelte'; import { filtersStore } from './filters.svelte'; +export const filterManager = createFilterManager({ + queryValue: '', + groups: [], +}); + /** - * Creates initial filter config - * - * Uses dynamic filters from backend with empty state initially + * Reactively sync backend filter metadata into filterManager groups. + * When filtersStore.filters resolves, setGroups replaces the empty groups. */ -function createInitialConfig() { - const dynamicFilters = filtersStore.filters; +$effect.root(() => { + $effect(() => { + const dynamicFilters = filtersStore.filters; - // If filters are loaded, use them - if (dynamicFilters.length > 0) { - return { - queryValue: '', - groups: dynamicFilters.map(filter => ({ - id: filter.id, - label: filter.name, - properties: filter.options.map(opt => ({ - id: opt.id, - name: opt.name, - value: opt.value, - count: opt.count, - selected: false, + if (dynamicFilters.length > 0) { + filterManager.setGroups( + dynamicFilters.map(filter => ({ + id: filter.id, + label: filter.name, + properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({ + id: opt.id, + name: opt.name, + value: opt.value, + selected: false, + })), })), - })), - }; - } - - // No filters loaded yet - return empty state - return { - queryValue: '', - groups: [], - }; -} - -const initialConfig = createInitialConfig(); - -export const filterManager = createFilterManager(initialConfig); + ); + } + }); +}); diff --git a/src/features/GetFonts/model/store/sortStore.svelte.ts b/src/features/GetFonts/model/store/sortStore.svelte.ts new file mode 100644 index 0000000..a886339 --- /dev/null +++ b/src/features/GetFonts/model/store/sortStore.svelte.ts @@ -0,0 +1,41 @@ +/** + * Sort store — manages the current sort option for font listings. + * + * Display labels are mapped to API values through SORT_MAP so that + * the UI layer never has to know about the wire format. + */ + +export type SortOption = 'Name' | 'Popularity' | 'Newest'; + +export const SORT_OPTIONS: SortOption[] = ['Name', 'Popularity', 'Newest'] as const; + +export const SORT_MAP: Record = { + Name: 'name', + Popularity: 'popularity', + Newest: 'lastModified', +}; + +export type SortApiValue = (typeof SORT_MAP)[SortOption]; + +function createSortStore(initial: SortOption = 'Popularity') { + let current = $state(initial); + + return { + /** Current display label (e.g. 'Popularity') */ + get value() { + return current; + }, + + /** Mapped API value (e.g. 'popularity') */ + get apiValue(): SortApiValue { + return SORT_MAP[current]; + }, + + /** Set the active sort option by its display label */ + set(option: SortOption) { + current = option; + }, + }; +} + +export const sortStore = createSortStore(); diff --git a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte index 40a1074..b77f35e 100644 --- a/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte +++ b/src/features/GetFonts/ui/FiltersControl/FilterControls.svelte @@ -4,28 +4,41 @@ Sits below the filter list, separated by a top border. -->