Move State out of Helpers (#65)

This commit is contained in:
Xinrui Chen 2022-09-04 14:15:15 -07:00 committed by GitHub
parent 5d43a1186d
commit 2101b2a416
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 287 additions and 243 deletions

View File

@ -1,5 +1,5 @@
import { avgRating } from '$helpers';
import type { FullProduct, Tag } from '$types';
import type { Product, Tag } from '$types';
export type TagArgs = {
value: string;
@ -10,33 +10,66 @@ export type RatingArgs = {
value: string | number;
};
/** Default value for filter selectors, saved in store */
export const defaultFilter = { selectedCat: 0, selectedRating: 0 };
/** Rating greater than what is passed in */
export const _ratingHigher = (product: FullProduct, { value }: RatingArgs) =>
product.rating && avgRating(product.rating) >= Number(value);
/** Matches tags according to passed value */
export const _matchesTag = (product: FullProduct, { value, $tags }: TagArgs): boolean => {
try {
if (product.tags) {
const selectedTagId = $tags.find((tag) => tag.name === value)._id;
const productTags = product.tags.map((tag) => tag._ref);
if (productTags.includes(selectedTagId)) return true;
} else return false;
} catch {
return true;
}
export type FilterArgs = {
type: string;
value: string | number;
products: Product[];
tags?: Tag[];
};
export type DefaultFilter = {
selectedCat: string | number;
selectedRating: string | number;
};
/** Returns matching product tag from full list of tags */
export const _findProductId = (tags: Tag[], name: string): string =>
tags.find((tag) => tag.name === name)?._id;
/** Checks whether product has a tagId that matches passed id string */
export const _productIncludesRef = (product: Product, id: string): boolean =>
product.tags.map((tag) => tag._ref).includes(id) ? true : false;
/** Matches tags according to passed value */
export const _matchesTag = (product: Product, { value, $tags }: TagArgs): boolean =>
product?.tags ? _productIncludesRef(product, _findProductId($tags, value)) : true;
/** Rating greater than what is passed in */
export const _ratingHigher = (product: Product, { value }: RatingArgs) =>
product.rating && avgRating(product.rating) >= Number(value);
export const _getSingleArgs = (name, rating, tag, products, tags) =>
name === 'rating'
? { type: 'rating', value: rating, products }
: { type: 'tag', value: tag, products, tags };
export const _getDoubleArgs = (name, otherVal, tags) =>
name === 'rating' ? { type: 'tag', value: otherVal, tags } : { type: 'rating', value: otherVal };
/** Public */
/** Default value for filter selectors, saved in store */
export const defaultFilter = { selectedCat: 0, selectedRating: 0 } as DefaultFilter;
/** Filters products */
export const filterProductsBy = (type: string, products: FullProduct[], config) =>
export const singleFilter = ({ type, value, products, tags = [] }: FilterArgs) =>
products.filter((product) => {
switch (type) {
case 'tag':
return _matchesTag(product, config);
if (typeof value === 'number') value = value.toString();
return _matchesTag(product, { value, $tags: tags });
case 'rating':
return _ratingHigher(product, config);
return _ratingHigher(product, { value });
}
});
/** Returns either filter by one or two */
export const multiFilter = ({ name, otherVal, rating, tag, $tags, $products }) => {
const filterArgs = _getSingleArgs(name, rating, tag, $products, $tags);
const filterOne = singleFilter(filterArgs);
if (!otherVal) return filterOne;
const otherFilterArgs = _getDoubleArgs(name, otherVal, $tags);
return singleFilter({ ...otherFilterArgs, products: filterOne });
};

View File

@ -1,5 +1,4 @@
export * from './toProduct';
export * from './utils/parse';
export * from './utils/avgRating';
export * from './param';
export * from './avgRating';
export * from './filters';
export * from './param';
export * from './parse';

View File

@ -1,6 +1,6 @@
import { normalize, parseSlug, parseName } from '.';
import type { UrlObject } from 'url';
import type { Product } from '$types';
import type { Writable } from 'svelte/store';
/** Gets parameter from URL of field. Defaults to 'product' */
export const getProdParam = (field = 'product'): string =>
@ -8,16 +8,47 @@ export const getProdParam = (field = 'product'): string =>
/** Finds a product in products store with matching name as parameter. */
export const findProdFromParam = (
param: URLSearchParams,
param: string,
$products: Product[]
): Product | Record<string, never> =>
$products.find(({ name }) => normalize(name) === parseSlug(param)) || {};
/** Resets params and navigates back to index */
export const resetParams = () => history.pushState({}, '', new URL(window.location.origin));
/** Reset value with empty parameters */
export const _resetState = () => [{}, '', new URL(window.location.origin)] as const;
/** Updates a url with search parameters with name of product parameter. */
export const setUrlParam = (url: URL, product: Product): UrlObject => {
url.searchParams.set('product', parseName(product.name));
return url;
/** Creates a new URL and then appends parsed product to it. */
export const _updateUrlParam = (url: string) => {
const updatedUrl = new URL(url);
return (product: Product) => {
updatedUrl.searchParams.set('product', parseName(product.name));
return updatedUrl;
};
};
/**
* Checks that window parameters are equal to passed product name
*/
export const _didWinUrlUpdate = (product: Product): boolean => {
const params = new URLSearchParams(window.location.search).get('product');
return parseSlug(params) === normalize(product.name);
};
export const _pushParams = (url: URL) => window.history.pushState(window.history.state, '', url);
/** Public */
/** Navigates to product */
export const goToProduct = ({ detail }) => {
const { product, currentProduct } = detail;
const urlWithParam = _updateUrlParam(window.location.href)(product);
_pushParams(urlWithParam);
return _didWinUrlUpdate(product) ? currentProduct.set(product) : resetHistory();
};
export const resetHistory = () => window.history.pushState(..._resetState());
export const setCurrProdFromParam = (
currentProduct: Writable<Product | Record<string, unknown>>,
products: Product[]
) => currentProduct.set(findProdFromParam(getProdParam(), products));

View File

@ -1,3 +1,5 @@
import type { Product } from '$types';
/** Removes casing and trims trailing/leading whitespace from string */
export const normalize = (str: string): string => str.toLowerCase().trim();
@ -6,3 +8,7 @@ export const parseSlug = (slug: string): string => normalize(slug).replaceAll('-
/** Parses a name to return normalized slug with all spaces replaced with dashes */
export const parseName = (name: string): string => normalize(name).replaceAll(' ', '-');
/** Product name includes value */
export const includesName = (name: string, product: Product) =>
normalize(product.name).includes(normalize(name));

View File

@ -1,26 +1,46 @@
import { filterProductsBy } from '$helpers';
import { singleFilter } from '$helpers';
import type { Product, Tag } from '$types';
import { describe, expect, test } from 'vitest';
import { testProducts, testTags } from './data';
describe('filterProducts', () => {
test('filters products by passed category tag', () => {
expect(
filterProductsBy('tag', testProducts, { value: 'Exercise', $tags: testTags })
singleFilter({
type: 'tag',
products: testProducts as Product[],
value: 'Exercise',
tags: testTags as Tag[]
})
).toHaveLength(1);
expect(
filterProductsBy('tag', testProducts, { value: 'Electronics', $tags: testTags })
singleFilter({
type: 'tag',
products: testProducts as Product[],
value: 'Electronics',
tags: testTags as Tag[]
})
).toHaveLength(1);
});
test('filter by tag returns unchanged products if tag does not exist', () => {
expect(filterProductsBy('tag', testProducts, { value: 'grapes', $tags: testTags })).toEqual(
testProducts
);
test('filter by tag returns nothing if tag does not exist', () => {
expect(
singleFilter({
type: 'tag',
products: testProducts as Product[],
value: 'grapes',
tags: testTags as Tag[]
})
).toEqual([]);
});
test('filters ratings only greater than # passed in', () => {
expect(filterProductsBy('rating', testProducts, { value: 3 })).toHaveLength(3);
expect(
singleFilter({ type: 'rating', products: testProducts as Product[], value: 3 })
).toHaveLength(3);
expect(filterProductsBy('rating', testProducts, { value: 5 })).toHaveLength(0);
expect(
singleFilter({ type: 'rating', products: testProducts as Product[], value: 5 })
).toHaveLength(0);
});
});

View File

@ -1,22 +0,0 @@
import { parseSlug, normalize, setUrlParam, resetParams } from '.';
import type { Product } from '$types';
import type { Writable} from "svelte/store"
/**
* Updates Window URL and checks that it is equal to product name
*/
const didWinUrlUpdate = (url: URL, product:Product): boolean => {
window.history.pushState(window.history.state, '', url);
const params = new URLSearchParams(window.location.search).get('product');
return parseSlug(params) === normalize(product.name);
};
/**
* Takes a `product` and `currentProduct` store
* Sets URL params to parsed product name and updates Browser URL
* Sets `currentProduct` store to `product`.
*/
export const toProduct = (product: Product, currentProduct:Writable<Product>) =>
didWinUrlUpdate(setUrlParam((new URL(window.location.href)), product), product)?
currentProduct.set(product): resetParams()

View File

@ -45,7 +45,7 @@
{/each}
</div>
{/if}
<PrevNext />
<PrevNext on:toProduct />
</div>
<style lang="postcss">

View File

@ -1,18 +1,21 @@
<script>
<script lang="ts">
import { currentProduct, productsView } from '$lib/stores';
import { toProduct } from '$helpers';
import { createEventDispatcher } from 'svelte';
$: foundIndex = $productsView.findIndex((prod) => prod._id === $currentProduct._id);
$: PREV = $productsView[foundIndex - 1];
$: NEXT = $productsView[foundIndex + 1];
const dispatch = createEventDispatcher();
const navigate = (e) => {
if (e.target.name === 'prev' && PREV) {
toProduct(PREV, currentProduct);
}
if (e.target.name === 'next' && NEXT) {
toProduct(NEXT, currentProduct);
}
let product;
if (e.target.name === 'prev' && PREV) product = PREV;
if (e.target.name === 'next' && NEXT) product = NEXT;
dispatch('toProduct', {
product,
currentProduct
});
};
const { container, btn } = {
@ -23,7 +26,7 @@
<div class={container}>
{#if PREV}
<button on:click={navigate} class={`${btn} pr-4`} name='prev'
<button on:click={navigate} class={`${btn} pr-4`} name="prev"
><svg focusable="false" width={50} height={50} viewBox="0 0 24 24">
<path d="m14 7-5 5 5 5V7z" />
</svg><span class="text-left">{PREV.name}</span></button
@ -33,7 +36,7 @@
{/if}
{#if NEXT}
<button on:click={navigate} class={`${btn} pl-4`} name='next'
<button on:click={navigate} class={`${btn} pl-4`} name="next"
><span class="text-right">{NEXT.name}</span><svg
focusable="false"
width={50}
@ -55,6 +58,6 @@
button:hover {
background-image: url('dither.gif');
background-repeat: repeat;
background-repeat: repeat;
}
</style>

View File

@ -1,15 +1,18 @@
<script>
export let tag;
<script lang="ts">
import type { Ref } from '$types';
import { tags, currentProduct, productsView, products, filters } from '$lib/stores';
import { filterProductsBy, resetParams } from '$helpers';
import { singleFilter, resetHistory } from '$helpers';
export let tag: Ref;
const tagName = $tags.find((dirTag) => dirTag._id === tag._ref).name;
const filter = () => {
currentProduct.set({});
productsView.set(filterProductsBy('tag', $products, { value: tagName, $tags }));
productsView.set(
singleFilter({ type: 'tag', value: tagName, products: $products, tags: $tags })
);
filters.set({ ...$filters, selectedCat: tagName });
resetParams();
resetHistory();
};
</script>

View File

@ -1,17 +1,22 @@
<script>
import { urlFor } from './sanityClient';
import { currentProduct } from '$lib/stores';
import { toProduct } from '$helpers';
export let products;
import { createEventDispatcher } from 'svelte';
const { container } = {
container: 'flex flex-wrap mt-1 mb-1 justify-start'
};
const dispatch = createEventDispatcher();
</script>
<div class={container}>
<div class="container">
{#each products as product}
<button on:click={() => toProduct(product, currentProduct)}>
<button
on:click={() => {
dispatch('toProduct', {
product,
currentProduct
});
}}
>
{#if product.image}
<img src={urlFor(product.image).width(150).url()} alt={product.name} />
{/if}
@ -19,7 +24,10 @@
{/each}
</div>
<style>
<style lang="postcss">
.container {
@apply flex flex-wrap mt-1 mb-1 justify-start;
}
img:hover {
background-image: url('dither.gif');
background-repeat: repeat;

View File

@ -1,48 +1,36 @@
<script>
import { productsView, products, tags, filters } from '$lib/stores';
import { normalize, filterProductsBy } from '$helpers';
import { normalize, multiFilter } from '$helpers';
export let reset;
let { selectedCat, selectedRating } = $filters;
const stars = ['⭐ ⬆', '⭐⭐ ⬆', '⭐⭐⭐ ⬆', '⭐⭐⭐⭐ ⬆', '⭐⭐⭐⭐⭐ ⬆'];
const filterProducts = (name, otherVal, { rating = '', tag = '', $tags = [] }) => {
const { otherValName, filterArgs, otherFilterArgs } =
name === 'rating'
? {
otherValName: 'tag',
filterArgs: { value: rating },
otherFilterArgs: { value: otherVal, $tags }
}
: {
otherValName: 'rating',
filterArgs: { value: tag, $tags },
otherFilterArgs: { value: otherVal }
};
const filterOne = filterProductsBy(name, $products, filterArgs);
const filterBoth = filterProductsBy(otherValName, filterOne, otherFilterArgs);
return otherVal ? filterBoth : filterOne;
};
const filter = ({ target: { name, value } }) => {
if (Number(value) === 0) {
reset();
return;
}
switch (normalize(name)) {
case 'rating':
productsView.set(
filterProducts(name, selectedCat, { rating: value, tag: selectedCat, $tags })
);
break;
case 'tag':
productsView.set(
filterProducts(name, selectedRating, { rating: selectedRating, tag: value, $tags })
);
break;
}
const ratingArgs = {
name,
otherVal: selectedCat,
rating: value,
tag: selectedCat,
$tags,
$products
};
const tagArgs = {
name,
otherVal: selectedRating,
rating: selectedRating,
tag: value,
$tags,
$products
};
return normalize(name) === 'rating'
? productsView.set(multiFilter(ratingArgs))
: productsView.set(multiFilter(tagArgs));
};
</script>

View File

@ -1,13 +1,13 @@
<script>
export let reset;
const { title } = {
title: 'font-bold p-4 pr-0 snap-start sticky top-0 bg-white h-12 shadow-sm shadow-white'
};
</script>
<a href="/" class={title} on:click={reset}> Rating Room </a>
<a href="/" class="title" on:click={reset}> Rating Room </a>
<style>
<style lang="postcss">
.title {
@apply font-bold p-4 pr-0 snap-start sticky top-0 bg-white h-12 shadow-sm shadow-white;
}
a:hover {
background-image: url('dither.gif');
background-repeat: repeat;

View File

@ -1,18 +1,22 @@
<script>
export let productsView;
export let currentProduct;
import { toProduct } from '$helpers';
import { createEventDispatcher } from 'svelte';
const { container, productStyle } = {
container: 'pt-4',
productList: 'flex flex-col items-start mt-10 text-sm',
productStyle: 'w-full text-left snap-start snap-always'
};
const dispatch = createEventDispatcher();
</script>
<div class={container}>
<div class="container">
{#each productsView as product}
<button class={productStyle} on:click={() => toProduct(product, currentProduct)}>
<button
class="productStyle"
on:click={() => {
dispatch('toProduct', {
product,
currentProduct
});
}}
>
<p
class={`pl-4 hover:bg-gray-200 ${
$currentProduct && $currentProduct.name === product.name ? 'dither' : ''
@ -23,3 +27,13 @@
</button>
{/each}
</div>
<style lang="postcss">
.container {
@apply pt-4;
}
.productStyle {
@apply w-full text-left snap-start snap-always;
}
</style>

View File

@ -1,19 +1,22 @@
<script>
import { products, productsView } from '$lib/stores';
import { normalize } from '$helpers';
import { includesName } from '$helpers';
const searchProducts = (e) => {
productsView.set(
$products.filter((product) => normalize(product.name).includes(normalize(e.target.value)))
);
};
const { container, input } = {
container: 'h-12 ml-auto mr-auto flex justify-center items-center',
input: 'outline outline-1 w-28 focus:outline-blue-600 p-1 text-sm'
productsView.set($products.filter((product) => includesName(e.target.value, product)));
};
</script>
<div class={container}>
<input on:input={searchProducts} class={input} placeholder="search" />
<div class="container">
<input on:input={searchProducts} class="input" placeholder="search" />
</div>
<style lang="postcss">
.container {
@apply h-12 ml-auto mr-auto flex justify-center items-center;
}
.input {
@apply outline outline-1 w-28 focus:outline-blue-600 p-1 text-sm;
}
</style>

View File

@ -33,19 +33,22 @@
break;
}
};
const { container, sortBar, sortTitle } = {
container: 'flex flex-col text-sm h-auto mb-4 mr-6',
sortBar: 'pl-4 p-2 flex justify-between',
sortTitle: 'font-bold'
};
</script>
<div class={container}>
<div class={sortBar}>
<div class={sortTitle}>Sort</div>
<div class="container">
<div class="sortBar">
<div class="font-bold">Sort</div>
</div>
{#each sortOptions as option}
<SortOption {option} {sort} />
{/each}
</div>
<style lang="postcss">
.container {
@apply flex flex-col text-sm h-auto mb-4 mr-6;
}
.sortBar {
@apply pl-4 p-2 flex justify-between;
}
</style>

View File

@ -2,26 +2,28 @@
export let option;
export let sort;
let current = true;
const { sortOption, noPointer } = {
sortOption: 'flex p-1 pl-5 mr-12 w-full flex justify-between',
noPointer: 'pointer-events-none'
};
</script>
<button
class={sortOption}
class="sortOption"
on:click={(e) => {
sort(e.target.name, current);
current = !current;
}}
name={option}
>
<p class={noPointer}>{option}</p>
<p class={noPointer}>{current ? '▲' : '▼'}</p>
<p class="noPointer">{option}</p>
<p class="noPointer">{current ? '▲' : '▼'}</p>
</button>
<style>
<style lang="postcss">
.sortOption {
@apply p-1 pl-5 mr-12 w-full flex justify-between;
}
.noPointer {
@apply pointer-events-none;
}
button:hover {
background-image: url('dither.gif');
background-repeat: repeat;

View File

@ -1,7 +1,5 @@
<script>
const back = () => window.history.back()
</script>
<div class='flex flex-col w-screen text-2xl p-24 items-start'><p>This item could not be found!</p>
<button on:click={back} class='outline outline-1 p-1 mt-2'>Go back?</button>
</div>
<div class="flex flex-col w-screen text-2xl p-24 items-start">
<p>This item could not be found!</p>
<button on:click={() => window.history.back()} class="outline outline-1 p-1 mt-2">Go back?</button
>
</div>

View File

@ -1,4 +1,5 @@
import type { Tag, Product, Emotion } from '$types';
import { defaultFilter } from '$helpers';
import { writable, type Writable, readable, type Readable } from 'svelte/store';
import { client } from '$lib/sanityClient';
@ -20,5 +21,5 @@ export const emotions: Readable<Emotion[] | []> = readable(await fetchEmotionDat
export const tags: Readable<Tag[] | []> = readable(await fetchTagData());
export const productsView: Writable<Product[]> = writable([]);
export const currentProduct: Writable<Product | Record<string, never>> = writable({});
export const filters = writable({ selectedCat: 0, selectedRating: 0 });
export const currentProduct: Writable<Product | Record<string, unknown>> = writable({});
export const filters = writable(defaultFilter);

View File

@ -1,7 +1,7 @@
<script>
<script lang="ts">
import '../app.css';
import { products, productsView, currentProduct, filters } from '$lib/stores';
import { defaultFilter, resetParams } from '$helpers';
import { defaultFilter, resetHistory, goToProduct } from '$helpers';
import Products from '$lib/Layout/Products.svelte';
import Header from '$lib/Layout/Header.svelte';
import Search from '$lib/Layout/Search.svelte';
@ -10,35 +10,38 @@
const reset = () => {
productsView.set($products);
resetParams();
resetHistory();
currentProduct.set({});
filters.set(structuredClone(defaultFilter));
};
const { main, container, sidebar } = {
main: 'flex w-screen h-screen sfmono',
container: 'flex flex-col h-screen justify-start shrink-0 overflow-auto w-40',
sidebar: ' flex flex-col shrink-0'
};
</script>
<main class={main}>
<div class={container}>
<main class="main sfmono">
<div class="container">
<Header {reset} />
<div class={sidebar}>
<div class="sidebar">
<Search />
{#if !Object.keys($currentProduct).length}
<Filters {reset} />
<Sort />
{:else}
<Products productsView={$productsView} {currentProduct} />
<Products productsView={$productsView} {currentProduct} on:toProduct={goToProduct} />
{/if}
</div>
</div>
<slot />
</main>
<style>
<style lang="postcss">
.main {
@apply flex w-screen h-screen;
}
.container {
@apply flex flex-col h-screen justify-start shrink-0 overflow-auto w-40;
}
.sidebar {
@apply flex flex-col shrink-0;
}
::-webkit-scrollbar {
display: none;
}

View File

@ -2,14 +2,14 @@
import { browser } from '$app/env';
import Grid from '$lib/Grid.svelte';
import Feature from '$lib/Feature/Feature.svelte';
import { products, productsView, tags, currentProduct, emotions } from '$lib/stores';
import { findProdFromParam, getProdParam, resetParams } from '$helpers';
import { products, productsView, currentProduct } from '$lib/stores';
import { getProdParam, goToProduct, resetHistory, setCurrProdFromParam } from '$helpers';
productsView.set($products);
$: productsView.set($products);
const load = () => {
currentProduct.set(findProdFromParam(getProdParam(), $products));
if (getProdParam() && JSON.stringify($currentProduct) === '{}') resetParams();
setCurrProdFromParam(currentProduct, $products);
if (getProdParam() && JSON.stringify($currentProduct) === '{}') resetHistory();
};
if (browser) {
@ -26,9 +26,9 @@
<div class="container">
{#if Object.keys($currentProduct).length}
<Feature />
<Feature on:toProduct={goToProduct} />
{:else}
<Grid products={$productsView} />
<Grid products={$productsView} on:toProduct={goToProduct} />
{/if}
</div>

View File

@ -1,34 +0,0 @@
// import { client } from '$lib/sanityClient';
// import type { Product, Tag, Emotion } from '$types';
export {};
// type dataType = {
// products: Product[],
// tags: Tag[],
// emotions: Emotion[]
// }
// export async function GET() {
// const products = await client.fetch('*[_type == "product"]');
// const tags = await client.fetch('*[_type == "tag"]');
// const emotions = await client.fetch('*[_type == "emotion"]');
// const data:dataType = {
// products,
// tags,
// emotions
// };
// if (data) {
// return {
// status: 200,
// body: {
// data: data
// }
// };
// }
// return {
// status: 404
// };
// }

View File

@ -38,21 +38,6 @@ export interface Ref {
_type: 'reference' | 'tag';
}
export interface Product {
_createdAt: string;
_id: string;
_rev: string;
_type: 'product';
_updatedAt: string;
description: string;
image?: Image;
name: string;
rating?: Ref[];
tags?: Ref[];
url?: string;
subname?: string;
}
export interface FullProduct {
_createdAt: string;
_id: string;
_rev: string;