diff --git a/src/app/App.svelte b/src/app/App.svelte index 6f9fccc..ad5a97e 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -16,12 +16,17 @@ */ import '$routes/router'; import { Router } from 'sv-router'; -import { QueryProvider } from './providers'; +import { + AppBindingsProvider, + QueryProvider, +} from './providers'; import Layout from './ui/Layout.svelte'; - - - + + + + + diff --git a/src/app/providers/AppBindings.svelte b/src/app/providers/AppBindings.svelte new file mode 100644 index 0000000..3640dda --- /dev/null +++ b/src/app/providers/AppBindings.svelte @@ -0,0 +1,24 @@ + + + +{@render children?.()} diff --git a/src/app/providers/index.ts b/src/app/providers/index.ts index 80a7590..b8363e6 100644 --- a/src/app/providers/index.ts +++ b/src/app/providers/index.ts @@ -1 +1,2 @@ +export { default as AppBindingsProvider } from './AppBindings.svelte'; export { default as QueryProvider } from './QueryProvider.svelte'; diff --git a/src/features/FilterAndSortFonts/index.ts b/src/features/FilterAndSortFonts/index.ts index bd666a3..c76c3c2 100644 --- a/src/features/FilterAndSortFonts/index.ts +++ b/src/features/FilterAndSortFonts/index.ts @@ -1,7 +1,6 @@ export { mapAppliedFiltersToParams } from './lib'; export { - type AppliedFilterStore, appliedFilterStore, /** * Filter Store @@ -16,9 +15,14 @@ export { */ SORT_MAP, SORT_OPTIONS, - type SortApiValue, - type SortOption, sortStore, + startFilterBindings, +} from './model'; + +export type { + AppliedFilterStore, + SortApiValue, + SortOption, } from './model'; export { diff --git a/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.test.ts b/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.test.ts new file mode 100644 index 0000000..7c42e82 --- /dev/null +++ b/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.test.ts @@ -0,0 +1,83 @@ +import { + describe, + expect, + it, +} from 'vitest'; +import type { + FilterMetadata, + FilterOption, +} from '../../api/filters/filters'; +import { mapFilterMetadataToGroups } from './mapFilterMetadataToGroups'; + +/** + * Build a FilterOption with a known value and count. + */ +function option(value: string, count: number): FilterOption { + return { id: value, name: value, value, count }; +} + +/** + * Build filter metadata for one group from (value, count) entries. + */ +function metadata(id: string, options: Array<[string, number]>): FilterMetadata { + return { + id, + name: id, + description: '', + type: 'array', + options: options.map(([value, count]) => option(value, count)), + }; +} + +describe('mapFilterMetadataToGroups', () => { + it('maps id and name onto group id and label', () => { + const [group] = mapFilterMetadataToGroups([metadata('categories', [['serif', 1]])]); + + expect(group.id).toBe('categories'); + expect(group.label).toBe('categories'); + }); + + it('projects each option to a property with selected: false', () => { + const [group] = mapFilterMetadataToGroups([metadata('providers', [['google', 5]])]); + + expect(group.properties).toEqual([ + { id: 'google', name: 'google', value: 'google', selected: false }, + ]); + }); + + it('orders properties by descending count', () => { + const [group] = mapFilterMetadataToGroups([ + metadata('subsets', [['latin', 2], ['cyrillic', 9], ['greek', 5]]), + ]); + + expect(group.properties.map(p => p.value)).toEqual(['cyrillic', 'greek', 'latin']); + }); + + it('does not mutate the source options array (TanStack cache safety)', () => { + const source = metadata('subsets', [['latin', 2], ['cyrillic', 9]]); + const originalOrder = source.options.map(o => o.value); + + mapFilterMetadataToGroups([source]); + + expect(source.options.map(o => o.value)).toEqual(originalOrder); + }); + + it('maps every group, preserving group order', () => { + const groups = mapFilterMetadataToGroups([ + metadata('providers', [['google', 1]]), + metadata('categories', [['serif', 1]]), + ]); + + expect(groups.map(g => g.id)).toEqual(['providers', 'categories']); + }); + + it('returns an empty group list for empty metadata', () => { + expect(mapFilterMetadataToGroups([])).toEqual([]); + }); + + it('yields an empty properties list when a group has no options', () => { + const [group] = mapFilterMetadataToGroups([metadata('providers', [])]); + + expect(group.properties).toEqual([]); + }); +}); diff --git a/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.ts b/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.ts new file mode 100644 index 0000000..f17244e --- /dev/null +++ b/src/features/FilterAndSortFonts/lib/mapper/mapFilterMetadataToGroups.ts @@ -0,0 +1,36 @@ +import type { FilterMetadata } from '../../api/filters/filters'; +import type { FilterGroupConfig } from '../../model'; + +/** + * Map backend filter metadata into the group configs `appliedFilterStore.setGroups` + * consumes. + * + * Inverse direction of `mapAppliedFiltersToParams`: that maps applied selections out + * to API params; this maps the API's available-filter catalog in to the UI model. + * + * Options are ordered by descending font count so the most populated values surface + * first. The source array is copied before sorting — `metadata` is TanStack-cached + * query data, and `.sort()` mutates in place; sorting the live cache both corrupts it + * and, when called from a reactive effect, writes into that effect's own read + * dependency (triggering an update loop). + * + * Every property starts unselected; selection state is owned by the store, not the + * backend catalog. + * + * @param metadata - Available-filter catalog from the filters endpoint + * @returns Group configs ready for `setGroups` + */ +export function mapFilterMetadataToGroups(metadata: FilterMetadata[]): FilterGroupConfig[] { + return metadata.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, + })), + })); +} diff --git a/src/features/FilterAndSortFonts/model/index.ts b/src/features/FilterAndSortFonts/model/index.ts index 755643a..49ad32a 100644 --- a/src/features/FilterAndSortFonts/model/index.ts +++ b/src/features/FilterAndSortFonts/model/index.ts @@ -41,7 +41,7 @@ export { * Side-effect import: installs the global appliedFilterStore+sortStore → fontCatalogStore * bridge on first import of this feature barrel. No exports. */ -import './store/bindings.svelte'; +export { startFilterBindings } from './store/bindings.svelte'; /** * Sorting logic diff --git a/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts b/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts index f2e8258..11a65cc 100644 --- a/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/appliedFilterStore/appliedFilterStore.svelte.ts @@ -42,8 +42,13 @@ import type { export function createAppliedFilterStore(config: FilterConfig) { const search = createDebouncedState(config.queryValue ?? ''); - // Create filter instances upfront - const groups = $state( + // Create filter instances upfront. + // `let` (not `const`) so setGroups can REASSIGN the whole array. In-place + // `groups.length = 0; groups.push(...)` is forbidden here: push reads the + // array's length signal, so a $effect that calls setGroups would both read + // and write `groups.length` in one run and re-trigger itself forever + // (effect_update_depth_exceeded). + let groups = $state( config.groups.map(config => ({ id: config.id, label: config.label, @@ -62,14 +67,11 @@ export function createAppliedFilterStore(config: FilterCo * 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 }), - })), - ); + groups = newGroups.map(g => ({ + id: g.id, + label: g.label, + instance: createFilter({ properties: g.properties }), + })); }, /** * Current search query value (immediate, for UI binding) diff --git a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts index 4191533..537d5d8 100644 --- a/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts +++ b/src/features/FilterAndSortFonts/model/store/bindings.svelte.ts @@ -9,52 +9,30 @@ * observer, so it lives at module scope, not in any individual widget. */ -import { fontCatalogStore } from '$entities/Font/model'; +import { getFontCatalog } from '$entities/Font/model'; import { untrack } from 'svelte'; import { mapAppliedFiltersToParams } from '../../lib/mapper/mapAppliedFiltersToParams'; +import { mapFilterMetadataToGroups } from '../../lib/mapper/mapFilterMetadataToGroups'; import { appliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte'; import { availableFilterStore } from './availableFilterStore/availableFilterStore.svelte'; import { sortStore } from './sortStore/sortStore.svelte'; -$effect.root(() => { - /** - * Populate appliedFilterStore groups when backend filter metadata resolves. - * availableFilterStore is async; until it loads, appliedFilterStore has empty groups - * and the UI renders nothing for them. - */ - $effect(() => { - const dynamicFilters = availableFilterStore.filters; +export function startFilterBindings(): () => void { + const stop = $effect.root(() => { + $effect(() => { + const dynamicFilters = availableFilterStore.filters; + if (dynamicFilters.length > 0) { + appliedFilterStore.setGroups(mapFilterMetadataToGroups(dynamicFilters)); + } + }); - if (dynamicFilters.length > 0) { - appliedFilterStore.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, - })), - })), - ); - } + $effect(() => { + const params = mapAppliedFiltersToParams(appliedFilterStore); + const sort = sortStore.apiValue; + const catalog = getFontCatalog(); + untrack(() => catalog.setParams({ ...params, sort })); + }); }); - /** - * Mirror filter selections + debounced search query + sort into fontCatalogStore params. - * - * Filters and sort are merged into one setParams call to avoid a startup race: - * two separate effects each issued setOptions with a different queryKey on the - * first flush, producing an orphaned `?limit=50&offset=0` fetch immediately - * followed by the real `?limit=50&sort=popularity&offset=0` fetch. - * - * untrack the write so fontCatalogStore's internal $state reads don't feed back - * into this effect's dependency graph. - */ - $effect(() => { - const params = mapAppliedFiltersToParams(appliedFilterStore); - const sort = sortStore.apiValue; - untrack(() => fontCatalogStore.setParams({ ...params, sort })); - }); -}); + return stop; // hand the caller the cleanup +}