diff --git a/package-lock.json b/package-lock.json index 5491c62..fdf0596 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "michalvankodev", "version": "0.0.1", "dependencies": { + "@mobily/ts-belt": "^3.10.0", "@vanilla-extract/css": "^1.6.8", "@vanilla-extract/sprinkles": "^1.4.0", "@vanilla-extract/vite-plugin": "^3.1.4", @@ -40,7 +41,8 @@ "svgstore-cli": "^2.0.1", "tslib": "^2.3.1", "typescript": "^4.6.2", - "vite": "^2.8.6" + "vite": "^2.8.6", + "vitest": "^0.7.10" } }, "node_modules/@babel/code-frame": { @@ -445,6 +447,14 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "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": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -566,6 +576,21 @@ "integrity": "sha512-aqkICXbM1oX5FfgZd2qSSAGdyo/NRxjWCamxoyi3T8iVQnzGge19HhDYzZ6NrVOW7bhcWNSq9XexWFtMzbB24A==", "dev": true }, + "node_modules/@types/chai": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.0.tgz", + "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", + "dev": true + }, + "node_modules/@types/chai-subset": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz", + "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/classnames": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.1.tgz", @@ -1035,6 +1060,15 @@ "node": ">=8" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -1120,6 +1154,24 @@ "optional": true, "peer": true }, + "node_modules/chai": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", + "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "loupe": "^2.3.1", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -1136,6 +1188,15 @@ "node": ">=4" } }, + "node_modules/check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/cheerio": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz", @@ -1304,6 +1365,18 @@ } } }, + "node_modules/deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -2377,6 +2450,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -2787,6 +2869,18 @@ "node": ">=10" } }, + "node_modules/local-pkg": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.1.tgz", + "integrity": "sha512-lL87ytIGP2FU5PWwNDo0w3WhIo2gopIAxPg9RxDYF7m4rr5ahuZxP22xnJHIvaLTe4Z9P6uKKY2UHiwyB4pcrw==", + "dev": true, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2812,6 +2906,15 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/loupe": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", + "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.0" + } + }, "node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -3119,6 +3222,15 @@ "node": ">=8" } }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -3707,6 +3819,24 @@ "globrex": "^0.1.2" } }, + "node_modules/tinypool": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.1.2.tgz", + "integrity": "sha512-fvtYGXoui2RpeMILfkvGIgOVkzJEGediv8UJt7TxdAOY8pnvUkFg/fkvqTfXG9Acc9S17Cnn1S4osDc2164guA==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-0.3.0.tgz", + "integrity": "sha512-c5uFHqtUp74R2DJE3/Efg0mH5xicmgziaQXMm/LvuuZn3RdpADH32aEGDRyCzObXT1DNfwDMqRQ/Drh1MlO12g==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -3775,6 +3905,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -3885,6 +4024,50 @@ "esbuild-windows-arm64": "0.14.27" } }, + "node_modules/vitest": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.7.10.tgz", + "integrity": "sha512-We5a7cnY2aUpX4tAO+w2KRhJiJ4FznfWjYKkqWoAqs4x4pKgyRsMJNZ7OSY/lFHOoRz3yv0mgwfVlZiRc0/mmA==", + "dev": true, + "dependencies": { + "@types/chai": "^4.3.0", + "@types/chai-subset": "^1.3.3", + "chai": "^4.3.6", + "local-pkg": "^0.4.1", + "tinypool": "^0.1.2", + "tinyspy": "^0.3.0", + "vite": "^2.8.6" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.16.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vitest/ui": "*", + "c8": "*", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@vitest/ui": { + "optional": true + }, + "c8": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", @@ -4315,6 +4498,11 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "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": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4401,6 +4589,21 @@ "integrity": "sha512-aqkICXbM1oX5FfgZd2qSSAGdyo/NRxjWCamxoyi3T8iVQnzGge19HhDYzZ6NrVOW7bhcWNSq9XexWFtMzbB24A==", "dev": true }, + "@types/chai": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.0.tgz", + "integrity": "sha512-/ceqdqeRraGolFTcfoXNiqjyQhZzbINDngeoAq9GoHa8PPK1yNzTaxWjA6BFWp5Ua9JpXEMSS4s5i9tS0hOJtw==", + "dev": true + }, + "@types/chai-subset": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.3.tgz", + "integrity": "sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw==", + "dev": true, + "requires": { + "@types/chai": "*" + } + }, "@types/classnames": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/@types/classnames/-/classnames-2.3.1.tgz", @@ -4721,6 +4924,12 @@ "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", "dev": true }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -4787,6 +4996,21 @@ "optional": true, "peer": true }, + "chai": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.6.tgz", + "integrity": "sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "loupe": "^2.3.1", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + } + }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -4800,6 +5024,12 @@ "supports-color": "^5.3.0" } }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, "cheerio": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.10.tgz", @@ -4929,6 +5159,15 @@ "ms": "2.1.2" } }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -5623,6 +5862,12 @@ "optional": true, "peer": true }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, "glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -5932,6 +6177,12 @@ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.0.4.tgz", "integrity": "sha512-bfTIN7lEsiooCocSISTWXkiWJkRqtL9wYtYy+8EK3Y41qh3mpwPU0ycTOgjdY9ErwXCc8QyrQp82bdL0Xkm9yA==" }, + "local-pkg": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.1.tgz", + "integrity": "sha512-lL87ytIGP2FU5PWwNDo0w3WhIo2gopIAxPg9RxDYF7m4rr5ahuZxP22xnJHIvaLTe4Z9P6uKKY2UHiwyB4pcrw==", + "dev": true + }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5951,6 +6202,15 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "loupe": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.4.tgz", + "integrity": "sha512-OvKfgCC2Ndby6aSTREl5aCCPTNIzlDfQZvZxNUrBrihDhL3xcrYegTblhmEiCrg2kKQz4XsFIaemE5BF4ybSaQ==", + "dev": true, + "requires": { + "get-func-name": "^2.0.0" + } + }, "lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -6180,6 +6440,12 @@ "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", "dev": true }, + "pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true + }, "picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -6535,6 +6801,18 @@ "globrex": "^0.1.2" } }, + "tinypool": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.1.2.tgz", + "integrity": "sha512-fvtYGXoui2RpeMILfkvGIgOVkzJEGediv8UJt7TxdAOY8pnvUkFg/fkvqTfXG9Acc9S17Cnn1S4osDc2164guA==", + "dev": true + }, + "tinyspy": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-0.3.0.tgz", + "integrity": "sha512-c5uFHqtUp74R2DJE3/Efg0mH5xicmgziaQXMm/LvuuZn3RdpADH32aEGDRyCzObXT1DNfwDMqRQ/Drh1MlO12g==", + "dev": true + }, "to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -6590,6 +6868,12 @@ "prelude-ls": "^1.2.1" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -6658,6 +6942,21 @@ } } }, + "vitest": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.7.10.tgz", + "integrity": "sha512-We5a7cnY2aUpX4tAO+w2KRhJiJ4FznfWjYKkqWoAqs4x4pKgyRsMJNZ7OSY/lFHOoRz3yv0mgwfVlZiRc0/mmA==", + "dev": true, + "requires": { + "@types/chai": "^4.3.0", + "@types/chai-subset": "^1.3.3", + "chai": "^4.3.6", + "local-pkg": "^0.4.1", + "tinypool": "^0.1.2", + "tinyspy": "^0.3.0", + "vite": "^2.8.6" + } + }, "word-wrap": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", diff --git a/package.json b/package.json index 036fad6..a858cf3 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "build": "svelte-kit build --verbose", "preview": "svelte-kit preview", "start": "svelte-kit start", - "test": "run-p --race dev cy:run", + "test": "vitest", "lint": "prettier --check . && eslint --ignore-path .gitignore .", "format": "prettier --write .", "svgstore": "svgstore -o static/build/icons-sprite.svg src/svg/**.svg" }, "dependencies": { + "@mobily/ts-belt": "^3.10.0", "@vanilla-extract/css": "^1.6.8", "@vanilla-extract/sprinkles": "^1.4.0", "@vanilla-extract/vite-plugin": "^3.1.4", @@ -48,6 +49,7 @@ "svgstore-cli": "^2.0.1", "tslib": "^2.3.1", "typescript": "^4.6.2", - "vite": "^2.8.6" + "vite": "^2.8.6", + "vitest": "^0.7.10" } } diff --git a/src/lib/pagination/pagination.test.ts b/src/lib/pagination/pagination.test.ts new file mode 100644 index 0000000..a9fda45 --- /dev/null +++ b/src/lib/pagination/pagination.test.ts @@ -0,0 +1,98 @@ +import { range } from 'ramda' +import { describe, expect, test } from 'vitest' +import { filterByPropContains, dropAndTake, filterAndCount } from './pagination' + +describe('pagination', () => { + test('does not drop any items by default', () => { + const items = range(0, 100) + expect(dropAndTake({})(items)).toHaveLength(100) + }) + + test('limits out exact number of items', () => { + const items = range(0, 100) + expect(dropAndTake({ limit: 10 })(items)).toHaveLength(10) + expect(dropAndTake({ limit: 10 })(items)[0]).toBe(0) + expect(dropAndTake({ limit: 10 })(items)[9]).toBe(9) + }) + + test('offset is skipping a number of items from the front', () => { + const items = range(0, 100) + expect(dropAndTake({ offset: 10 })(items)).toHaveLength(90) + expect(dropAndTake({ offset: 10 })(items)[0]).toBe(10) + }) + + test('is able to combine limit and offset', () => { + const items = range(0, 100) + expect(dropAndTake({ offset: 10, limit: 10 })(items)).toHaveLength(10) + expect(dropAndTake({ offset: 10, limit: 10 })(items)[0]).toBe(10) + expect(dropAndTake({ offset: 10, limit: 10 })(items)[9]).toBe(19) + }) + + test('is able to filter by a field', () => { + const items = [ + { + id: 1, + prop: ['yes'], + }, + { + id: 2, + prop: ['yes', 'no'], + }, + ] + + expect(filterByPropContains({ prop: 'no' })(items)).toHaveLength(1) + expect(filterByPropContains({ prop: 'no' })(items)[0].id).toBe(2) + + expect(filterByPropContains({ prop: 'yes' })(items)[0].id).toBe(1) + expect(filterByPropContains({ prop: 'yes' })(items)).toHaveLength(2) + }) + + describe('is able to combine limit and offset while filtering by field', () => { + const items = [ + { + id: 1, + prop: ['yes'], + }, + { + id: 2, + prop: ['yes', 'no'], + }, + { + id: 3, + prop: ['yes', 'no'], + }, + ] + + test('combine all parameters', () => { + const result = filterAndCount({ + offset: 1, + limit: 1, + filters: { prop: 'no' }, + })(items) + expect(result.totalCount).toBe(2) + expect(result.items[0].id).toBe(3) + }) + + test('with 0 offset', () => { + const result = filterAndCount({ + offset: 0, + limit: 1, + filters: { prop: 'no' }, + })(items) + expect(result.totalCount).toBe(2) + expect(result.items[0].id).toBe(2) + }) + + test('without filter', () => { + const result = filterAndCount({ offset: 1, limit: 1 })(items) + expect(result.totalCount).toBe(3) + expect(result.items[0].id).toBe(2) + }) + + test('without any params', () => { + const result = filterAndCount({})(items) + expect(result.totalCount).toBe(3) + expect(result.items.length).toEqual(result.totalCount) + }) + }) +}) diff --git a/src/lib/pagination/pagination.ts b/src/lib/pagination/pagination.ts new file mode 100644 index 0000000..3dabaf0 --- /dev/null +++ b/src/lib/pagination/pagination.ts @@ -0,0 +1,44 @@ +import { identity } from 'ramda' +import { flow, A } from '@mobily/ts-belt' +const { drop, take } = A + +export interface PaginationQuery { + offset?: number + limit?: number + filters?: Record +} + +export interface PaginationResult { + items: ItemType[] + totalCount: number +} + +export function dropAndTake({ offset = 0, limit = Infinity }) { + return flow(drop(offset), take(limit)) +} + +export function filterByPropContains(filters: Record) { + return function (items: Item[]) { + return items.filter((item) => { + return Object.entries(filters).every(([fieldName, value]) => + item[fieldName].includes(value) + ) + }) + } +} + +export function filterAndCount({ + filters, + ...dropTakeParams +}: PaginationQuery) { + return function (items: Item[]) { + const filterFunction = filters + ? filterByPropContains(filters) + : identity + const filteredItems = filterFunction(items) + return { + items: dropAndTake(dropTakeParams)(filteredItems), + totalCount: filteredItems.length, + } + } +} diff --git a/src/lib/pagination/searchParams.test.ts b/src/lib/pagination/searchParams.test.ts new file mode 100644 index 0000000..8928369 --- /dev/null +++ b/src/lib/pagination/searchParams.test.ts @@ -0,0 +1,64 @@ +import { describe, test, expect } from 'vitest' +import { + getPaginationQueryFromSearchParams, + getPaginationSearchParams, + parseParams, +} from './searchParams' + +describe('convert search params', () => { + test('drop take params are not taken as filters', () => { + expect( + getPaginationQueryFromSearchParams( + new URLSearchParams('offset=2&limit=5') + ) + ).toEqual({ offset: 2, limit: 5 }) + }) + + test('return empty paginationQuery if ', () => { + expect(getPaginationQueryFromSearchParams(new URLSearchParams(''))).toEqual( + {} + ) + }) + + test('other than drop take params are moved to filters ', () => { + expect( + getPaginationQueryFromSearchParams(new URLSearchParams('tag=news')) + ).toEqual({ filters: { tag: 'news' } }) + }) + + test('offset and filter combined', () => { + expect( + getPaginationQueryFromSearchParams( + new URLSearchParams('offset=3&tag=news') + ) + ).toEqual({ offset: 3, filters: { tag: 'news' } }) + }) +}) + +describe('get search params', () => { + test('parse params', () => { + const params = 'tags/News/page/1' + expect(parseParams(params)).toEqual({ tags: 'News', page: '1' }) + }) + + test('should parse values into searchParams for first page', () => { + const params = 'tags/News/page/1' + expect(getPaginationSearchParams(7, params).toString()).toEqual( + 'limit=7&offset=0&tags=News' + ) + }) + + test('should parse values into searchParams for third page', () => { + const params = 'tags/News/page/3' + expect(getPaginationSearchParams(7, params).toString()).toEqual( + 'limit=7&offset=14&tags=News' + ) + }) + + test('should return first page without any params specified', () => { + const params = '' + expect(getPaginationSearchParams(7, params).toString()).toEqual( + 'limit=7&offset=0' + ) + }) +}) diff --git a/src/lib/pagination/searchParams.ts b/src/lib/pagination/searchParams.ts new file mode 100644 index 0000000..1cc4a51 --- /dev/null +++ b/src/lib/pagination/searchParams.ts @@ -0,0 +1,46 @@ +import { splitEvery } from 'ramda' +import type { PaginationQuery } from './pagination' + +export function getPaginationQueryFromSearchParams( + searchParams: URLSearchParams +) { + return Array.from(searchParams).reduce( + (acc, [key, value]) => { + const isDropTake = ['offset', 'limit'].includes(key) + if (isDropTake) { + return { + ...acc, + [key]: Number(value), + } + } + return { + ...acc, + filters: { + ...acc.filters, + [key]: value, + }, + } + }, + {} + ) +} + +export function parseParams(params: string) { + const splittedParams = params.split('/') + if (splittedParams.length % 2 !== 0) { + return [] + } + const splits = splitEvery(2, splittedParams) + return Object.fromEntries(splits) +} + +/** + * 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) { + const { page = 1, ...filters } = parseParams(params) + + const offset = pageSize * (page - 1) + const limit = pageSize + return new URLSearchParams({ limit, offset, ...filters }) +} diff --git a/src/routes/__layout.svelte b/src/routes/__layout.svelte index 09c7c21..27e4924 100644 --- a/src/routes/__layout.svelte +++ b/src/routes/__layout.svelte @@ -3,11 +3,11 @@ import type { LoadInput, LoadOutput } from '@sveltejs/kit/types/page' export async function load({ fetch, url }: LoadInput): Promise { - const blogPostsResponse = await fetch(`/blog/articles`) + const blogPostsResponse = await fetch(`/blog/articles?limit=5`) const blogPostsContent = await blogPostsResponse.json() return { props: { - latestPosts: take(5, blogPostsContent.posts), + latestPosts: blogPostsContent.posts.items, // TODO Check if not bugged FIXME segment: '', }, diff --git a/src/routes/blog/[...params].svelte b/src/routes/blog/[...params].svelte index 699ddf2..b089d14 100644 --- a/src/routes/blog/[...params].svelte +++ b/src/routes/blog/[...params].svelte @@ -1,10 +1,15 @@ @@ -23,7 +29,7 @@ My blog @michalvankodev -{#if posts.length === 0} +{#if posts.items.length === 0}

You've found void in the space.

{:else}

@@ -40,7 +46,7 @@ {/if} {/if}
    - {#each posts as post} + {#each posts.items as post}
  • diff --git a/src/routes/blog/_content.ts b/src/routes/blog/_content.ts index c466345..1de5989 100644 --- a/src/routes/blog/_content.ts +++ b/src/routes/blog/_content.ts @@ -1,11 +1,18 @@ import { readdir, readFile } from 'fs' import { promisify } from 'util' import { basename } from 'path' -import { pipe, partial, prop, sortBy, reverse, filter } from 'ramda' +import { pipe, prop, sortBy, reverse, filter } from 'ramda' import fm from 'front-matter' import marked from 'marked' +import { + filterAndCount, + type PaginationQuery, +} from '$lib/pagination/pagination' const { NODE_ENV } = process.env +// TODO remove ramda and migrate to ts-belt +// TODO Pagination component for routing +// TODO Tag filtering visualization export interface PostAttributes { layout: string @@ -22,7 +29,7 @@ export interface PostContent extends PostAttributes { published: boolean } -export async function getBlogListing(tag?: string) { +export async function getBlogListing(paginationQuery: PaginationQuery) { const files = await promisify(readdir)(`_posts/blog/`, 'utf-8') const filteredFiles = filterDevelopmentFiles(files) @@ -48,17 +55,11 @@ export async function getBlogListing(tag?: string) { } }) ) - const filteredContents = pipe< - PostContent[], - PostContent[], - PostContent[], - PostContent[], - PostContent[] - >( - sortBy(prop('date')), - reverse, + const filteredContents = pipe( + sortBy(prop('date')), + (items) => reverse(items), filter((article) => article.published), - partial(filterByTag, [tag]) + filterAndCount(paginationQuery) )(contents) return filteredContents @@ -69,9 +70,3 @@ function filterDevelopmentFiles(files: string[]) { ? files : files.filter((file) => !file.startsWith('dev-')) } - -function filterByTag(tag: string | undefined, contents: PostContent[]) { - return tag - ? contents.filter((content) => content.tags.includes(tag)) - : contents -} diff --git a/src/routes/blog/articles.ts b/src/routes/blog/articles.ts index b55f91d..e1050fe 100644 --- a/src/routes/blog/articles.ts +++ b/src/routes/blog/articles.ts @@ -1,11 +1,10 @@ +import { getPaginationQueryFromSearchParams } from '$lib/pagination/searchParams' import { getBlogListing } from './_content' export async function get({ url: { searchParams } }) { - console.log('bloglistingparams', searchParams) + const paginationQuery = getPaginationQueryFromSearchParams(searchParams) + const filteredContents = await getBlogListing(paginationQuery) - //Regexp for getting an optional tag and a page from the params - const tag = undefined - const filteredContents = await getBlogListing(tag) return { status: 200, body: {