parent
0279675828
commit
5d43a1186d
|
@ -0,0 +1,31 @@
|
|||
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
|
||||
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
|
||||
|
||||
name: Testing CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "dev", "main" ]
|
||||
pull_request:
|
||||
branches: [ "dev", "main" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [16.x]
|
||||
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
- run: npm install
|
||||
- run: npm run test:unit
|
||||
|
|
@ -1,23 +1,42 @@
|
|||
import { getAvgRating } from '$helpers';
|
||||
import type { Product, Tag } from '$types';
|
||||
import { avgRating } from '$helpers';
|
||||
import type { FullProduct, Tag } from '$types';
|
||||
|
||||
export type TagArgs = {
|
||||
value: string;
|
||||
$tags: Tag[];
|
||||
};
|
||||
|
||||
export type RatingArgs = {
|
||||
value: string | number;
|
||||
};
|
||||
|
||||
/** Default value for filter selectors, saved in store */
|
||||
export const defaultFilter = { selectedCat: 0, selectedRating: 0 };
|
||||
|
||||
/** Filters products by matching tags according to passed value */
|
||||
export const filterByCat = (products: Product[], value: string, tags: Tag[]): Product[] =>
|
||||
/** 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;
|
||||
}
|
||||
};
|
||||
|
||||
/** Filters products */
|
||||
export const filterProductsBy = (type: string, products: FullProduct[], config) =>
|
||||
products.filter((product) => {
|
||||
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;
|
||||
switch (type) {
|
||||
case 'tag':
|
||||
return _matchesTag(product, config);
|
||||
case 'rating':
|
||||
return _ratingHigher(product, config);
|
||||
}
|
||||
});
|
||||
|
||||
/** Filters by rating greater than what is passed in */
|
||||
export const filterByRating = (products: Product[], value: string | number) =>
|
||||
products.filter((product) => product.rating && getAvgRating(product.rating) >= Number(value));
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
export * from './toProduct';
|
||||
export * from './parse';
|
||||
export * from './getAvgRating';
|
||||
export * from './utils/parse';
|
||||
export * from './utils/avgRating';
|
||||
export * from './param';
|
||||
export * from './filters'
|
||||
export * from './filters';
|
||||
|
|
|
@ -1,22 +1,26 @@
|
|||
import { filterByCat, filterByRating } from '$helpers';
|
||||
import { filterProductsBy } from '$helpers';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { testProducts, testTags } from './data';
|
||||
|
||||
describe('filterByCat', () => {
|
||||
describe('filterProducts', () => {
|
||||
test('filters products by passed category tag', () => {
|
||||
expect(filterByCat(testProducts, 'Exercise', testTags)).toHaveLength(1);
|
||||
expect(filterByCat(testProducts, 'Electronics', testTags)).toHaveLength(1);
|
||||
expect(
|
||||
filterProductsBy('tag', testProducts, { value: 'Exercise', $tags: testTags })
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
filterProductsBy('tag', testProducts, { value: 'Electronics', $tags: testTags })
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('returns unchanged products if tag does not exist', () => {
|
||||
expect(filterByCat(testProducts, 'grapes', testTags)).toEqual(testProducts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterByRating', () => {
|
||||
test('filterByRating returns products only greater than # passed in', () => {
|
||||
expect(filterByRating(testProducts, 3)).toHaveLength(3);
|
||||
|
||||
expect(filterByRating(testProducts, 5)).toHaveLength(0);
|
||||
test('filter by tag returns unchanged products if tag does not exist', () => {
|
||||
expect(filterProductsBy('tag', testProducts, { value: 'grapes', $tags: testTags })).toEqual(
|
||||
testProducts
|
||||
);
|
||||
});
|
||||
|
||||
test('filters ratings only greater than # passed in', () => {
|
||||
expect(filterProductsBy('rating', testProducts, { value: 3 })).toHaveLength(3);
|
||||
|
||||
expect(filterProductsBy('rating', testProducts, { value: 5 })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,21 +1,19 @@
|
|||
import { getAvgRating } from '$helpers';
|
||||
import { avgRating } from '$helpers';
|
||||
import { test, expect } from 'vitest';
|
||||
import { testRating1, testRating2 } from './data';
|
||||
|
||||
test('return false if no parameters are passed', async () => {
|
||||
expect(getAvgRating()).toBeFalsy();
|
||||
expect(avgRating()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('return false if array with empty object is passed', async () => {
|
||||
expect(getAvgRating([{}])).toBeFalsy();
|
||||
expect(avgRating([{}])).toBeFalsy();
|
||||
});
|
||||
|
||||
test('return single rating if only one rating is passed', async () => {
|
||||
expect(getAvgRating([testRating1])).toBe(testRating1.rating);
|
||||
expect(avgRating([testRating1])).toBe(testRating1.rating);
|
||||
});
|
||||
|
||||
test('return average of 2 ratings passed', async () => {
|
||||
expect(getAvgRating([testRating1, testRating2])).toBe(
|
||||
(testRating1.rating + testRating2.rating) / 2
|
||||
);
|
||||
expect(avgRating([testRating1, testRating2])).toBe((testRating1.rating + testRating2.rating) / 2);
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import type { Rating } from '$types';
|
||||
|
||||
/** Average of all ratings of a product */
|
||||
export const getAvgRating = (ratings: Rating[]): number => {
|
||||
export const avgRating = (ratings: Rating[]): number => {
|
||||
if (!ratings) return 0;
|
||||
if (ratings.length === 1) return ratings[0].rating;
|
||||
else return ratings.reduce((total, curr) => total + curr.rating, 0) / ratings.length;
|
|
@ -4,55 +4,42 @@
|
|||
import Rating from './Rating/Rating.svelte';
|
||||
import PrevNext from './PrevNext/PrevNext.svelte';
|
||||
import Tag from './Tag/Tag.svelte';
|
||||
|
||||
const {
|
||||
container,
|
||||
imageView,
|
||||
subname,
|
||||
description,
|
||||
productInfo,
|
||||
date,
|
||||
tags,
|
||||
img,
|
||||
ratings
|
||||
} = {
|
||||
container: 'flex flex-col',
|
||||
imageView: 'flex m-16 gap-12 w-100',
|
||||
productInfo: 'flex flex-col gap-2',
|
||||
subname: '-mb-2 font-light text-sm',
|
||||
description: 'leading-5',
|
||||
date: 'text-xs w-2/3 mt-auto mb-4',
|
||||
tags: 'flex gap-1 mb-1',
|
||||
img: 'p-8 border w-72 h-72 min-w-36 min-h-36 border-black border-2 p-3',
|
||||
ratings: 'w-100'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class={container}>
|
||||
<div class={imageView}>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex m-16 gap-12 w-full">
|
||||
{#if $currentProduct.image}
|
||||
<img src={urlFor($currentProduct.image).url()} class={img} alt={$currentProduct.name} />
|
||||
<img
|
||||
src={urlFor($currentProduct.image).url()}
|
||||
class="min-w-36 min-h-36 w-72 h-72 border-black border-2 p-3"
|
||||
alt={$currentProduct.name}
|
||||
/>
|
||||
{/if}
|
||||
<div class={productInfo}>
|
||||
<div class="productInfo">
|
||||
{#if $currentProduct.subname}
|
||||
<span class={subname}>{$currentProduct.subname}</span>
|
||||
<span class="subname">{$currentProduct.subname}</span>
|
||||
{/if}
|
||||
<a href={$currentProduct?.url} target="_blank" class={`font-bold text-2xl title ${$currentProduct.url? 'text-sky-600':''}`}>{$currentProduct.name}</a>
|
||||
<a
|
||||
href={$currentProduct?.url}
|
||||
target="_blank"
|
||||
class={`font-bold text-2xl title ${$currentProduct.url ? 'text-sky-600' : ''}`}
|
||||
>{$currentProduct.name}</a
|
||||
>
|
||||
{#if $currentProduct.tags}
|
||||
<div class={tags}>
|
||||
<div class="tags">
|
||||
{#each $currentProduct.tags as tag (tag._ref)}
|
||||
<Tag {tag} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if $currentProduct.description}
|
||||
<p class={description}>{$currentProduct.description}</p>
|
||||
<p class="description">{$currentProduct.description}</p>
|
||||
{/if}
|
||||
<p class={date}>Created on: {new Date($currentProduct._createdAt)}</p>
|
||||
<p class="date">Created on: {new Date($currentProduct._createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if $currentProduct.rating}
|
||||
<div class={ratings}>
|
||||
<div class="ratings">
|
||||
{#each $currentProduct.rating as rating (rating._key)}
|
||||
<Rating {rating} />
|
||||
{/each}
|
||||
|
@ -61,12 +48,31 @@
|
|||
<PrevNext />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
<style lang="postcss">
|
||||
.productInfo {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
.subname {
|
||||
@apply -mb-2 font-light text-sm;
|
||||
}
|
||||
.description {
|
||||
@apply leading-5;
|
||||
}
|
||||
.date {
|
||||
@apply text-xs w-2/3 mt-auto mb-4;
|
||||
}
|
||||
.tags {
|
||||
@apply flex gap-1 mb-1;
|
||||
}
|
||||
.ratings {
|
||||
@apply w-full;
|
||||
}
|
||||
img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
.title:hover, img:hover{
|
||||
.title:hover,
|
||||
img:hover {
|
||||
background-image: url('dither.gif');
|
||||
background-repeat: repeat;
|
||||
background-repeat: repeat;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,28 +1,27 @@
|
|||
<script>
|
||||
export let tag;
|
||||
import { tags, currentProduct, productsView, products, filters } from '$lib/stores';
|
||||
import { filterByCat, resetParams } from '$helpers';
|
||||
import { filterProductsBy, resetParams } from '$helpers';
|
||||
|
||||
const tagName = $tags.find((dirTag) => dirTag._id === tag._ref).name;
|
||||
|
||||
const filter = () => {
|
||||
const updatedByCat = filterByCat($products, tagName, $tags);
|
||||
currentProduct.set({});
|
||||
productsView.set(updatedByCat);
|
||||
productsView.set(filterProductsBy('tag', $products, { value: tagName, $tags }));
|
||||
filters.set({ ...$filters, selectedCat: tagName });
|
||||
resetParams();
|
||||
};
|
||||
|
||||
const { container } = {
|
||||
container: 'text-sm flex rounded-md pl-2 pr-2 pt-1 pb-1 bg-gray-200'
|
||||
};
|
||||
</script>
|
||||
|
||||
<button class={container} on:click={filter}>{tagName}</button>
|
||||
<button class="container" on:click={filter}>{tagName}</button>
|
||||
|
||||
<style>
|
||||
<style lang="postcss">
|
||||
button:hover {
|
||||
background-image: url('dither.gif');
|
||||
background-repeat: repeat;
|
||||
}
|
||||
|
||||
.container {
|
||||
@apply text-sm flex rounded-md pl-2 pr-2 pt-1 pb-1 bg-gray-200 w-auto;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,48 +1,61 @@
|
|||
<script>
|
||||
import { productsView, products, tags, filters } from '$lib/stores';
|
||||
import { normalize, filterByRating, filterByCat } from '$helpers';
|
||||
import { normalize, filterProductsBy } 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':
|
||||
if (selectedCat)
|
||||
productsView.set(filterByCat(filterByRating($products, value), selectedCat, $tags));
|
||||
else productsView.set(filterByRating($products, value));
|
||||
productsView.set(
|
||||
filterProducts(name, selectedCat, { rating: value, tag: selectedCat, $tags })
|
||||
);
|
||||
break;
|
||||
case 'category':
|
||||
if (selectedRating)
|
||||
productsView.set(filterByRating(filterByCat($products, value, $tags), selectedRating));
|
||||
else productsView.set(filterByCat($products, value, $tags));
|
||||
case 'tag':
|
||||
productsView.set(
|
||||
filterProducts(name, selectedRating, { rating: selectedRating, tag: value, $tags })
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const { container, filterBar, filterTitle } = {
|
||||
container: 'flex flex-col text-sm h-auto mb-4 mr-6 mt-4',
|
||||
filterBar: 'pl-4 p-2 pr-2 flex justify-between',
|
||||
filterTitle: 'font-bold'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class={container}>
|
||||
<div class={filterBar}>
|
||||
<div class={filterTitle}>Filter</div>
|
||||
<div class="container">
|
||||
<div class="filterBar">
|
||||
<div class="filterTitle">Filter</div>
|
||||
<button on:click={reset}> ✖️ </button>
|
||||
</div>
|
||||
|
||||
<select
|
||||
on:change={filter}
|
||||
class={`p-1 pl-5 mr-12 w-full focus:outline-none ${$filters.selectedCat ? 'dither' : ''}`}
|
||||
name="category"
|
||||
name="tag"
|
||||
bind:value={$filters.selectedCat}
|
||||
>
|
||||
<option select="selected" value={0}>Category</option>
|
||||
|
@ -64,7 +77,18 @@
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
<style lang="postcss">
|
||||
.container {
|
||||
@apply flex flex-col text-sm h-auto mb-4 mr-6 mt-4;
|
||||
}
|
||||
|
||||
.filterBar {
|
||||
@apply pl-4 p-2 pr-2 flex justify-between;
|
||||
}
|
||||
|
||||
.filterTitle {
|
||||
@apply font-bold;
|
||||
}
|
||||
select:hover {
|
||||
background-image: url('dither.gif');
|
||||
background-repeat: repeat;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import SortOption from './SortOption.svelte';
|
||||
import { productsView } from '$lib/stores';
|
||||
import { normalize, getAvgRating } from '$helpers';
|
||||
import { normalize, avgRating } from '$helpers';
|
||||
|
||||
const sortOptions = ['Rating', 'Name', 'Created'];
|
||||
|
||||
|
@ -10,8 +10,8 @@
|
|||
case 'rating':
|
||||
productsView.set(
|
||||
$productsView.sort((prev, curr) => {
|
||||
if (current) return getAvgRating(prev.rating) < getAvgRating(curr.rating) ? -1 : 1;
|
||||
else return getAvgRating(prev.rating) < getAvgRating(curr.rating) ? 1 : -1;
|
||||
if (current) return avgRating(prev.rating) < avgRating(curr.rating) ? -1 : 1;
|
||||
else return avgRating(prev.rating) < avgRating(curr.rating) ? 1 : -1;
|
||||
})
|
||||
);
|
||||
break;
|
||||
|
|
|
@ -18,20 +18,22 @@
|
|||
load();
|
||||
};
|
||||
}
|
||||
|
||||
const { container } = {
|
||||
container: 'h-screen overflow-auto w-screen'
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Rating Room</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class={container}>
|
||||
<div class="container">
|
||||
{#if Object.keys($currentProduct).length}
|
||||
<Feature />
|
||||
{:else}
|
||||
<Grid products={$productsView} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="postcss">
|
||||
.container {
|
||||
@apply h-screen overflow-auto w-screen;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -46,8 +46,23 @@ export interface Product {
|
|||
description: string;
|
||||
image?: Image;
|
||||
name: string;
|
||||
rating?: Ref[] | Rating[];
|
||||
tags?: Ref[] | Tag[];
|
||||
rating?: Ref[];
|
||||
tags?: Ref[];
|
||||
url?: string;
|
||||
subname?: string;
|
||||
}
|
||||
|
||||
export interface FullProduct {
|
||||
_createdAt: string;
|
||||
_id: string;
|
||||
_rev: string;
|
||||
_type: 'product';
|
||||
_updatedAt: string;
|
||||
description: string;
|
||||
image?: Image;
|
||||
name: string;
|
||||
rating?: Rating[];
|
||||
tags?: Ref[];
|
||||
url?: string;
|
||||
subname?: string;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue