Refactor/reacrhitecture to fsd+ #49

Merged
ilia merged 70 commits from refactor/reacrhitecture-to-fsd+ into main 2026-06-03 09:55:47 +00:00
13 changed files with 107 additions and 46 deletions
Showing only changes of commit 0b675635b3 - Show all commits
+6 -6
View File
@@ -1,21 +1,21 @@
export { mapAppliedFiltersToParams } from './lib';
export {
appliedFilterStore,
/**
* Filter Store
*/
availableFilterStore,
/**
* Filter Manager
*/
createAppliedFilterStore,
/**
* Lazy store accessors
*/
getAppliedFilterStore,
getAvailableFilterStore,
getSortStore,
/**
* Sort Store
*/
SORT_MAP,
SORT_OPTIONS,
sortStore,
startFilterBindings,
} from './model';
+10 -10
View File
@@ -14,9 +14,9 @@ export type {
*/
export {
/**
* Low-level property selection store
* Lazy accessor for the app-wide filter-metadata store
*/
availableFilterStore,
getAvailableFilterStore,
} from './store/availableFilterStore/availableFilterStore.svelte';
/**
@@ -27,14 +27,14 @@ export {
* Reactive interface returned by `createAppliedFilterStore`
*/
type AppliedFilterStore,
/**
* High-level manager for syncing search and filters
*/
appliedFilterStore,
/**
* Factory for constructing a filter manager instance
*/
createAppliedFilterStore,
/**
* Lazy accessor for the app-wide filter manager
*/
getAppliedFilterStore,
} from './store/appliedFilterStore/appliedFilterStore.svelte';
/**
@@ -47,6 +47,10 @@ export { startFilterBindings } from './store/bindings.svelte';
* Sorting logic
*/
export {
/**
* Lazy accessor for the app-wide sort store
*/
getSortStore,
/**
* Map of human-readable labels to API sort keys
*/
@@ -63,8 +67,4 @@ export {
* UI model for a single sort option
*/
type SortOption,
/**
* Reactive store for the current sort selection
*/
sortStore,
} from './store/sortStore/sortStore.svelte';
@@ -129,14 +129,23 @@ export function createAppliedFilterStore<TValue extends string>(config: FilterCo
export type AppliedFilterStore = ReturnType<typeof createAppliedFilterStore>;
let _appliedFilterStore: AppliedFilterStore | undefined;
/**
* App-wide filter manager singleton.
* App-wide filter manager, created on first access.
*
* Constructed with empty groups; the availableFilterStore → appliedFilterStore wiring
* lives in `./bindings.svelte` and populates groups once backend filter
* metadata arrives.
*/
export const appliedFilterStore = createAppliedFilterStore({
export function getAppliedFilterStore(): AppliedFilterStore {
return (_appliedFilterStore ??= createAppliedFilterStore<string>({
queryValue: '',
groups: [],
});
}));
}
// test-only reset, so specs don't share filter/selection state
export function __resetAppliedFilterStore() {
_appliedFilterStore = undefined;
}
@@ -126,7 +126,18 @@ export class AvailableFilterStore {
}
}
let _availableFilterStore: AvailableFilterStore | undefined;
/**
* Singleton instance
* App-wide filter-metadata store, created on first access. Lazy so the
* QueryObserver isn't constructed at module load.
*/
export const availableFilterStore = new AvailableFilterStore();
export function getAvailableFilterStore(): AvailableFilterStore {
return (_availableFilterStore ??= new AvailableFilterStore());
}
// test-only reset, so specs don't share a live observer
export function __resetAvailableFilterStore() {
_availableFilterStore?.destroy();
_availableFilterStore = undefined;
}
@@ -13,11 +13,15 @@ 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';
import { getAppliedFilterStore } from './appliedFilterStore/appliedFilterStore.svelte';
import { getAvailableFilterStore } from './availableFilterStore/availableFilterStore.svelte';
import { getSortStore } from './sortStore/sortStore.svelte';
export function startFilterBindings(): () => void {
const appliedFilterStore = getAppliedFilterStore();
const availableFilterStore = getAvailableFilterStore();
const sortStore = getSortStore();
const stop = $effect.root(() => {
$effect(() => {
const dynamicFilters = availableFilterStore.filters;
@@ -44,4 +44,18 @@ export function createSortStore(initial: SortOption = 'Popularity') {
};
}
export const sortStore = createSortStore();
export type SortStore = ReturnType<typeof createSortStore>;
let _sortStore: SortStore | undefined;
/**
* App-wide sort store, created on first access.
*/
export function getSortStore(): SortStore {
return (_sortStore ??= createSortStore());
}
// test-only reset, so specs don't share selection state
export function __resetSortStore() {
_sortStore = undefined;
}
@@ -1,4 +1,5 @@
import {
afterEach,
describe,
expect,
it,
@@ -7,8 +8,9 @@ import {
SORT_MAP,
SORT_OPTIONS,
type SortOption,
__resetSortStore,
createSortStore,
sortStore,
getSortStore,
} from './sortStore.svelte';
describe('createSortStore', () => {
@@ -51,14 +53,24 @@ describe('createSortStore', () => {
});
});
describe('sortStore singleton', () => {
describe('getSortStore singleton', () => {
afterEach(() => {
__resetSortStore();
});
it('returns the same instance across calls', () => {
expect(getSortStore()).toBe(getSortStore());
});
it('exposes the same shape as a factory instance', () => {
const sortStore = getSortStore();
expect(typeof sortStore.value).toBe('string');
expect(typeof sortStore.apiValue).toBe('string');
expect(typeof sortStore.set).toBe('function');
});
it('accepts all SORT_OPTIONS as valid set() inputs', () => {
const sortStore = getSortStore();
for (const option of SORT_OPTIONS) {
sortStore.set(option);
expect(sortStore.value).toBe(option);
@@ -4,10 +4,13 @@
-->
<script lang="ts">
import { FilterGroup } from '$shared/ui';
import { appliedFilterStore } from '../../model';
import { getAppliedFilterStore } from '../../model';
const appliedFilterStore = getAppliedFilterStore();
const groups = $derived(appliedFilterStore.groups);
</script>
{#each appliedFilterStore.groups as group (group.id)}
{#each groups as group (group.id)}
<FilterGroup
displayedLabel={group.label}
filter={group.instance}
@@ -1,6 +1,6 @@
import {
appliedFilterStore,
availableFilterStore,
getAppliedFilterStore,
getAvailableFilterStore,
} from '$features/FilterAndSortFonts';
import {
render,
@@ -12,8 +12,8 @@ import Filters from './Filters.svelte';
describe('Filters', () => {
beforeEach(() => {
// Clear groups and mock availableFilterStore to be empty so the auto-sync effect doesn't overwrite us
appliedFilterStore.setGroups([]);
vi.spyOn(availableFilterStore, 'filters', 'get').mockReturnValue([]);
getAppliedFilterStore().setGroups([]);
vi.spyOn(getAvailableFilterStore(), 'filters', 'get').mockReturnValue([]);
});
afterEach(() => {
@@ -28,7 +28,7 @@ describe('Filters', () => {
});
it('renders a label for each filter group', () => {
appliedFilterStore.setGroups([
getAppliedFilterStore().setGroups([
{ id: 'cat', label: 'Categories', properties: [] },
{ id: 'prov', label: 'Font Providers', properties: [] },
]);
@@ -38,7 +38,7 @@ describe('Filters', () => {
});
it('renders filter properties within groups', () => {
appliedFilterStore.setGroups([
getAppliedFilterStore().setGroups([
{
id: 'cat',
label: 'Category',
@@ -54,7 +54,7 @@ describe('Filters', () => {
});
it('renders multiple groups with their properties', () => {
appliedFilterStore.setGroups([
getAppliedFilterStore().setGroups([
{
id: 'cat',
label: 'Category',
@@ -12,8 +12,8 @@ import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
import { getContext } from 'svelte';
import {
SORT_OPTIONS,
appliedFilterStore,
sortStore,
getAppliedFilterStore,
getSortStore,
} from '../../model';
interface Props {
@@ -30,6 +30,10 @@ const {
const responsive = getContext<ResponsiveManager>('responsive');
const isMobileOrTabletPortrait = $derived(responsive.isMobile || responsive.isTabletPortrait);
const appliedFilterStore = getAppliedFilterStore();
const sortStore = getSortStore();
const sortValue = $derived(sortStore.value);
function handleReset() {
appliedFilterStore.deselectAllGlobal();
}
@@ -53,7 +57,7 @@ function handleReset() {
<Button
variant="ghost"
size={isMobileOrTabletPortrait ? 'xs' : 'sm'}
active={sortStore.value === option}
active={sortValue === option}
onclick={() => sortStore.set(option)}
class="tracking-wide px-0"
>
@@ -5,8 +5,10 @@
propagates the value into fontCatalogStore.
-->
<script lang="ts">
import { appliedFilterStore } from '$features/FilterAndSortFonts';
import { getAppliedFilterStore } from '$features/FilterAndSortFonts';
import { SearchBar } from '$shared/ui';
const appliedFilterStore = getAppliedFilterStore();
</script>
<div class="p-6 border-b border-subtle">
@@ -1,4 +1,4 @@
import { appliedFilterStore } from '$features/FilterAndSortFonts';
import { getAppliedFilterStore } from '$features/FilterAndSortFonts';
import {
render,
screen,
@@ -7,7 +7,7 @@ import Search from './Search.svelte';
describe('Search', () => {
beforeEach(() => {
appliedFilterStore.queryValue = '';
getAppliedFilterStore().queryValue = '';
});
describe('Rendering', () => {
@@ -24,7 +24,7 @@ describe('Search', () => {
describe('Value binding', () => {
it('reflects appliedFilterStore.queryValue as initial value', () => {
appliedFilterStore.queryValue = 'Inter';
getAppliedFilterStore().queryValue = 'Inter';
render(Search);
expect(screen.getByRole('textbox')).toHaveValue('Inter');
});
@@ -6,7 +6,7 @@
import {
FilterControls,
Filters,
appliedFilterStore,
getAppliedFilterStore,
} from '$features/FilterAndSortFonts';
import { springySlideFade } from '$shared/lib';
import {
@@ -31,6 +31,8 @@ interface Props {
let { showFilters = $bindable(true) }: Props = $props();
const appliedFilterStore = getAppliedFilterStore();
const transform = new Tween(
{ scale: 1, rotate: 0 },
{ duration: 250, easing: cubicOut },