This commit is contained in:
Xinrui Chen 2022-07-22 15:17:33 -07:00
commit a015bfcda4
19 changed files with 5435 additions and 2897 deletions

2
.gitignore vendored
View File

@ -1,3 +1,4 @@
coverage
.DS_Store .DS_Store
node_modules node_modules
/build /build
@ -7,3 +8,4 @@ node_modules
.env .env
.env.* .env.*
!.env.example !.env.example
/coverage

View File

@ -3,5 +3,23 @@
## About ## About
This project is a collaborative effort to catalogue and rate everything in existence. This project is a collaborative effort to catalogue and rate everything in existence.
Written in `Svelte` with `TailwindCSS` and `Sanity`. Written in `Svelte` with `TailwindCSS`, `Typescript`, and `Sanity`. Testing is implemented in `Jest` and `Playwright`.
## Usage
```js
npm run dev
```
Runs client in `vite`.
```bash
yarn test:unit
yarn test
```
Testing: Unit tests run in `Jest`, integration tests run in `Playwright`.
## Functionality
Click through products to view details, with descriptions and ratings. Live filtering, sorting, and searching is implemented in the side bar.
![home](static/home.gif)

View File

@ -11,30 +11,44 @@
"check": "svelte-check --tsconfig ./tsconfig.json", "check": "svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --plugin-search-dir=. . && eslint .", "lint": "prettier --check --plugin-search-dir=. . && eslint .",
"format": "prettier --write --plugin-search-dir=. ." "format": "prettier --write --plugin-search-dir=. .",
"test:unit": "vitest",
"coverage": "vitest run --coverage"
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.18.6", "@babel/preset-env": "^7.18.9",
"@playwright/test": "^1.22.2", "@playwright/test": "^1.22.2",
"@sveltejs/adapter-auto": "next", "@sveltejs/adapter-auto": "next",
"@sveltejs/kit": "next", "@sveltejs/kit": "next",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/svelte": "^3.1.3",
"@testing-library/user-event": "^14.3.0",
"@types/testing-library__jest-dom": "^5.14.5",
"@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0", "@typescript-eslint/parser": "^5.27.0",
"autoprefixer": "^10.4.7", "autoprefixer": "^10.4.7",
"babel-jest": "^28.1.3",
"c8": "^7.12.0",
"cross-fetch": "^3.1.5",
"cz-conventional-changelog": "3.3.0", "cz-conventional-changelog": "3.3.0",
"eslint": "^8.19.0", "eslint": "^8.19.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-svelte3": "^4.0.0", "eslint-plugin-svelte3": "^4.0.0",
"jest-environment-jsdom": "^28.1.3",
"jsdom": "^20.0.0",
"msw": "^0.44.2",
"postcss": "^8.4.14", "postcss": "^8.4.14",
"prettier": "^2.6.2", "prettier": "^2.6.2",
"prettier-plugin-svelte": "^2.7.0", "prettier-plugin-svelte": "^2.7.0",
"svelte": "^3.44.0", "svelte": "^3.44.0",
"svelte-check": "^2.7.1", "svelte-check": "^2.7.1",
"svelte-jester": "^2.3.2",
"svelte-preprocess": "^4.10.7", "svelte-preprocess": "^4.10.7",
"tailwindcss": "^3.1.5", "tailwindcss": "^3.1.5",
"tslib": "^2.3.1", "tslib": "^2.3.1",
"typescript": "^4.7.2", "typescript": "^4.7.2",
"vite": "^3" "vite": "^3",
"vitest": "^0.18.1"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {

View File

@ -4,7 +4,8 @@ const config: PlaywrightTestConfig = {
webServer: { webServer: {
command: 'npm run build && npm run preview', command: 'npm run build && npm run preview',
port: 3000 port: 3000
} },
testMatch: 'tests/**/*.ts'
}; };
export default config; export default config;

4
setupTest.js Normal file
View File

@ -0,0 +1,4 @@
import matchers from '@testing-library/jest-dom/matchers';
import { expect } from 'vitest';
expect.extend(matchers);

16
src/helpers/filters.js Normal file
View File

@ -0,0 +1,16 @@
import { getAvgRating } from './'
export const defaultFilter = { selectedCat: 0, selectedRating: 0 }
export const filterByCat = ($products, value, $tags) =>
$products.filter((product) => {
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;
});
export const filterByRating = (products, value) => products.filter((product) => product.rating && getAvgRating(product.rating) >= Number(value));

View File

@ -3,7 +3,7 @@
* @returns {number} Average rating * @returns {number} Average rating
*/ */
export const getAvgRating = (ratings) => { export const getAvgRating = (ratings) => {
if (!ratings) return 0; if (!ratings) return 0;
if (ratings.length === 1) return ratings[0].rating; if (ratings.length === 1) return ratings[0].rating;
else return ratings.reduce((prev, curr) => prev.rating + curr.rating) / ratings.length; else return ratings.reduce((prev, curr) => prev.rating + curr.rating) / ratings.length;
}; };

View File

@ -0,0 +1,35 @@
import { getAvgRating } from '$helpers/getAvgRating.js';
let testRating1 = {
_key: '5a80f4748d91',
comments: 'The new one seems pretty good but my old one died and got sticky.',
emotion: { _ref: '633f8d5f-897a-461f-a817-ee910f6ad614', _type: 'reference' },
name: 'xyn',
rating: 4
};
let testRating2 = {
_key: '5a80f4748d91',
comments: 'This is a fake comment for testing, wahoo',
emotion: { _ref: '633f8d5f-897a-461f-a817-ee910f6ad614', _type: 'reference' },
name: 'zane',
rating: 3
};
test('return false if no parameters are passed', async () => {
expect(getAvgRating()).toBeFalsy();
});
test('return false if array with empty object is passed', async () => {
expect(getAvgRating([{}])).toBeFalsy();
});
test('return single rating if only one rating is passed', async () => {
expect(getAvgRating([testRating1])).toBe(testRating1.rating);
});
test('return average of 2 ratings passed', async () => {
expect(getAvgRating([testRating1, testRating2])).toBe(
(testRating1.rating + testRating2.rating) / 2
);
});

View File

@ -2,3 +2,4 @@ export * from './toProduct';
export * from './parse'; export * from './parse';
export * from './getAvgRating'; export * from './getAvgRating';
export * from './param'; export * from './param';
export * from './filters'

View File

@ -1,13 +1,22 @@
<script> <script>
export let tag; export let tag;
import { tags } from '$lib/stores'; import { tags, currentProduct, productsView, products, filters } from '$lib/stores';
import { filterByCat, resetParams} from '$helpers';
const tagName = ($tags.find((dirTag) => dirTag._id === tag._ref)).name const tagName = ($tags.find((dirTag) => dirTag._id === tag._ref)).name
const filter = () => {
const updatedByCat = filterByCat($products, tagName, $tags)
currentProduct.set({});
productsView.set(updatedByCat)
filters.set({...$filters, selectedCat: tagName})
resetParams()
}
const { container } = { const { container } = {
container: 'text-sm flex rounded-md pl-2 pr-2 pt-1 pb-1 bg-gray-200' container: 'text-sm flex rounded-md pl-2 pr-2 pt-1 pb-1 bg-gray-200 hover:bg-blue-300'
} }
</script> </script>
<p class={container}>{tagName}</p> <button class={container} on:click={filter}>{tagName}</button>

View File

@ -1,48 +1,35 @@
<script> <script>
import { productsView, products, tags } from '$lib/stores'; import { productsView, products, tags, filters } from '$lib/stores';
import {normalize, getAvgRating } from '$helpers'; import { normalize, filterByRating, filterByCat } from '$helpers';
export let filters;
export let reset; export let reset;
let { selectedCat, selectedRating } = filters; let { selectedCat, selectedRating } = $filters;
const stars = ['⭐ ⬆', '⭐⭐ ⬆', '⭐⭐⭐ ⬆', '⭐⭐⭐⭐ ⬆', '⭐⭐⭐⭐⭐ ⬆']; const stars = ['⭐ ⬆', '⭐⭐ ⬆', '⭐⭐⭐ ⬆', '⭐⭐⭐⭐ ⬆', '⭐⭐⭐⭐⭐ ⬆'];
const filterByCat = (products, value) =>
products.filter((product) => {
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;
});
const filterByRating = (products, value) =>
products.filter((product) => product.rating && getAvgRating(product.rating) >= Number(value));
const filter = ({ target: { name, value } }) => { const filter = ({ target: { name, value } }) => {
if (Number(value) === 0) { if (Number(value) === 0) {
reset(); reset();
return; return;
} }
switch (normalize(name)) { switch (normalize(name)) {
case 'rating': case 'rating':
if (selectedCat) if (selectedCat)
productsView.set(filterByCat(filterByRating($products, value), selectedCat)); productsView.set(filterByCat(filterByRating($products, value), selectedCat, $tags));
else productsView.set(filterByRating($products, value)); else productsView.set(filterByRating($products, value));
break; break;
case 'category': case 'category':
if (selectedRating) if (selectedRating)
productsView.set(filterByRating(filterByCat($products, value), selectedRating)); productsView.set(filterByRating(filterByCat($products, value, $tags), selectedRating));
else productsView.set(filterByCat($products, value)); else productsView.set(filterByCat($products, value, $tags));
break; break;
} }
}; };
const { container, filterBar, filterTitle, filterSort } = { const { container, filterBar, filterTitle } = {
container: 'flex flex-col text-sm h-auto mb-4 mr-6 mt-4', container: 'flex flex-col text-sm h-auto mb-4 mr-6 mt-4',
filterBar: 'pl-8 p-2 pr-2 flex justify-between', filterBar: 'pl-8 p-2 pr-2 flex justify-between',
filterTitle: 'font-bold', filterTitle: 'font-bold',
filterSort: 'p-1 pl-9 mr-12 hover:bg-blue-300 w-full focus:outline-none'
}; };
</script> </script>
@ -52,14 +39,16 @@
<button on:click={reset}> ✖️ </button> <button on:click={reset}> ✖️ </button>
</div> </div>
<select on:change={filter} class={filterSort} name="category" bind:value={filters.selectedCat}> <select on:change={filter} class={`p-1 pl-9 mr-12 hover:bg-blue-300 w-full focus:outline-none ${$filters.selectedCat? 'bg-blue-300':''}`}
name="category" bind:value={$filters.selectedCat}>
<option select="selected" value={0}>Category</option> <option select="selected" value={0}>Category</option>
{#each $tags as tag (tag.name)} {#each $tags as tag (tag.name)}
<option value={tag.name}>{tag.name}</option> <option value={tag.name}>{tag.name}</option>
{/each} {/each}
</select> </select>
<select on:change={filter} class={filterSort} name="rating" bind:value={filters.selectedRating}> <select on:change={filter} class={`p-1 pl-9 mr-12 hover:bg-blue-300 w-full focus:outline-none ${$filters.selectedRating? 'bg-blue-300':''}`}
name="rating" bind:value={$filters.selectedRating}>
<option select="selected" value={0}>Rating</option> <option select="selected" value={0}>Rating</option>
{#each stars as starValue, i (starValue)} {#each stars as starValue, i (starValue)}
<option value={i + 1}>{starValue}</option> <option value={i + 1}>{starValue}</option>

View File

@ -1,8 +1,10 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
export const products = writable([]) export const products = writable([])
export const currentProduct = writable({})
export const productsView = writable([])
export const emotions = writable([]) export const emotions = writable([])
export const tags = writable([]) export const tags = writable([])
export const productsView = writable([])
export const currentProduct = writable({})
export const filters = writable({ selectedCat: 0, selectedRating: 0 })

View File

@ -1,19 +1,18 @@
<script> <script>
import '../app.css'; import '../app.css';
import { products, productsView, currentProduct } from '$lib/stores'; import { products, productsView, currentProduct, filters } from '$lib/stores';
import { defaultFilter, resetParams } from '$helpers';
import Products from '$lib/Layout/Products.svelte'; import Products from '$lib/Layout/Products.svelte';
import Header from '$lib/Layout/Header.svelte'; import Header from '$lib/Layout/Header.svelte';
import Search from '$lib/Layout/Search.svelte'; import Search from '$lib/Layout/Search.svelte';
import Filters from '$lib/Layout/Filters.svelte'; import Filters from '$lib/Layout/Filters.svelte';
import Sort from '$lib/Layout/Sort.svelte'; import Sort from '$lib/Layout/Sort.svelte';
let filters = { selectedCat: 0, selectedRating: 0 };
const reset = () => { const reset = () => {
productsView.set($products); productsView.set($products);
const url = new URL(window.location); resetParams();
window.history.pushState({}, '', url);
currentProduct.set({}); currentProduct.set({});
filters = { selectedCat: 0, selectedRating: 0 }; filters.set(structuredClone(defaultFilter));
}; };
const { main, container, sidebar } = { const { main, container, sidebar } = {
@ -29,10 +28,10 @@
<div class={sidebar}> <div class={sidebar}>
<Search /> <Search />
{#if !Object.keys($currentProduct).length} {#if !Object.keys($currentProduct).length}
<Filters bind:filters {reset} /> <Filters {reset} />
<Sort /> <Sort />
{:else} {:else}
<Products productsView={$productsView} {currentProduct} /> <Products productsView={$productsView} {currentProduct}/>
{/if} {/if}
</div> </div>
</div> </div>

BIN
static/home.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 826 KiB

View File

@ -10,7 +10,7 @@ const config = {
}), }),
kit: { kit: {
adapter: adapter(), adapter: adapter()
} }
}; };

View File

@ -1,6 +1,6 @@
import { expect, test } from '@playwright/test'; import { expect, test } from '@playwright/test';
test('index page has expected h1', async ({ page }) => { // test('index page has expected h1', async ({ page }) => {
await page.goto('/'); // await page.goto('/');
expect(await page.textContent('main')).toBe('Welcome to SvelteKit'); // expect(await page.textContent('main')).toBe('Welcome to SvelteKit');
}); // });

View File

@ -1,3 +1,9 @@
{ {
"extends": "./.svelte-kit/tsconfig.json", "extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"paths": {
"$helpers/*": ["src/helpers/*"]
}
}
} }

View File

@ -1,5 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { resolve } from 'path'; import { resolve } from 'path';
import { configDefaults } from 'vitest/config';
/** @type {import('vite').UserConfig} */ /** @type {import('vite').UserConfig} */
const config = { const config = {
@ -15,6 +16,27 @@ const config = {
fs: { fs: {
allow: ['backend'] allow: ['backend']
} }
},
define: {
// Eliminate in-source test code
'import.meta.vitest': 'undefined'
},
test: {
// jest like globals
globals: true,
environment: 'jsdom',
// in-source testing
includeSource: ['src/**/*.{js,ts,svelte}'],
// Exclude files in c8
coverage: {
exclude: ['setupTest.js', 'src/mocks']
},
setupFiles: ['./setupTest.js'],
deps: {
// Put Svelte component here, e.g., inline: [/svelte-multiselect/, /msw/]
},
// Exclude playwright tests folder
exclude: [...configDefaults.exclude, 'tests']
} }
}; };

8118
yarn.lock

File diff suppressed because it is too large Load Diff