Refactor/reacrhitecture to fsd+ #49
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
+14
-5
@@ -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({
|
||||
queryValue: '',
|
||||
groups: [],
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
+13
-2
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user