refactor(GetFonts): restructure filter API and add sort store
This commit is contained in:
85
src/features/GetFonts/api/filters/filters.ts
Normal file
85
src/features/GetFonts/api/filters/filters.ts
Normal file
@@ -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<FilterMetadata[]> {
|
||||||
|
const response = await api.get<FilterMetadata[]>(PROXY_API_URL);
|
||||||
|
|
||||||
|
if (!response.data || !Array.isArray(response.data)) {
|
||||||
|
throw new Error('Proxy API returned invalid response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
1
src/features/GetFonts/api/index.ts
Normal file
1
src/features/GetFonts/api/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './filters/filters';
|
||||||
@@ -4,14 +4,16 @@ export {
|
|||||||
mapManagerToParams,
|
mapManagerToParams,
|
||||||
} from './lib';
|
} from './lib';
|
||||||
|
|
||||||
export {
|
|
||||||
FONT_CATEGORIES,
|
|
||||||
FONT_PROVIDERS,
|
|
||||||
FONT_SUBSETS,
|
|
||||||
} from './model/const/const';
|
|
||||||
|
|
||||||
export { filterManager } from './model/state/manager.svelte';
|
export { filterManager } from './model/state/manager.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
SORT_MAP,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
type SortApiValue,
|
||||||
|
type SortOption,
|
||||||
|
sortStore,
|
||||||
|
} from './model/store/sortStore.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
FilterControls,
|
FilterControls,
|
||||||
Filters,
|
Filters,
|
||||||
|
|||||||
@@ -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 { createFilter } from '$shared/lib';
|
||||||
import { createDebouncedState } from '$shared/lib/helpers';
|
import { createDebouncedState } from '$shared/lib/helpers';
|
||||||
import type { FilterConfig } from '../../model';
|
import type {
|
||||||
|
FilterConfig,
|
||||||
|
FilterGroupConfig,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a filter manager instance.
|
* Creates a filter manager instance
|
||||||
* - Uses debounce to update search query for better performance.
|
|
||||||
* - Manages filter instances for each group.
|
|
||||||
*
|
*
|
||||||
* @param config - Configuration for the filter manager.
|
* Manages multiple filter groups with debounced search. Each group
|
||||||
* @returns - An instance of the filter manager.
|
* 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<TValue extends string>(config: FilterConfig<TValue>) {
|
export function createFilterManager<TValue extends string>(config: FilterConfig<TValue>) {
|
||||||
const search = createDebouncedState(config.queryValue ?? '');
|
const search = createDebouncedState(config.queryValue ?? '');
|
||||||
@@ -28,37 +54,68 @@ export function createFilterManager<TValue extends string>(config: FilterConfig<
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
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<TValue>[]) {
|
||||||
|
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() {
|
get queryValue() {
|
||||||
return search.immediate;
|
return search.immediate;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Setter for queryValue
|
/**
|
||||||
|
* Set the search query value
|
||||||
|
*/
|
||||||
set queryValue(value) {
|
set queryValue(value) {
|
||||||
search.immediate = 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() {
|
get debouncedQueryValue() {
|
||||||
return search.debounced;
|
return search.debounced;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Direct array reference (reactive)
|
/**
|
||||||
|
* All filter groups (reactive)
|
||||||
|
*/
|
||||||
get groups() {
|
get groups() {
|
||||||
return groups;
|
return groups;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Derived values
|
/**
|
||||||
|
* Whether any filter has an active selection
|
||||||
|
*/
|
||||||
get hasAnySelection() {
|
get hasAnySelection() {
|
||||||
return hasAnySelection;
|
return hasAnySelection;
|
||||||
},
|
},
|
||||||
|
|
||||||
// Global action
|
/**
|
||||||
|
* Deselect all filters across all groups
|
||||||
|
*/
|
||||||
deselectAllGlobal: () => {
|
deselectAllGlobal: () => {
|
||||||
groups.forEach(group => group.instance.deselectAll());
|
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) => {
|
getGroup: (id: string) => {
|
||||||
return groups.find(g => g.id === id);
|
return groups.find(g => g.id === id);
|
||||||
},
|
},
|
||||||
|
|||||||
784
src/features/GetFonts/lib/filterManager/filterManager.test.ts
Normal file
784
src/features/GetFonts/lib/filterManager/filterManager.test.ts
Normal file
@@ -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<string>[] {
|
||||||
|
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<string>[];
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,4 +3,13 @@ export type {
|
|||||||
FilterGroupConfig,
|
FilterGroupConfig,
|
||||||
} from './types/filter';
|
} from './types/filter';
|
||||||
|
|
||||||
|
export { filtersStore } from './state/filters.svelte';
|
||||||
export { filterManager } from './state/manager.svelte';
|
export { filterManager } from './state/manager.svelte';
|
||||||
|
|
||||||
|
export {
|
||||||
|
SORT_MAP,
|
||||||
|
SORT_OPTIONS,
|
||||||
|
type SortApiValue,
|
||||||
|
type SortOption,
|
||||||
|
sortStore,
|
||||||
|
} from './store/sortStore.svelte';
|
||||||
|
|||||||
@@ -15,8 +15,8 @@
|
|||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { fetchProxyFilters } from '$entities/Font/api/proxy/filters';
|
import { fetchProxyFilters } from '$features/GetFonts/api/filters/filters';
|
||||||
import type { FilterMetadata } from '$entities/Font/api/proxy/filters';
|
import type { FilterMetadata } from '$features/GetFonts/api/filters/filters';
|
||||||
import { queryClient } from '$shared/api/queryClient';
|
import { queryClient } from '$shared/api/queryClient';
|
||||||
import {
|
import {
|
||||||
type QueryKey,
|
type QueryKey,
|
||||||
@@ -32,9 +32,6 @@ import {
|
|||||||
* Provides reactive access to filter data
|
* Provides reactive access to filter data
|
||||||
*/
|
*/
|
||||||
class FiltersStore {
|
class FiltersStore {
|
||||||
/** Cleanup function for effects */
|
|
||||||
cleanup: () => void;
|
|
||||||
|
|
||||||
/** TanStack Query result state */
|
/** TanStack Query result state */
|
||||||
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
|
protected result = $state<QueryObserverResult<FilterMetadata[], Error>>({} as any);
|
||||||
|
|
||||||
@@ -54,13 +51,6 @@ class FiltersStore {
|
|||||||
this.observer.subscribe(r => {
|
this.observer.subscribe(r => {
|
||||||
this.result = 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() {
|
destroy() {
|
||||||
this.cleanup();
|
this.observer.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { createFilterManager } from '../../lib/filterManager/filterManager.svelte';
|
||||||
import { filtersStore } from './filters.svelte';
|
import { filtersStore } from './filters.svelte';
|
||||||
|
|
||||||
|
export const filterManager = createFilterManager({
|
||||||
|
queryValue: '',
|
||||||
|
groups: [],
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates initial filter config
|
* Reactively sync backend filter metadata into filterManager groups.
|
||||||
*
|
* When filtersStore.filters resolves, setGroups replaces the empty groups.
|
||||||
* Uses dynamic filters from backend with empty state initially
|
|
||||||
*/
|
*/
|
||||||
function createInitialConfig() {
|
$effect.root(() => {
|
||||||
|
$effect(() => {
|
||||||
const dynamicFilters = filtersStore.filters;
|
const dynamicFilters = filtersStore.filters;
|
||||||
|
|
||||||
// If filters are loaded, use them
|
|
||||||
if (dynamicFilters.length > 0) {
|
if (dynamicFilters.length > 0) {
|
||||||
return {
|
filterManager.setGroups(
|
||||||
queryValue: '',
|
dynamicFilters.map(filter => ({
|
||||||
groups: dynamicFilters.map(filter => ({
|
|
||||||
id: filter.id,
|
id: filter.id,
|
||||||
label: filter.name,
|
label: filter.name,
|
||||||
properties: filter.options.map(opt => ({
|
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
|
||||||
id: opt.id,
|
id: opt.id,
|
||||||
name: opt.name,
|
name: opt.name,
|
||||||
value: opt.value,
|
value: opt.value,
|
||||||
count: opt.count,
|
|
||||||
selected: false,
|
selected: false,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
};
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
// No filters loaded yet - return empty state
|
});
|
||||||
return {
|
|
||||||
queryValue: '',
|
|
||||||
groups: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialConfig = createInitialConfig();
|
|
||||||
|
|
||||||
export const filterManager = createFilterManager(initialConfig);
|
|
||||||
|
|||||||
41
src/features/GetFonts/model/store/sortStore.svelte.ts
Normal file
41
src/features/GetFonts/model/store/sortStore.svelte.ts
Normal file
@@ -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<SortOption, 'name' | 'popularity' | 'lastModified'> = {
|
||||||
|
Name: 'name',
|
||||||
|
Popularity: 'popularity',
|
||||||
|
Newest: 'lastModified',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SortApiValue = (typeof SORT_MAP)[SortOption];
|
||||||
|
|
||||||
|
function createSortStore(initial: SortOption = 'Popularity') {
|
||||||
|
let current = $state<SortOption>(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();
|
||||||
@@ -4,28 +4,41 @@
|
|||||||
Sits below the filter list, separated by a top border.
|
Sits below the filter list, separated by a top border.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { unifiedFontStore } from '$entities/Font';
|
||||||
|
import type { ResponsiveManager } from '$shared/lib';
|
||||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||||
import { Button } from '$shared/ui';
|
import { Button } from '$shared/ui';
|
||||||
import { Label } from '$shared/ui';
|
import { Label } from '$shared/ui';
|
||||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||||
import { filterManager } from '../../model';
|
import {
|
||||||
|
getContext,
|
||||||
type SortOption = 'Name' | 'Popularity' | 'Newest';
|
untrack,
|
||||||
|
} from 'svelte';
|
||||||
const SORT_OPTIONS: SortOption[] = ['Name', 'Popularity', 'Newest'];
|
import {
|
||||||
|
SORT_OPTIONS,
|
||||||
|
filterManager,
|
||||||
|
sortStore,
|
||||||
|
} from '../../model';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sort?: SortOption;
|
/**
|
||||||
onSortChange?: (v: SortOption) => void;
|
* CSS classes
|
||||||
|
*/
|
||||||
class?: string;
|
class?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
sort = 'Popularity',
|
|
||||||
onSortChange,
|
|
||||||
class: className,
|
class: className,
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const apiSort = sortStore.apiValue;
|
||||||
|
untrack(() => unifiedFontStore.setSort(apiSort));
|
||||||
|
});
|
||||||
|
|
||||||
|
const responsive = getContext<ResponsiveManager>('responsive');
|
||||||
|
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
filterManager.deselectAllGlobal();
|
filterManager.deselectAllGlobal();
|
||||||
}
|
}
|
||||||
@@ -34,28 +47,27 @@ function handleReset() {
|
|||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex flex-col md:flex-row justify-between items-start md:items-center',
|
'flex flex-col md:flex-row justify-between items-start md:items-center',
|
||||||
'gap-4 md:gap-6',
|
'gap-1 md:gap-6',
|
||||||
'pt-6 mt-6 md:pt-8 md:mt-8',
|
'pt-6 mt-6 md:pt-8 md:mt-8',
|
||||||
'border-t border-foreground/5 dark:border-white/10',
|
'border-t border-foreground/5 dark:border-white/10',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<!-- Left: Sort By label + options -->
|
<!-- Sort By label + options -->
|
||||||
<div class="flex flex-col md:flex-row items-start md:items-center gap-3 md:gap-8 w-full md:w-auto">
|
<div class="flex flex-col md:flex-row items-start md:items-center gap-2 md:gap-8 w-full md:w-auto">
|
||||||
<Label variant="muted" size="sm">Sort By:</Label>
|
<Label variant="muted" size="sm">Sort By:</Label>
|
||||||
|
|
||||||
<div class="flex gap-3 md:gap-4">
|
<div class="flex gap-3 md:gap-4">
|
||||||
{#each SORT_OPTIONS as option}
|
{#each SORT_OPTIONS as option}
|
||||||
<!--
|
|
||||||
Ghost button with red-only hover (no bg lift).
|
|
||||||
active prop turns text [#ff3b30] for the selected sort.
|
|
||||||
class overrides: Space_Grotesk bold tracking-wide, no padding bg.
|
|
||||||
-->
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
active={sort === option}
|
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
||||||
onclick={() => onSortChange?.(option)}
|
active={sortStore.value === option}
|
||||||
class="text-xs font-bold uppercase tracking-wide font-primary"
|
onclick={() => sortStore.set(option)}
|
||||||
|
class={cn(
|
||||||
|
'font-bold uppercase tracking-wide font-primary, px-0',
|
||||||
|
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -63,23 +75,19 @@ function handleReset() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Reset_Filters -->
|
<!-- Reset_Filters -->
|
||||||
<!--
|
|
||||||
Bare ghost, red hover. Space_Mono to match Swiss technical text pattern.
|
|
||||||
Icon uses CSS group-hover for the spin — no Tween needed.
|
|
||||||
-->
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
||||||
onclick={handleReset}
|
onclick={handleReset}
|
||||||
class="
|
class={cn(
|
||||||
group
|
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
|
||||||
text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest
|
isMobileOrTabletPortrait && 'px-0',
|
||||||
text-neutral-400
|
)}
|
||||||
"
|
|
||||||
iconPosition="left"
|
iconPosition="left"
|
||||||
>
|
>
|
||||||
{#snippet icon()}
|
{#snippet icon()}
|
||||||
<RefreshCwIcon class="size-4 transition-transform duration-300 group-hover:rotate-180" />
|
<RefreshCwIcon class="size-3 transition-transform duration-300 group-hover:rotate-180" />
|
||||||
{/snippet}
|
{/snippet}
|
||||||
Reset_Filters
|
Reset_Filters
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Reference in New Issue
Block a user