Style paginator component

This commit is contained in:
Michal Vanko 2022-04-03 20:14:08 +02:00
parent 9846fab54c
commit 27d17874f4
15 changed files with 299 additions and 52 deletions

View File

@ -90,7 +90,7 @@ work_history:
displayed: true displayed: true
address: address:
name: eSOLUTIONS s.r.o. name: eSOLUTIONS s.r.o.
location: Hroncová 2 location: Gorkého 8
zipcode: 040 01 zipcode: 040 01
city: Košice city: Košice
country: Slovakia country: Slovakia
@ -151,7 +151,7 @@ projects:
This project is built with modern web technologies including: **CycleJS**, **Reactive Streams**, **D3**, **Jest**, **Webpack**. This project is built with modern web technologies including: **CycleJS**, **Reactive Streams**, **D3**, **Jest**, **Webpack**.
displayed: true displayed: true
image: image:
image_description: " responzIO main controller" image_description: ' responzIO main controller'
source: /images/uploads/responzio.png source: /images/uploads/responzio.png
name: responzIO name: responzIO
- description: >- - description: >-
@ -300,14 +300,16 @@ presentations:
affect the future of the world. affect the future of the world.
- displayed: true - displayed: true
name: Spreading the web name: Spreading the web
description: A presentation about the rising number of use cases for utilizing description:
A presentation about the rising number of use cases for utilizing
web technologies outside of the web platform such as native mobile web technologies outside of the web platform such as native mobile
applications and robotics. applications and robotics.
link: https://michalvankodev.github.io/spreading-the-web/#/ link: https://michalvankodev.github.io/spreading-the-web/#/
- displayed: true - displayed: true
name: Docker name: Docker
link: http://michalvankodev.github.io/dockerpresentation/#/ link: http://michalvankodev.github.io/dockerpresentation/#/
description: An introduction to Docker containerization technology and how it description:
An introduction to Docker containerization technology and how it
differs from virtualization. differs from virtualization.
education: education:
- description: |- - description: |-

43
package-lock.json generated
View File

@ -8,7 +8,6 @@
"name": "michalvankodev", "name": "michalvankodev",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@mobily/ts-belt": "^3.10.0",
"@vanilla-extract/css": "^1.6.8", "@vanilla-extract/css": "^1.6.8",
"@vanilla-extract/sprinkles": "^1.4.0", "@vanilla-extract/sprinkles": "^1.4.0",
"@vanilla-extract/vite-plugin": "^3.1.4", "@vanilla-extract/vite-plugin": "^3.1.4",
@ -42,7 +41,8 @@
"tslib": "^2.3.1", "tslib": "^2.3.1",
"typescript": "^4.6.2", "typescript": "^4.6.2",
"vite": "^2.8.6", "vite": "^2.8.6",
"vitest": "^0.7.10" "vitest": "^0.7.13",
"vitest-svelte-kit": "^0.0.6"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@ -447,14 +447,6 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
"dev": true "dev": true
}, },
"node_modules/@mobily/ts-belt": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@mobily/ts-belt/-/ts-belt-3.10.0.tgz",
"integrity": "sha512-F3XLU3zMDzJOf9KlKgnNOz5rdAtMG/UBxEDU4UNA4ewKFRd5DsbIIJmeAifLudNwcXmoIgtZ39KwVjPaL/CjgA==",
"engines": {
"node": ">= 10.*"
}
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -4025,9 +4017,9 @@
} }
}, },
"node_modules/vitest": { "node_modules/vitest": {
"version": "0.7.10", "version": "0.7.13",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-0.7.10.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.7.13.tgz",
"integrity": "sha512-We5a7cnY2aUpX4tAO+w2KRhJiJ4FznfWjYKkqWoAqs4x4pKgyRsMJNZ7OSY/lFHOoRz3yv0mgwfVlZiRc0/mmA==", "integrity": "sha512-UCHeJEOK+qCBa/e4UtkCfv0wIZ125T4Nf2R0J/46v/Wnv6bt9zGfAyKAI6siYFhvLvg20MgDIreROtVgedHFWw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@types/chai": "^4.3.0", "@types/chai": "^4.3.0",
@ -4042,7 +4034,7 @@
"vitest": "vitest.mjs" "vitest": "vitest.mjs"
}, },
"engines": { "engines": {
"node": ">=v14.16.0" "node": ">=v14.19.1"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/antfu" "url": "https://github.com/sponsors/antfu"
@ -4068,6 +4060,12 @@
} }
} }
}, },
"node_modules/vitest-svelte-kit": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/vitest-svelte-kit/-/vitest-svelte-kit-0.0.6.tgz",
"integrity": "sha512-bQ1GcCAk600YV1xOiJBhltGE/HO/j6FozNY2BFq2GP1mHh3pj0KrGZlyx0kVlXx+BSKDXQHuYZtwlHwNlvv0fQ==",
"dev": true
},
"node_modules/word-wrap": { "node_modules/word-wrap": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
@ -4498,11 +4496,6 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
"dev": true "dev": true
}, },
"@mobily/ts-belt": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/@mobily/ts-belt/-/ts-belt-3.10.0.tgz",
"integrity": "sha512-F3XLU3zMDzJOf9KlKgnNOz5rdAtMG/UBxEDU4UNA4ewKFRd5DsbIIJmeAifLudNwcXmoIgtZ39KwVjPaL/CjgA=="
},
"@nodelib/fs.scandir": { "@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@ -6943,9 +6936,9 @@
} }
}, },
"vitest": { "vitest": {
"version": "0.7.10", "version": "0.7.13",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-0.7.10.tgz", "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.7.13.tgz",
"integrity": "sha512-We5a7cnY2aUpX4tAO+w2KRhJiJ4FznfWjYKkqWoAqs4x4pKgyRsMJNZ7OSY/lFHOoRz3yv0mgwfVlZiRc0/mmA==", "integrity": "sha512-UCHeJEOK+qCBa/e4UtkCfv0wIZ125T4Nf2R0J/46v/Wnv6bt9zGfAyKAI6siYFhvLvg20MgDIreROtVgedHFWw==",
"dev": true, "dev": true,
"requires": { "requires": {
"@types/chai": "^4.3.0", "@types/chai": "^4.3.0",
@ -6957,6 +6950,12 @@
"vite": "^2.8.6" "vite": "^2.8.6"
} }
}, },
"vitest-svelte-kit": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/vitest-svelte-kit/-/vitest-svelte-kit-0.0.6.tgz",
"integrity": "sha512-bQ1GcCAk600YV1xOiJBhltGE/HO/j6FozNY2BFq2GP1mHh3pj0KrGZlyx0kVlXx+BSKDXQHuYZtwlHwNlvv0fQ==",
"dev": true
},
"word-wrap": { "word-wrap": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",

View File

@ -16,7 +16,6 @@
"svgstore": "svgstore -o static/build/icons-sprite.svg src/svg/**.svg" "svgstore": "svgstore -o static/build/icons-sprite.svg src/svg/**.svg"
}, },
"dependencies": { "dependencies": {
"@mobily/ts-belt": "^3.10.0",
"@vanilla-extract/css": "^1.6.8", "@vanilla-extract/css": "^1.6.8",
"@vanilla-extract/sprinkles": "^1.4.0", "@vanilla-extract/sprinkles": "^1.4.0",
"@vanilla-extract/vite-plugin": "^3.1.4", "@vanilla-extract/vite-plugin": "^3.1.4",
@ -50,6 +49,7 @@
"tslib": "^2.3.1", "tslib": "^2.3.1",
"typescript": "^4.6.2", "typescript": "^4.6.2",
"vite": "^2.8.6", "vite": "^2.8.6",
"vitest": "^0.7.10" "vitest": "^0.7.13",
"vitest-svelte-kit": "^0.0.6"
} }
} }

View File

@ -21,7 +21,7 @@
<ul class={tagsListClass}> <ul class={tagsListClass}>
{#each post.tags as tag} {#each post.tags as tag}
<li class={tagsListLiClass}> <li class={tagsListLiClass}>
<a href="/blog?tag={tag}">{tag}</a> <a href="/blog/tags/{tag}">{tag}</a>
</li> </li>
{/each} {/each}
</ul> </ul>

View File

@ -0,0 +1,21 @@
import { sprinkles } from '$lib/styles/sprinkles.css'
export const listClass = sprinkles({
listStyle: 'none',
display: 'flex',
justifyContent: 'center',
})
export const listItemClass = sprinkles({
paddingX: '1x',
})
export const activePage = sprinkles({
//fontStyle: 'italic',
fontWeight: 'bold',
paddingX: '2x',
})
export const pageLinkClass = sprinkles({
paddingX: '1x',
})

View File

@ -0,0 +1,52 @@
<script lang="ts">
import {
activePage,
listClass,
listItemClass,
pageLinkClass,
} from './Paginator.css'
import { getPaginatorPages, createHref } from './paginatorUtils'
// TODO styles
export const Divider = 'divider'
export let href: string
export let page: number
export let pageSize: number
export let totalCount: number
export let filters: Record<string, string>
let paginatorPages: (number | typeof Divider)[]
$: paginatorPages = getPaginatorPages({ page, pageSize, totalCount })
</script>
<ul class={listClass}>
{#if page !== 1}
<li class="{listItemClass} ">
<a class={pageLinkClass} href={createHref(href, filters, page - 1)}
>&lt;</a
>
</li>
{/if}
{#each paginatorPages as pageNumber}
{#if pageNumber === Divider}
<li class={listItemClass}>...</li>
{:else if page === pageNumber}
<li class="{listItemClass} {activePage}">{pageNumber}</li>
{:else}
<li class="{listItemClass} ">
<a class={pageLinkClass} href={createHref(href, filters, pageNumber)}
>{pageNumber}</a
>
</li>
{/if}
{/each}
{#if page !== paginatorPages.length}
<li class="{listItemClass} ">
<a class={pageLinkClass} href={createHref(href, filters, page + 1)}
>&gt;</a
>
</li>
{/if}
</ul>

View File

@ -0,0 +1,52 @@
import { describe, expect, test } from 'vitest'
import { Divider, getPaginatorPages } from './paginatorUtils'
describe('Paginator component', () => {
describe('Paginator generates feasable pages to display', () => {
test('Page: 1/5', () => {
expect(
getPaginatorPages({ page: 1, totalCount: 5, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5])
})
test('Page: 4/7', () => {
expect(
getPaginatorPages({ page: 4, totalCount: 7, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5, 6, 7])
})
test('Page: 4/8', () => {
expect(
getPaginatorPages({ page: 4, totalCount: 8, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5, 6, Divider, 8])
})
test('Page: 1/10', () => {
expect(
getPaginatorPages({ page: 1, totalCount: 10, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5, 6, Divider, 10])
})
test('Page: 2/10', () => {
expect(
getPaginatorPages({ page: 2, totalCount: 10, pageSize: 1 })
).toEqual([1, 2, 3, 4, 5, 6, Divider, 10])
})
test('Page: 5/10', () => {
expect(
getPaginatorPages({ page: 5, totalCount: 10, pageSize: 1 })
).toEqual([1, Divider, 3, 4, 5, 6, 7, Divider, 10])
})
test('Page: 7/10', () => {
expect(
getPaginatorPages({ page: 7, totalCount: 10, pageSize: 1 })
).toEqual([1, Divider, 5, 6, 7, 8, 9, 10])
})
test('Page: 8/10', () => {
expect(
getPaginatorPages({ page: 8, totalCount: 10, pageSize: 1 })
).toEqual([1, Divider, 5, 6, 7, 8, 9, 10])
})
test('Page: 10/10', () => {
expect(
getPaginatorPages({ page: 10, totalCount: 10, pageSize: 1 })
).toEqual([1, Divider, 5, 6, 7, 8, 9, 10])
})
})
})

View File

@ -0,0 +1,50 @@
import { toParams } from '$lib/pagination/searchParams'
import { last, range } from 'ramda'
export const Divider = 'divider'
export function getPaginatorPages({
page,
pageSize,
totalCount,
}: {
page: number
pageSize: number
totalCount: number
}) {
const maxLinksLength = 7
const linksAroundActive = 2
const totalPages = Math.ceil(totalCount / pageSize)
const daco = range(1, totalPages + 1).reduce((acc, link) => {
const isFirst = link === 1
const isLast = link === totalPages
const isPageOnStart = page <= 3 && link < maxLinksLength
const isPageOnEnd =
page >= totalPages - 3 && link > totalPages - maxLinksLength + 1
if ([isFirst, isLast, isPageOnStart, isPageOnEnd].some((value) => value)) {
return [...acc, link]
}
if (link < page - linksAroundActive || link > page + linksAroundActive) {
if (last(acc) === Divider) {
return acc
}
return [...acc, Divider]
}
return [...acc, link]
}, [])
return daco
}
export function createHref(
href: string,
filters: Record<string, string>,
pageNumber: number
) {
const filtersPath = toParams(filters)
console.log(filtersPath, filters)
return `/${href}/${filtersPath ? filtersPath + '/' : ''}page/${pageNumber}`
}

View File

@ -1,6 +1,4 @@
import { identity } from 'ramda' import { identity, drop, take, pipe } from 'ramda'
import { flow, A } from '@mobily/ts-belt'
const { drop, take } = A
export interface PaginationQuery { export interface PaginationQuery {
offset?: number offset?: number
@ -14,7 +12,9 @@ export interface PaginationResult<ItemType> {
} }
export function dropAndTake<Item>({ offset = 0, limit = Infinity }) { export function dropAndTake<Item>({ offset = 0, limit = Infinity }) {
return flow(drop<Item>(offset), take<Item>(limit)) return pipe(drop<Item>(offset), take<Item>(limit)) as (
items: Item[]
) => Item[]
} }
export function filterByPropContains<Item>(filters: Record<string, string>) { export function filterByPropContains<Item>(filters: Record<string, string>) {

View File

@ -42,22 +42,37 @@ describe('get search params', () => {
}) })
test('should parse values into searchParams for first page', () => { test('should parse values into searchParams for first page', () => {
const params = 'tags/News/page/1' const params = {
expect(getPaginationSearchParams(7, params).toString()).toEqual( pageSize: 7,
page: 1,
filters: {
tags: 'News',
},
}
expect(getPaginationSearchParams(params).toString()).toEqual(
'limit=7&offset=0&tags=News' 'limit=7&offset=0&tags=News'
) )
}) })
test('should parse values into searchParams for third page', () => { test('should parse values into searchParams for third page', () => {
const params = 'tags/News/page/3' const params = {
expect(getPaginationSearchParams(7, params).toString()).toEqual( pageSize: 7,
page: 3,
filters: {
tags: 'News',
},
}
expect(getPaginationSearchParams(params).toString()).toEqual(
'limit=7&offset=14&tags=News' 'limit=7&offset=14&tags=News'
) )
}) })
test('should return first page without any params specified', () => { test('should return first page without any params specified', () => {
const params = '' const params = {
expect(getPaginationSearchParams(7, params).toString()).toEqual( pageSize: 7,
page: 1,
}
expect(getPaginationSearchParams(params).toString()).toEqual(
'limit=7&offset=0' 'limit=7&offset=0'
) )
}) })

View File

@ -34,12 +34,26 @@ export function parseParams(params: string) {
return Object.fromEntries(splits) return Object.fromEntries(splits)
} }
export function toParams(records: Record<string, string>) {
return Object.entries(records)
.map(([key, value]) => `${key}/${value}`)
.join('/')
}
export interface PaginationSearchParams {
pageSize: number
page: number
filters?: Record<string, string>
}
/** /**
* Convert svelte `load` params into a `URLSearchParams` so they can be used to fetch endpoints with pagination queries * Convert svelte `load` params into a `URLSearchParams` so they can be used to fetch endpoints with pagination queries
*/ */
export function getPaginationSearchParams(pageSize: number, params: string) { export function getPaginationSearchParams({
const { page = 1, ...filters } = parseParams(params) pageSize,
page,
filters,
}: PaginationSearchParams) {
const offset = pageSize * (page - 1) const offset = pageSize * (page - 1)
const limit = pageSize const limit = pageSize
return new URLSearchParams({ limit, offset, ...filters }) return new URLSearchParams({ limit, offset, ...filters })

View File

@ -1,28 +1,47 @@
<script lang="ts" context="module"> <script lang="ts" context="module">
import { getPaginationSearchParams } from '$lib/pagination/searchParams' import {
getPaginationSearchParams,
parseParams,
} from '$lib/pagination/searchParams'
const pageSize = 7
/** /**
* @type {import('@sveltejs/kit').Load} * @type {import('@sveltejs/kit').Load}
*/ */
export async function load({ fetch, params }) { export async function load({ fetch, params }) {
console.log('params', params) console.log('params', params)
const searchParams = getPaginationSearchParams(7, params.params) const { page = 1, ...filters } = parseParams(params.params)
const searchParams = getPaginationSearchParams({ pageSize, page, filters })
console.log('searchpprsm', searchParams) console.log('searchpprsm', searchParams)
const articleResponse = await fetch( const articleResponse = await fetch(
`/blog/articles?${searchParams.toString()}` `/blog/articles?${searchParams.toString()}`
).then((r) => r.json()) ).then((r) => r.json())
return { props: { posts: articleResponse.posts } } return {
props: {
posts: articleResponse.posts,
page: Number(page),
pageSize,
filters,
},
}
} }
</script> </script>
<script lang="ts"> <script lang="ts">
import ArticleFooter from '../../components/blog/ArticleFooter.svelte' import ArticleFooter from '../../components/blog/ArticleFooter.svelte'
import Paginator from '../../components/paginator/Paginator.svelte'
import { postListClass, seeAllClass } from './index.css' import { postListClass, seeAllClass } from './index.css'
import type { PostContent } from './_content' import type { PostContent } from './_content'
import type { PaginationResult } from '$lib/pagination/pagination' import type { PaginationResult } from '$lib/pagination/pagination'
export let posts: PaginationResult<PostContent> export let posts: PaginationResult<PostContent>
export let tagQuery: string export let filters: Record<string, string>
export let page: number
export let pageSize: number
let totalPages = Math.ceil(posts.totalCount / pageSize)
// TODO display filter name
</script> </script>
<svelte:head> <svelte:head>
@ -33,18 +52,28 @@
<p class="no-posts">You've found void in the space.</p> <p class="no-posts">You've found void in the space.</p>
{:else} {:else}
<h1> <h1>
Recent {#if filters.tags}
{#if tagQuery} <em>{filters.tags}</em>
<em>{tagQuery}</em> {:else}
Blog
{/if} {/if}
posts posts
</h1> </h1>
{#if tagQuery} {#if filters.tags}
<div class={seeAllClass}> <div class={seeAllClass}>
<a href="/blog">See all posts</a> <a href="/blog">See all posts</a>
</div> </div>
{/if} {/if}
{/if} {/if}
<header>
<Paginator
href="blog"
{page}
{pageSize}
{filters}
totalCount={posts.totalCount}
/>
</header>
<ul class="post-list {postListClass}"> <ul class="post-list {postListClass}">
{#each posts.items as post} {#each posts.items as post}
<li> <li>
@ -60,3 +89,12 @@
</li> </li>
{/each} {/each}
</ul> </ul>
<footer>
<Paginator
href="blog"
{page}
{pageSize}
{filters}
totalCount={posts.totalCount}
/>
</footer>

View File

@ -24,8 +24,8 @@ export async function getFeed() {
}, },
}) })
const blogListing = await getBlogListing() const blogListing = await getBlogListing({})
blogListing.forEach((post) => { blogListing.items.forEach((post) => {
feed.addItem({ feed.addItem({
title: post.title, title: post.title,
id: `https://michalvanko.dev/blog/${post.slug}`, id: `https://michalvanko.dev/blog/${post.slug}`,

View File

@ -10,6 +10,7 @@ const config = {
kit: { kit: {
adapter: adapterStatic(), adapter: adapterStatic(),
vite: { plugins: [vanillaExtractPlugin()] }, vite: { plugins: [vanillaExtractPlugin()] },
prerender: { default: true }
}, },
preprocess: preprocess({ preprocess: preprocess({
sourceMap: dev, sourceMap: dev,

3
vitest.config.js Normal file
View File

@ -0,0 +1,3 @@
import { extractFromSvelteConfig } from "vitest-svelte-kit"
export default extractFromSvelteConfig()