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,
|
||||
} 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,
|
||||
|
||||
@@ -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<TValue extends string>(config: FilterConfig<TValue>) {
|
||||
const search = createDebouncedState(config.queryValue ?? '');
|
||||
@@ -28,37 +54,68 @@ export function createFilterManager<TValue extends string>(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<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() {
|
||||
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);
|
||||
},
|
||||
|
||||
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,
|
||||
} 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';
|
||||
|
||||
@@ -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<QueryObserverResult<FilterMetadata[], Error>>({} 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
$effect.root(() => {
|
||||
$effect(() => {
|
||||
const dynamicFilters = filtersStore.filters;
|
||||
|
||||
// If filters are loaded, use them
|
||||
if (dynamicFilters.length > 0) {
|
||||
return {
|
||||
queryValue: '',
|
||||
groups: dynamicFilters.map(filter => ({
|
||||
filterManager.setGroups(
|
||||
dynamicFilters.map(filter => ({
|
||||
id: filter.id,
|
||||
label: filter.name,
|
||||
properties: filter.options.map(opt => ({
|
||||
properties: filter.options.sort((a, b) => b.count - a.count).map(opt => ({
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
value: opt.value,
|
||||
count: opt.count,
|
||||
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.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { unifiedFontStore } from '$entities/Font';
|
||||
import type { ResponsiveManager } from '$shared/lib';
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import { Button } from '$shared/ui';
|
||||
import { Label } from '$shared/ui';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import { filterManager } from '../../model';
|
||||
|
||||
type SortOption = 'Name' | 'Popularity' | 'Newest';
|
||||
|
||||
const SORT_OPTIONS: SortOption[] = ['Name', 'Popularity', 'Newest'];
|
||||
import {
|
||||
getContext,
|
||||
untrack,
|
||||
} from 'svelte';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
filterManager,
|
||||
sortStore,
|
||||
} from '../../model';
|
||||
|
||||
interface Props {
|
||||
sort?: SortOption;
|
||||
onSortChange?: (v: SortOption) => void;
|
||||
/**
|
||||
* CSS classes
|
||||
*/
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const {
|
||||
sort = 'Popularity',
|
||||
onSortChange,
|
||||
class: className,
|
||||
}: 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() {
|
||||
filterManager.deselectAllGlobal();
|
||||
}
|
||||
@@ -34,28 +47,27 @@ function handleReset() {
|
||||
<div
|
||||
class={cn(
|
||||
'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',
|
||||
'border-t border-foreground/5 dark:border-white/10',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<!-- Left: 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">
|
||||
<!-- Sort By label + options -->
|
||||
<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>
|
||||
|
||||
<div class="flex gap-3 md:gap-4">
|
||||
{#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
|
||||
variant="ghost"
|
||||
active={sort === option}
|
||||
onclick={() => onSortChange?.(option)}
|
||||
class="text-xs font-bold uppercase tracking-wide font-primary"
|
||||
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
||||
active={sortStore.value === option}
|
||||
onclick={() => sortStore.set(option)}
|
||||
class={cn(
|
||||
'font-bold uppercase tracking-wide font-primary, px-0',
|
||||
isMobileOrTabletPortrait ? 'text-[0.5625rem]' : 'text-[0.625rem]',
|
||||
)}
|
||||
>
|
||||
{option}
|
||||
</Button>
|
||||
@@ -63,23 +75,19 @@ function handleReset() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: 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.
|
||||
-->
|
||||
<!-- Reset_Filters -->
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={isMobileOrTabletPortrait ? 'sm' : 'md'}
|
||||
onclick={handleReset}
|
||||
class="
|
||||
group
|
||||
text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest
|
||||
text-neutral-400
|
||||
"
|
||||
class={cn(
|
||||
'group text-[0.5625rem] md:text-[0.625rem] font-mono font-bold uppercase tracking-widest text-neutral-400',
|
||||
isMobileOrTabletPortrait && 'px-0',
|
||||
)}
|
||||
iconPosition="left"
|
||||
>
|
||||
{#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}
|
||||
Reset_Filters
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user