diff --git a/package-lock.json b/package-lock.json index 3b98744..7f1e27c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "@types/node": "^16.11.6", "@types/react": "^17.0.38", "@types/react-dom": "^17.0.11", + "@types/react-test-renderer": "^18.0.1", "@typescript-eslint/eslint-plugin": "^5.2.0", "@typescript-eslint/parser": "^5.2.0", "builtin-modules": "^3.2.0", @@ -51,10 +52,21 @@ "jest": "^29.3.1", "obsidian": "^0.16.3", "prettier": "^2.5.1", + "react-test-renderer": "^17.0.2", "ts-jest": "^29.0.3", "tslib": "2.3.1", "typescript": "4.4.4", - "zod-fast-check": "^0.9.0" + "zod-fast-check": "^0.10.0" + } + }, + "node_modules/@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=0.10.0" } }, "node_modules/@ampproject/remapping": { @@ -675,19 +687,19 @@ "dev": true }, "node_modules/@codemirror/state": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.0.tgz", - "integrity": "sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.1.tgz", + "integrity": "sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw==", "peer": true }, "node_modules/@codemirror/view": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.9.1.tgz", - "integrity": "sha512-bzfSjJn9dAADVpabLKWKNmMG4ibyTV2e3eOGowjElNPTdTkSbi6ixPYHm2u0ADcETfKsi2/R84Rkmi91dH9yEg==", + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.18.1.tgz", + "integrity": "sha512-xcsXcMkIMd7l3WZEWoc4ljteAiqzxb5gVerRxk5132p5cLix6rTydWTQjsj2oxORepfsrwy1fC4r20iMa9plrg==", "peer": true, "dependencies": { "@codemirror/state": "^6.1.4", - "style-mod": "^4.0.0", + "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, @@ -1021,16 +1033,42 @@ "node": ">=12" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "peer": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.8.1.tgz", + "integrity": "sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ==", + "dev": true, + "peer": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.0.tgz", - "integrity": "sha512-fluIaaV+GyV24CCu/ggiHdV+j4RNh85yQnAYS/G2mZODZgGmmlrgCydjUcV3YvxCm9x8nMAfThsqTni4KiXT4A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.2.tgz", + "integrity": "sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==", "dev": true, "peer": true, "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.4.0", + "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -1046,9 +1084,9 @@ } }, "node_modules/@eslint/js": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.35.0.tgz", - "integrity": "sha512-JXdzbRiWclLVoD8sNUjR443VVlYqiYmDVT6rGUEIEHU5YJW0gaVZwV2xgM7D4arkvASqD0IlLUVjHiFuxaftRw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.49.0.tgz", + "integrity": "sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==", "dev": true, "peer": true, "engines": { @@ -1142,9 +1180,9 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", + "integrity": "sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==", "dev": true, "peer": true, "dependencies": { @@ -2254,6 +2292,15 @@ "@types/react": "^17" } }, + "node_modules/@types/react-test-renderer": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.1.tgz", + "integrity": "sha512-LjEF+jTUCjzd+Qq4eWqsmZvEWPA/l4L0my+YWN5US8Fo3wZOMiyrpBshHDFbkO8usjdO1B430mEWNU/i1MF7Qg==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", @@ -2529,9 +2576,9 @@ } }, "node_modules/acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", "bin": { "acorn": "bin/acorn" }, @@ -3893,27 +3940,28 @@ } }, "node_modules/eslint": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.35.0.tgz", - "integrity": "sha512-BxAf1fVL7w+JLRQhWl2pzGeSiGqbWumV4WNvc9Rhp6tiCtm4oHnyPBSEtMGZwrQgudFQ+otqzWoPB7x+hxoWsw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.49.0.tgz", + "integrity": "sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==", "dev": true, "peer": true, "dependencies": { - "@eslint/eslintrc": "^2.0.0", - "@eslint/js": "8.35.0", - "@humanwhocodes/config-array": "^0.11.8", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.2", + "@eslint/js": "8.49.0", + "@humanwhocodes/config-array": "^0.11.11", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -3921,23 +3969,19 @@ "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", + "graphemer": "^1.4.0", "ignore": "^5.2.0", - "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", + "optionator": "^0.9.3", "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "bin": { @@ -3991,18 +4035,21 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "peer": true, "dependencies": { @@ -4011,6 +4058,9 @@ }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/estraverse": { @@ -4024,15 +4074,15 @@ } }, "node_modules/espree": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz", - "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "peer": true, "dependencies": { - "acorn": "^8.8.0", + "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.1" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -4386,17 +4436,18 @@ } }, "node_modules/flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", + "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", "dev": true, "peer": true, "dependencies": { - "flatted": "^3.1.0", + "flatted": "^3.2.7", + "keyv": "^4.5.3", "rimraf": "^3.0.2" }, "engines": { - "node": "^10.12.0 || >=12.0.0" + "node": ">=12.0.0" } }, "node_modules/flatted": { @@ -4550,9 +4601,9 @@ } }, "node_modules/globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "version": "13.21.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz", + "integrity": "sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==", "dev": true, "peer": true, "dependencies": { @@ -4613,6 +4664,13 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "peer": true + }, "node_modules/has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -6058,17 +6116,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/js-sdsl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", - "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", - "dev": true, - "peer": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6098,6 +6145,13 @@ "node": ">=4" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "peer": true + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -6140,6 +6194,16 @@ "node": ">= 0.6" } }, + "node_modules/keyv": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", + "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "dev": true, + "peer": true, + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -7642,18 +7706,18 @@ } }, "node_modules/optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", "dev": true, "peer": true, "dependencies": { + "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" + "type-check": "^0.4.0" }, "engines": { "node": ">= 0.8.0" @@ -8146,6 +8210,40 @@ "node": ">=0.10.0" } }, + "node_modules/react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-test-renderer": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz", + "integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==", + "dev": true, + "dependencies": { + "object-assign": "^4.1.1", + "react-is": "^17.0.2", + "react-shallow-renderer": "^16.13.1", + "scheduler": "^0.20.2" + }, + "peerDependencies": { + "react": "17.0.2" + } + }, + "node_modules/react-test-renderer/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -8718,9 +8816,9 @@ } }, "node_modules/style-mod": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz", - "integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.0.tgz", + "integrity": "sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==", "peer": true }, "node_modules/style-to-object": { @@ -9329,9 +9427,9 @@ } }, "node_modules/w3c-keyname": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.6.tgz", - "integrity": "sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==", + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "peer": true }, "node_modules/walker": { @@ -9474,16 +9572,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -9607,12 +9695,12 @@ } }, "node_modules/zod-fast-check": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/zod-fast-check/-/zod-fast-check-0.9.0.tgz", - "integrity": "sha512-7N56zNAO7HabbIETlCofd8e94ZRBulo9gx8qAC5AC+yTVo++WUKqi21fNyIWHtM46Xl0iTT7ucz0blMoCTz5jg==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/zod-fast-check/-/zod-fast-check-0.10.0.tgz", + "integrity": "sha512-3AjwS/s0jB1gkbgslmSSjI+MNoT8T9R+YiM9RruyB8mwZmiVfvA8UDfu7jkwzPwSHfyyqGGnH0UL2Hag7oj18w==", "dev": true, "peerDependencies": { - "fast-check": "^2.23.0", + "fast-check": ">2.23.0 <4.0.0", "zod": "^3.18.0" } }, @@ -9627,6 +9715,13 @@ } }, "dependencies": { + "@aashutoshrathi/word-wrap": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", + "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", + "dev": true, + "peer": true + }, "@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -10083,19 +10178,19 @@ "dev": true }, "@codemirror/state": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.0.tgz", - "integrity": "sha512-69QXtcrsc3RYtOtd+GsvczJ319udtBf1PTrr2KbLWM/e2CXUPnh0Nz9AUo8WfhSQ7GeL8dPVNUmhQVgpmuaNGA==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.2.1.tgz", + "integrity": "sha512-RupHSZ8+OjNT38zU9fKH2sv+Dnlr8Eb8sl4NOnnqz95mCFTZUaiRP8Xv5MeeaG0px2b8Bnfe7YGwCV3nsBhbuw==", "peer": true }, "@codemirror/view": { - "version": "6.9.1", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.9.1.tgz", - "integrity": "sha512-bzfSjJn9dAADVpabLKWKNmMG4ibyTV2e3eOGowjElNPTdTkSbi6ixPYHm2u0ADcETfKsi2/R84Rkmi91dH9yEg==", + "version": "6.18.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.18.1.tgz", + "integrity": "sha512-xcsXcMkIMd7l3WZEWoc4ljteAiqzxb5gVerRxk5132p5cLix6rTydWTQjsj2oxORepfsrwy1fC4r20iMa9plrg==", "peer": true, "requires": { "@codemirror/state": "^6.1.4", - "style-mod": "^4.0.0", + "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, @@ -10231,16 +10326,33 @@ "integrity": "sha512-JOOxw49BVZx2/5tW3FqkdjSD/5gXYeVGPDcB0lvap0gLQshkh1Nyel1QazC+wNxus3xPlsYAgqU1BUmrmCvWtw==", "optional": true }, + "@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "peer": true, + "requires": { + "eslint-visitor-keys": "^3.3.0" + } + }, + "@eslint-community/regexpp": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.8.1.tgz", + "integrity": "sha512-PWiOzLIUAjN/w5K17PoF4n6sKBw0gqLHPhywmYHP4t1VFQQVYeb1yWsJwnMVEMl3tUHME7X/SJPZLmtG7XBDxQ==", + "dev": true, + "peer": true + }, "@eslint/eslintrc": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.0.tgz", - "integrity": "sha512-fluIaaV+GyV24CCu/ggiHdV+j4RNh85yQnAYS/G2mZODZgGmmlrgCydjUcV3YvxCm9x8nMAfThsqTni4KiXT4A==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.2.tgz", + "integrity": "sha512-+wvgpDsrB1YqAMdEUCcnTlpfVBH7Vqn6A/NT3D8WVXFIaKMlErPIZT3oCIAVCOtarRpMtelZLqJeU3t7WY6X6g==", "dev": true, "peer": true, "requires": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.4.0", + "espree": "^9.6.0", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -10250,9 +10362,9 @@ } }, "@eslint/js": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.35.0.tgz", - "integrity": "sha512-JXdzbRiWclLVoD8sNUjR443VVlYqiYmDVT6rGUEIEHU5YJW0gaVZwV2xgM7D4arkvASqD0IlLUVjHiFuxaftRw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.49.0.tgz", + "integrity": "sha512-1S8uAY/MTJqVx0SC4epBq+N2yhuwtNwLbJYNZyhL2pO1ZVKn5HFXav5T41Ryzy9K9V7ZId2JB2oy/W4aCd9/2w==", "dev": true, "peer": true }, @@ -10340,9 +10452,9 @@ } }, "@humanwhocodes/config-array": { - "version": "0.11.8", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.8.tgz", - "integrity": "sha512-UybHIJzJnR5Qc/MsD9Kr+RpO2h+/P1GhOwdiLPXK5TWk5sgTdu88bTD9UP+CKbPPh5Rni1u0GjAdYQLemG8g+g==", + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.11.tgz", + "integrity": "sha512-N2brEuAadi0CcdeMXUkhbZB84eskAc8MEX1By6qEchoVywSgXPIjou4rYsl0V3Hj0ZnuGycGCjdNgockbzeWNA==", "dev": true, "peer": true, "requires": { @@ -11203,6 +11315,15 @@ "@types/react": "^17" } }, + "@types/react-test-renderer": { + "version": "18.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.1.tgz", + "integrity": "sha512-LjEF+jTUCjzd+Qq4eWqsmZvEWPA/l4L0my+YWN5US8Fo3wZOMiyrpBshHDFbkO8usjdO1B430mEWNU/i1MF7Qg==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/scheduler": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz", @@ -11377,9 +11498,9 @@ } }, "acorn": { - "version": "8.8.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", - "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==" + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==" }, "acorn-jsx": { "version": "5.3.2", @@ -12292,27 +12413,28 @@ "peer": true }, "eslint": { - "version": "8.35.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.35.0.tgz", - "integrity": "sha512-BxAf1fVL7w+JLRQhWl2pzGeSiGqbWumV4WNvc9Rhp6tiCtm4oHnyPBSEtMGZwrQgudFQ+otqzWoPB7x+hxoWsw==", + "version": "8.49.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.49.0.tgz", + "integrity": "sha512-jw03ENfm6VJI0jA9U+8H5zfl5b+FvuU3YYvZRdZHOlU2ggJkxrlkJH4HcDrZpj6YwD8kuYqvQM8LyesoazrSOQ==", "dev": true, "peer": true, "requires": { - "@eslint/eslintrc": "^2.0.0", - "@eslint/js": "8.35.0", - "@humanwhocodes/config-array": "^0.11.8", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.2", + "@eslint/js": "8.49.0", + "@humanwhocodes/config-array": "^0.11.11", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", - "ajv": "^6.10.0", + "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -12320,30 +12442,26 @@ "find-up": "^5.0.0", "glob-parent": "^6.0.2", "globals": "^13.19.0", - "grapheme-splitter": "^1.0.4", + "graphemer": "^1.4.0", "ignore": "^5.2.0", - "import-fresh": "^3.0.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "is-path-inside": "^3.0.3", - "js-sdsl": "^4.1.4", "js-yaml": "^4.1.0", "json-stable-stringify-without-jsonify": "^1.0.1", "levn": "^0.4.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", - "optionator": "^0.9.1", - "regexpp": "^3.2.0", + "optionator": "^0.9.3", "strip-ansi": "^6.0.1", - "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" }, "dependencies": { "eslint-scope": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz", - "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", "dev": true, "peer": true, "requires": { @@ -12388,21 +12506,21 @@ } }, "eslint-visitor-keys": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz", - "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true }, "espree": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.4.1.tgz", - "integrity": "sha512-XwctdmTO6SIvCzd9810yyNzIrOrqNYV9Koizx4C/mRhf9uq0o4yHoCEU/670pOxOL/MSraektvSAji79kX90Vg==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", "dev": true, "peer": true, "requires": { - "acorn": "^8.8.0", + "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.1" } }, "esprima": { @@ -12661,13 +12779,14 @@ } }, "flat-cache": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz", - "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.1.0.tgz", + "integrity": "sha512-OHx4Qwrrt0E4jEIcI5/Xb+f+QmJYNj2rrK8wiIdQOIrB9WrrJL8cjZvXdXuBTkkEwEqLycb5BeZDV1o2i9bTew==", "dev": true, "peer": true, "requires": { - "flatted": "^3.1.0", + "flatted": "^3.2.7", + "keyv": "^4.5.3", "rimraf": "^3.0.2" } }, @@ -12776,9 +12895,9 @@ } }, "globals": { - "version": "13.20.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", - "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", + "version": "13.21.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.21.0.tgz", + "integrity": "sha512-ybyme3s4yy/t/3s35bewwXKOf7cvzfreG2lH0lZl0JB7I4GxRP2ghxOK/Nb9EkRXdbBXZLfq/p/0W2JUONB/Gg==", "dev": true, "peer": true, "requires": { @@ -12824,6 +12943,13 @@ "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "dev": true }, + "graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "peer": true + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -13864,13 +13990,6 @@ } } }, - "js-sdsl": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", - "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", - "dev": true, - "peer": true - }, "js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -13891,6 +14010,13 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==" }, + "json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "peer": true + }, "json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -13924,6 +14050,16 @@ "tsscmp": "1.0.6" } }, + "keyv": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.3.tgz", + "integrity": "sha512-QCiSav9WaX1PgETJ+SpNnx2PRRapJ/oRSXM4VO5OGYGSjrxbKPVFVhB3l2OCbLCk329N8qyAtsJjSjvVBWzEug==", + "dev": true, + "peer": true, + "requires": { + "json-buffer": "3.0.1" + } + }, "kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -14934,18 +15070,18 @@ } }, "optionator": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", - "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==", + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", + "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", "dev": true, "peer": true, "requires": { + "@aashutoshrathi/word-wrap": "^1.2.3", "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.3" + "type-check": "^0.4.0" } }, "p-limit": { @@ -15276,6 +15412,36 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==" }, + "react-shallow-renderer": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", + "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0" + } + }, + "react-test-renderer": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz", + "integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==", + "dev": true, + "requires": { + "object-assign": "^4.1.1", + "react-is": "^17.0.2", + "react-shallow-renderer": "^16.13.1", + "scheduler": "^0.20.2" + }, + "dependencies": { + "react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true + } + } + }, "readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -15680,9 +15846,9 @@ "dev": true }, "style-mod": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.0.0.tgz", - "integrity": "sha512-OPhtyEjyyN9x3nhPsu76f52yUGXiZcgvsrFVtvTkyGRQJ0XK+GPc6ov1z+lRpbeabka+MYEQxOYRnt5nF30aMw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.0.tgz", + "integrity": "sha512-Ca5ib8HrFn+f+0n4N4ScTIA9iTOQ7MaGS1ylHcoVqW9J7w2w8PzN6g9gKmTYgGEBH8e120+RCmhpje6jC5uGWA==", "peer": true }, "style-to-object": { @@ -16069,9 +16235,9 @@ } }, "w3c-keyname": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.6.tgz", - "integrity": "sha512-f+fciywl1SJEniZHD6H+kUO8gOnwIr7f4ijKA6+ZvJFjeGi1r4PDLl53Ayud9O/rk64RqgoQine0feoeOU0kXg==", + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "peer": true }, "walker": { @@ -16170,13 +16336,6 @@ } } }, - "word-wrap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz", - "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", - "dev": true, - "peer": true - }, "wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -16263,9 +16422,9 @@ "integrity": "sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==" }, "zod-fast-check": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/zod-fast-check/-/zod-fast-check-0.9.0.tgz", - "integrity": "sha512-7N56zNAO7HabbIETlCofd8e94ZRBulo9gx8qAC5AC+yTVo++WUKqi21fNyIWHtM46Xl0iTT7ucz0blMoCTz5jg==", + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/zod-fast-check/-/zod-fast-check-0.10.0.tgz", + "integrity": "sha512-3AjwS/s0jB1gkbgslmSSjI+MNoT8T9R+YiM9RruyB8mwZmiVfvA8UDfu7jkwzPwSHfyyqGGnH0UL2Hag7oj18w==", "dev": true, "requires": {} }, diff --git a/package.json b/package.json index e466f11..8dc0524 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,8 @@ "dev": "node esbuild.config.mjs", "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", "version": "node version-bump.mjs && git add manifest.json versions.json", - "lint": "prettier --check 'src/**/*.ts*'", - "fix-lint": "prettier --write 'src/**/*.ts*'", + "lint": "prettier --check ./src/**/*.ts*", + "fix-lint": "prettier --write ./src/**/*.ts*", "compile": "tsc -noEmit --skipLibCheck", "prepare": "husky install", "test": "jest --ci --silent=true", @@ -43,7 +43,7 @@ "ts-jest": "^29.0.3", "tslib": "2.3.1", "typescript": "4.4.4", - "zod-fast-check": "^0.9.0" + "zod-fast-check": "^0.10.0" }, "dependencies": { "@fullcalendar/core": "^5.10.1", diff --git a/src/calendars/FullNoteCalendar.test.ts b/src/calendars/FullNoteCalendar.test.ts index 85cbef4..7a36bc4 100644 --- a/src/calendars/FullNoteCalendar.test.ts +++ b/src/calendars/FullNoteCalendar.test.ts @@ -324,4 +324,116 @@ describe("Note Calendar Tests", () => { // .calls[0]; // expect(file.path).toBe(join("events", filename)); // }); + + it("creates an rrule event", async () => { + const obsidian = makeApp(MockAppBuilder.make().done()); + const calendar = new FullNoteCalendar(obsidian, color, dirName); + const event = { + type: "rrule", + title: "Test Event", + startDate: "2023-09-12", + rrule: "DTSTART:20230912T110000Z\nRRULE:FREQ=WEEKLY;COUNT=30;INTERVAL=1;BYDAY=TU", + skipDates: ["2023-09-19"], + allDay: false, + startTime: "11:00", + endTime: "12:30", + }; + + (obsidian.create as jest.Mock).mockReturnValue({ + path: join(dirName, "2022-01-01 Test Event.md"), + }); + const { lineNumber } = await calendar.createEvent(parseEvent(event)); + expect(lineNumber).toBeUndefined(); + expect(obsidian.create).toHaveBeenCalledTimes(1); + const returns = (obsidian.create as jest.Mock).mock.calls[0]; + console.warn(returns); + expect(returns).toMatchInlineSnapshot(` + [ + "events/(every week on Tuesday for 30 times) Test Event.md", + "--- + title: Test Event + allDay: false + startTime: 11:00 + endTime: 12:30 + type: rrule + startDate: 2023-09-12 + rrule: |- + DTSTART:20230912T110000Z + RRULE:FREQ=WEEKLY;COUNT=30;INTERVAL=1;BYDAY=TU + skipDates: [2023-09-19] + --- + ", + ] + `); + }); + it("modifies an rrule event", async () => { + const rawEvent = { + type: "rrule", + title: "Test Event", + startDate: "2023-09-12", + rrule: "DTSTART:20230912T110000Z\nRRULE:FREQ=WEEKLY;COUNT=30;INTERVAL=1;BYDAY=TU", + skipDates: ["2023-09-19"], + allDay: false, + startTime: "11:00", + endTime: "12:30", + }; + const event = parseEvent(rawEvent); + const filename = "(every week on Tuesday for 30 times) Test Event.md"; + const obsidian = makeApp( + MockAppBuilder.make() + .folder( + new MockAppBuilder("events").file( + filename, + new FileBuilder().frontmatter(event) + ) + ) + .done() + ); + const calendar = new FullNoteCalendar(obsidian, color, dirName); + + const firstFile = obsidian.getAbstractFileByPath( + join("events", filename) + ) as TFile; + + const contents = await obsidian.read(firstFile); + + const mockFn = jest.fn(); + await calendar.modifyEvent( + { path: join("events", filename), lineNumber: undefined }, + // @ts-ignore + parseEvent({ + ...rawEvent, + rrule: "DTSTART:20230912T110000Z\nRRULE:FREQ=MONTHLY;COUNT=5;INTERVAL=2;BYDAY=TU;BYSETPOS=1", + }), + mockFn + ); + const newFilename = + "events/(every 2 months on Tuesday for 5 times) Test Event.md"; + // TODO: make the third param a mock that we can inspect + const newLoc = mockFn.mock.calls[0][0]; + expect(newLoc.file.path).toBe(newFilename); + expect(newLoc.lineNumber).toBeUndefined(); + + expect(obsidian.rewrite).toHaveReturnedTimes(1); + const [file, rewriteCallback] = (obsidian.rewrite as jest.Mock).mock + .calls[0]; + expect(file.path).toBe(join("events", filename)); + + expect(rewriteCallback(contents)).toMatchInlineSnapshot(` + "--- + title: Test Event + allDay: false + startTime: 11:00 + endTime: 12:30 + type: rrule + startDate: 2023-09-12 + rrule: |- + DTSTART:20230912T110000Z + RRULE:FREQ=MONTHLY;COUNT=5;INTERVAL=2;BYDAY=TU;BYSETPOS=1 + RRULE:FREQ=WEEKLY;COUNT=30;INTERVAL=1;BYDAY=TU + skipDates: [2023-09-19] + --- + " + `); + }); }); diff --git a/src/calendars/FullNoteCalendar.ts b/src/calendars/FullNoteCalendar.ts index c41e014..b06f65d 100644 --- a/src/calendars/FullNoteCalendar.ts +++ b/src/calendars/FullNoteCalendar.ts @@ -80,7 +80,17 @@ function stringifyYamlLine( k: string | number | symbol, v: PrintableAtom ): string { - return `${String(k)}: ${stringifyYamlAtom(v)}`; + let stringifiedAtom: string; + if (k === "rrule" && typeof v === "string") { + stringifiedAtom = "|-"; + + const indentation = "\n "; + const replacedValue = v.replace("\n", indentation); + stringifiedAtom += `${indentation}${replacedValue}`; + } else { + stringifiedAtom = stringifyYamlAtom(v); + } + return `${String(k)}: ${stringifiedAtom}`; } function newFrontmatter(fields: Partial): string { @@ -109,7 +119,18 @@ function modifyFrontmatterString( const linesAdded: Set = new Set(); // Modify rows in-place. for (let i = 0; i < frontmatter.length; i++) { - const line: string = frontmatter[i]; + let line: string = frontmatter[i]; + if (line.endsWith("|-")) { + let j = i + 1; + while ( + j < frontmatter.length && + frontmatter[j].startsWith(" ") + ) { + line += `\n${frontmatter[j]}`; + i++; + j++; + } + } const obj: Record | null = parseYaml(line); if (!obj) { continue; diff --git a/src/main.ts b/src/main.ts index db16e9c..558eda4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -22,41 +22,47 @@ import CalDAVCalendar from "./calendars/CalDAVCalendar"; export default class FullCalendarPlugin extends Plugin { settings: FullCalendarSettings = DEFAULT_SETTINGS; - cache: EventCache = new EventCache({ - local: (info) => - info.type === "local" - ? new FullNoteCalendar( - new ObsidianIO(this.app), - info.color, - info.directory - ) - : null, - dailynote: (info) => - info.type === "dailynote" - ? new DailyNoteCalendar( - new ObsidianIO(this.app), - info.color, - info.heading - ) - : null, - ical: (info) => - info.type === "ical" ? new ICSCalendar(info.color, info.url) : null, - caldav: (info) => - info.type === "caldav" - ? new CalDAVCalendar( - info.color, - info.name, - { - type: "basic", - username: info.username, - password: info.password, - }, - info.url, - info.homeUrl - ) - : null, - FOR_TEST_ONLY: () => null, - }); + cache: EventCache = (() => { + console.debug("Event Cache Creation"); + + return new EventCache({ + local: (info) => + info.type === "local" + ? new FullNoteCalendar( + new ObsidianIO(this.app), + info.color, + info.directory + ) + : null, + dailynote: (info) => + info.type === "dailynote" + ? new DailyNoteCalendar( + new ObsidianIO(this.app), + info.color, + info.heading + ) + : null, + ical: (info) => + info.type === "ical" + ? new ICSCalendar(info.color, info.url) + : null, + caldav: (info) => + info.type === "caldav" + ? new CalDAVCalendar( + info.color, + info.name, + { + type: "basic", + username: info.username, + password: info.password, + }, + info.url, + info.homeUrl + ) + : null, + FOR_TEST_ONLY: () => null, + }); + })(); renderCalendar = renderCalendar; processFrontmatter = toEventInput; @@ -78,6 +84,7 @@ export default class FullCalendarPlugin extends Plugin { } } async onload() { + console.debug("On Load called..."); await this.loadSettings(); this.cache.reset(this.settings.calendarSources); diff --git a/src/ui/components/EditEvent.tsx b/src/ui/components/EditEvent.tsx index 773a243..0af7008 100644 --- a/src/ui/components/EditEvent.tsx +++ b/src/ui/components/EditEvent.tsx @@ -2,6 +2,8 @@ import { DateTime } from "luxon"; import * as React from "react"; import { useEffect, useRef, useState } from "react"; import { CalendarInfo, OFCEvent } from "../../types"; +import { RRule, rrulestr } from "rrule"; +import { EditEventRecurrence } from "./EditEventRecurrence"; function makeChangeListener( setState: React.Dispatch>, @@ -10,69 +12,14 @@ function makeChangeListener( return (e) => setState(fromString(e.target.value)); } -interface DayChoiceProps { - code: string; - label: string; - isSelected: boolean; - onClick: (code: string) => void; -} -const DayChoice = ({ code, label, isSelected, onClick }: DayChoiceProps) => ( - -); - const DAY_MAP = { - U: "Sunday", - M: "Monday", - T: "Tuesday", - W: "Wednesday", - R: "Thursday", - F: "Friday", - S: "Saturday", -}; - -const DaySelect = ({ - value: days, - onChange, -}: { - value: string[]; - onChange: (days: string[]) => void; -}) => { - return ( -
- {Object.entries(DAY_MAP).map(([code, label]) => ( - - days.includes(code) - ? onChange(days.filter((c) => c !== code)) - : onChange([code, ...days]) - } - /> - ))} -
- ); + U: RRule.SU.weekday, + M: RRule.MO.weekday, + T: RRule.TU.weekday, + W: RRule.WE.weekday, + R: RRule.TH.weekday, + F: RRule.FR.weekday, + S: RRule.SA.weekday, }; interface EditEventProps { @@ -96,17 +43,17 @@ export const EditEvent = ({ calendars, defaultCalendarIndex, }: EditEventProps) => { - const [date, setDate] = useState( - initialEvent - ? initialEvent.type === "single" - ? initialEvent.date - : initialEvent.type === "recurring" - ? initialEvent.startRecur - : initialEvent.type === "rrule" - ? initialEvent.startDate - : "" + const initialDate = initialEvent + ? initialEvent.type === "single" + ? initialEvent.date + : initialEvent.type === "recurring" + ? initialEvent.startRecur + : initialEvent.type === "rrule" + ? initialEvent.startDate : "" - ); + : ""; + + const [date, setDate] = useState(initialDate); const [endDate, setEndDate] = useState( initialEvent && initialEvent.type === "single" ? initialEvent.endDate @@ -125,14 +72,44 @@ export const EditEvent = ({ const [startTime, setStartTime] = useState(initialStartTime); const [endTime, setEndTime] = useState(initialEndTime); const [title, setTitle] = useState(initialEvent?.title || ""); + // const [isRecurring, setIsRecurring] = useState( + // initialEvent?.type === "recurring" || false + // ); + const [isRecurring, setIsRecurring] = useState( - initialEvent?.type === "recurring" || false + initialEvent?.type === "rrule" || + initialEvent?.type === "recurring" || + false ); - const [endRecur, setEndRecur] = useState(""); - const [daysOfWeek, setDaysOfWeek] = useState( - (initialEvent?.type === "recurring" ? initialEvent.daysOfWeek : []) || - [] + const parsedDate = initialDate + ? DateTime.fromFormat(initialDate, "yyyy-MM-dd") + : DateTime.now(); + console.debug("parsedDate:", parsedDate); + + const [recurringRule, setRecurringRule] = useState( + (initialEvent?.type === "rrule" && + initialEvent?.rrule && + rrulestr(initialEvent.rrule)) || + new RRule({ + freq: RRule.WEEKLY, + interval: 1, + dtstart: DateTime.fromObject({ + ...parsedDate.toObject(), + hour: Number(startTime.slice(0, 2)), + minute: Number(startTime.slice(3)), + }).toJSDate(), + byweekday: + initialEvent?.type === "recurring" + ? initialEvent.daysOfWeek?.map( + (value) => DAY_MAP[value] + ) + : [parsedDate.weekday - 1], + until: + initialEvent?.type === "recurring" && initialEvent.endRecur + ? DateTime.fromISO(initialEvent.endRecur).toJSDate() + : undefined, + }) ); const [allDay, setAllDay] = useState(initialEvent?.allDay || false); @@ -170,18 +147,12 @@ export const EditEvent = ({ : { allDay: false, startTime: startTime || "", endTime }), ...(isRecurring ? { - type: "recurring", - daysOfWeek: daysOfWeek as ( - | "U" - | "M" - | "T" - | "W" - | "R" - | "F" - | "S" - )[], - startRecur: date || undefined, - endRecur: endRecur || undefined, + type: "rrule", + rrule: recurringRule.toString(), + startDate: DateTime.fromJSDate( + recurringRule.options.dtstart ?? date + ).toISODate(), + skipDates: [], } : { type: "single", @@ -203,169 +174,160 @@ export const EditEvent = ({
-

- x)} - /> -

-

- -

-

- {!isRecurring && ( +

+

x)} - /> - )} - - {allDay ? ( - <> - ) : ( - <> - x - )} - /> - - - x - )} - /> - - )} -

-

- - setAllDay(e.target.checked)} - type="checkbox" - /> -

-

- - setIsRecurring(e.target.checked)} - type="checkbox" - /> -

- - {isRecurring && ( - <> - x)} /> -

- Starts recurring +

+

+ +

+

+ {!isRecurring && ( x)} /> - and stops recurring - x - )} - /> -

- - )} -

- - { - setIsTask(e.target.checked); - }} - type="checkbox" - /> -

+ )} - {isTask && ( - <> - + {allDay ? ( + <> + ) : ( + <> + x + )} + /> + - + x + )} + /> + + )} +

+

+ - setComplete( - e.target.checked - ? DateTime.now().toISO() - : false - ) - } + id="allDay" + checked={allDay} + onChange={(e) => setAllDay(e.target.checked)} + type="checkbox" + /> +

+ +

+ + { + setIsRecurring(e.target.checked); + }} type="checkbox" /> - - )} +

+ + {isRecurring && ( + + )} + +

+ + { + setIsTask(e.target.checked); + }} + type="checkbox" + /> +

+ + {isTask && ( + <> + + + setComplete( + e.target.checked + ? DateTime.now().toISO() + : false + ) + } + type="checkbox" + /> + + )} +

void; +} + +const ALL_FREQUENCIES = [ + RRule.DAILY, + RRule.WEEKLY, + RRule.MONTHLY, + RRule.YEARLY, +]; + +const FREQ_MAP: { [key: number]: string } = { + [RRule.DAILY]: "Day(s)", + [RRule.WEEKLY]: "Week(s)", + [RRule.MONTHLY]: "Month(s)", + [RRule.YEARLY]: "Year(s)", +}; + +const convertWeekday = (day: ByWeekday) => { + if (typeof day === "string") { + return Weekday.fromStr(day).weekday; + } + + if (Number.isNumber(day)) { + return day; + } + + return day.weekday; +}; + +export const EditEventRecurrence = ({ + recurrence, + startTime, + startDate, + onChange, +}: EditEventRecurrenceProps) => { + const splitStartTime = (startTime ?? "00:00").split(":"); + const startHour = splitStartTime[0]; + const startMinute = splitStartTime[1]; + + const currentDate = DateTime.now(); + + const defaultStartDate = DateTime.fromJSDate( + startDate + ? datetime( + Number(startDate.slice(0, 4)), + Number(startDate.slice(5, 7)), + Number(startDate.slice(8)) + ) + : datetime(currentDate.year, currentDate.month, currentDate.day) + ).toUTC(); + + const options = + recurrence?.origOptions && + "freq" in recurrence.origOptions && + "interval" in recurrence.origOptions + ? recurrence.origOptions + : ({ + freq: RRule.WEEKLY, + interval: 1, + dtstart: defaultStartDate.toJSDate(), + byweekday: [defaultStartDate.weekday - 1], + } as Options); + + console.debug("original:", recurrence?.origOptions); + + const currentStartDate = options.dtstart + ? DateTime.fromJSDate(options.dtstart).toUTC() + : defaultStartDate; + + const [defaultInterval, setDefaultInterval] = useState( + options.interval ?? 1 + ); + const [defaultEndCount, setDefaultEndCount] = useState(options.count ?? 1); + const [defaultEndDate, setDefaultEndDate] = useState( + options.until ?? + options.dtstart ?? + DateTime.fromISO(startDate ?? DateTime.now().toISODate()) + .toUTC() + .toJSDate() + ); + + let defaultExtraProps: Partial = {}; + if ((options.freq ?? RRule.WEEKLY) in RECURRENCE_INFO_MAP) { + const recurrenceInfo = + RECURRENCE_INFO_MAP[options.freq ?? RRule.MONTHLY]; + + const currentRecurrenceInfo = recurrenceInfo.find((info) => + info.hasProps(options) + ); + + if (currentRecurrenceInfo) { + defaultExtraProps = currentRecurrenceInfo.filterProps(options); + } + } + + const [currentExtraProps, setCurrentExtraProps] = + useState(defaultExtraProps); + + const [endType, setEndType] = useState( + recurrence?.options.until + ? "endDate" + : recurrence?.options.count + ? "endCount" + : "endNever" + ); + + const currentEndDate = DateTime.fromJSDate( + options.until ?? defaultEndDate ?? options.dtstart ?? new Date() + ); + + const handleChange = useCallback( + ( + updatedOptions: Partial, + includeExtra: boolean = true, + updatedEndType?: string + ) => { + const freq = updatedOptions.freq ?? options.freq; + const interval = options.interval; + const dtstart = options.dtstart; + let otherOptions: Partial = {}; + + console.debug("Other:", otherOptions); + console.debug("Updated:", updatedOptions); + console.debug("Include Extra", includeExtra); + + if ((updatedEndType ?? endType) === "endDate") { + otherOptions.until = currentEndDate.toJSDate(); + } else if ((updatedEndType ?? endType) === "endCount") { + otherOptions.count = updatedOptions.count ?? options.count; + } + + if (freq === RRule.WEEKLY) { + otherOptions.byweekday = + updatedOptions.byweekday ?? options.byweekday; + } + + if (includeExtra) { + otherOptions = { + ...otherOptions, + ...currentExtraProps, + }; + } + + const newProps = { + freq, + interval, + dtstart, + ...otherOptions, + ...updatedOptions, + }; + + console.debug("newProps", newProps); + + onChange?.(new RRule(newProps)); + }, + [onChange, options, endType, currentEndDate, currentExtraProps] + ); + + return ( + <> +

+ Every + { + const interval = Number(element.target.value); + setDefaultInterval(interval); + handleChange({ + interval, + }); + }} + /> + +

+ {options.freq === RRule.WEEKLY && ( + { + handleChange({ + byweekday: days, + }); + }} + /> + )} + {(options.freq === RRule.MONTHLY || + options.freq === RRule.YEARLY) && ( + { + setCurrentExtraProps(options); + handleChange(options, false); + }} + /> + )} +

+ + { + handleChange({ + dtstart: DateTime.fromISO(element.target.value) + .toUTC() + .set({ + hour: Number(startHour), + minute: Number(startMinute), + second: 0, + }) + .toJSDate(), + }); + }} + /> +

+

Ending:

+

+ { + setEndType("endNever"); + handleChange({}, true, "endNever"); + }} + /> + +

+

+ { + setEndType("endDate"); + handleChange({}); + }} + /> + + { + const value = DateTime.fromISO( + element.target.value + ).toUTC(); + setDefaultEndDate(value.toJSDate()); + handleChange( + { + until: value.toJSDate(), + }, + true, + "endDate" + ); + }} + /> +

+

+ { + setEndType("endCount"); + handleChange({}, true, "endCount"); + }} + /> + + { + const value = Number(element.target.value); + setDefaultEndCount(value); + handleChange({ + count: value, + }); + }} + className="fc-edit-control" + disabled={endType !== "endCount"} + /> + +

+ + ); +}; + +interface DayChoiceProps { + code: number; + label: string; + isSelected: boolean; + onClick: (code: number) => void; +} +const DayChoice = ({ code, label, isSelected, onClick }: DayChoiceProps) => ( + +); + +const DAYS_OF_WEEK = [ + RRule.SU, + RRule.MO, + RRule.TU, + RRule.WE, + RRule.TH, + RRule.FR, + RRule.SA, +]; + +const DaySelect = ({ + value, + onChange, +}: { + value: number[]; + onChange: (days: number[]) => void; +}) => { + return ( +
+ {DAYS_OF_WEEK.map((day) => { + const code = day.weekday; + return ( + { + console.debug("Day Switched:", value, code); + value.includes(code) + ? onChange(value.filter((c) => c !== code)) + : onChange([code, ...value]); + }} + /> + ); + })} +
+ ); +}; + +interface MonthSelectProps { + startDate: DateTime; + options: Partial; + onChange: (options: Partial) => void; +} + +const MonthYearSelect = ({ + startDate, + options, + onChange, +}: MonthSelectProps) => { + const dateStats = getDateStats(startDate); + + const recurrenceInfo = RECURRENCE_INFO_MAP[options.freq ?? RRule.MONTHLY]; + + const currentRecurrenceInfo = + recurrenceInfo.find((info) => info.hasProps(options)) ?? + recurrenceInfo[0]; + + return ( +

+ +

+ ); +}; diff --git a/src/ui/components/event-recurrence-tests/format-ordinal-number.test.ts b/src/ui/components/event-recurrence-tests/format-ordinal-number.test.ts new file mode 100644 index 0000000..b92c0a2 --- /dev/null +++ b/src/ui/components/event-recurrence-tests/format-ordinal-number.test.ts @@ -0,0 +1,221 @@ +import { formatOrdinalNumber } from "../event-recurrence-types"; + +describe("formatOrdinalNumber", () => { + describe("when value ends in 1", () => { + describe("when the tens place is zero", () => { + describe("when the value is 1", () => { + it("should end in st", () => { + const result = formatOrdinalNumber(1); + + expect(result).toBe("1st"); + }); + }); + + describe("when the value is 101", () => { + it("should end in st", () => { + const result = formatOrdinalNumber(101); + + expect(result).toBe("101st"); + }); + }); + }); + + describe("when the tens place is one", () => { + describe("when the value is 11", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(11); + + expect(result).toBe("11th"); + }); + }); + + describe("when the value is 111", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(111); + + expect(result).toBe("111th"); + }); + }); + }); + + describe("when the tens place is two", () => { + describe("when the value is 21", () => { + it("should end in st", () => { + const result = formatOrdinalNumber(21); + + expect(result).toBe("21st"); + }); + }); + + describe("when the value is 121", () => { + it("should end in st", () => { + const result = formatOrdinalNumber(121); + + expect(result).toBe("121st"); + }); + }); + }); + }); + + describe("when value ends in 2", () => { + describe("when the tens place is zero", () => { + describe("when the value is 2", () => { + it("should end in nd", () => { + const result = formatOrdinalNumber(2); + + expect(result).toBe("2nd"); + }); + }); + + describe("when the value is 102", () => { + it("should end in nd", () => { + const result = formatOrdinalNumber(102); + + expect(result).toBe("102nd"); + }); + }); + }); + + describe("when the tens place is one", () => { + describe("when the value is 12", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(12); + + expect(result).toBe("12th"); + }); + }); + + describe("when the value is 112", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(112); + + expect(result).toBe("112th"); + }); + }); + }); + + describe("when the tens place is two", () => { + describe("when the value is 22", () => { + it("should end in nd", () => { + const result = formatOrdinalNumber(22); + + expect(result).toBe("22nd"); + }); + }); + + describe("when the value is 122", () => { + it("should end in nd", () => { + const result = formatOrdinalNumber(122); + + expect(result).toBe("122nd"); + }); + }); + }); + }); + + describe("when value ends in 3", () => { + describe("when the tens place is zero", () => { + describe("when the value is 3", () => { + it("should end in rd", () => { + const result = formatOrdinalNumber(3); + + expect(result).toBe("3rd"); + }); + }); + + describe("when the value is 103", () => { + it("should end in rd", () => { + const result = formatOrdinalNumber(103); + + expect(result).toBe("103rd"); + }); + }); + }); + + describe("when the tens place is one", () => { + describe("when the value is 13", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(13); + + expect(result).toBe("13th"); + }); + }); + + describe("when the value is 113", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(113); + + expect(result).toBe("113th"); + }); + }); + }); + + describe("when the tens place is two", () => { + describe("when the value is 23", () => { + it("should end in rd", () => { + const result = formatOrdinalNumber(23); + + expect(result).toBe("23rd"); + }); + }); + + describe("when the value is 123", () => { + it("should end in rd", () => { + const result = formatOrdinalNumber(123); + + expect(result).toBe("123rd"); + }); + }); + }); + }); + + describe("when the value does not end in 1, 2 or 3", () => { + describe("when the value is 7", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(7); + + expect(result).toBe("7th"); + }); + }); + + describe("when the value is 17", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(17); + + expect(result).toBe("17th"); + }); + }); + + describe("when the value is 27", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(27); + + expect(result).toBe("27th"); + }); + }); + + describe("when the value is 107", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(107); + + expect(result).toBe("107th"); + }); + }); + + describe("when the value is 117", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(117); + + expect(result).toBe("117th"); + }); + }); + + describe("when the value is 127", () => { + it("should end in th", () => { + const result = formatOrdinalNumber(127); + + expect(result).toBe("127th"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/month-day-before-end-of-month.test.ts b/src/ui/components/event-recurrence-tests/month-day-before-end-of-month.test.ts new file mode 100644 index 0000000..48b47ef --- /dev/null +++ b/src/ui/components/event-recurrence-tests/month-day-before-end-of-month.test.ts @@ -0,0 +1,120 @@ +import { DateTime } from "luxon"; +import { + MONTH_RECURRENCE_INFO, + MonthYearRecurrenceType, + getDateStats, +} from "../event-recurrence-types"; +import { defaultOptions } from "./test-helpers"; + +describe("Monthly Recurrence -> dayBeforeEndOfMonth", () => { + const currentInfo = + MONTH_RECURRENCE_INFO.find( + (info) => + info.recurrenceType === + MonthYearRecurrenceType.dayBeforeEndOfMonth + ) ?? MONTH_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when day is an array with a positive number", () => { + it("should return false", () => { + const options = { + bymonthday: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is an array with a negative number", () => { + it("should return true", () => { + const options = { + bymonthday: [-1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is a positive number", () => { + it("should return false", () => { + const options = { + bymonthday: 1, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is a negative number", () => { + it("should return true", () => { + const options = { + bymonthday: -1, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is undefined", () => { + it("should return false", () => { + const options = {}; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is null", () => { + it("should return false", () => { + const options = { + bymonthday: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the dateStats daysBeforeEnd in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bymonthday).toMatchObject([-30]); + }); + }); + + describe("filterProps", () => { + it("should only return the bymonthday option in the options", () => { + const result = currentInfo.filterProps(defaultOptions); + + expect(Object.keys(result)).toMatchObject(["bymonthday"]); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2023-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 19th to last day of the month"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/month-day-of-month.test.ts b/src/ui/components/event-recurrence-tests/month-day-of-month.test.ts new file mode 100644 index 0000000..dd5a97e --- /dev/null +++ b/src/ui/components/event-recurrence-tests/month-day-of-month.test.ts @@ -0,0 +1,118 @@ +import { + MONTH_RECURRENCE_INFO, + MonthYearRecurrenceType, + getDateStats, +} from "../event-recurrence-types"; +import { DateTime } from "luxon"; +import { defaultOptions } from "./test-helpers"; + +describe("Monthly Recurrence -> dayOfMonth", () => { + const currentInfo = + MONTH_RECURRENCE_INFO.find( + (info) => info.recurrenceType === MonthYearRecurrenceType.dayOfMonth + ) ?? MONTH_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when day is an array with a positive number", () => { + it("should return true", () => { + const options = { + bymonthday: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is an array with a negative number", () => { + it("should return false", () => { + const options = { + bymonthday: [-1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is a positive number", () => { + it("should return true", () => { + const options = { + bymonthday: 1, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is a negative number", () => { + it("should return false", () => { + const options = { + bymonthday: -1, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is undefined", () => { + it("should return false", () => { + const options = {}; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is null", () => { + it("should return false", () => { + const options = { + bymonthday: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the dateStats month day in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bymonthday).toMatchObject([1]); + }); + }); + + describe("filterProps", () => { + it("should only return the bymonthday option in the options", () => { + const result = currentInfo.filterProps(defaultOptions); + + expect(Object.keys(result)).toMatchObject(["bymonthday"]); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2023-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 12th day of the month"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/month-weekday-before-end-of-month.test.ts b/src/ui/components/event-recurrence-tests/month-weekday-before-end-of-month.test.ts new file mode 100644 index 0000000..0e0f21e --- /dev/null +++ b/src/ui/components/event-recurrence-tests/month-weekday-before-end-of-month.test.ts @@ -0,0 +1,161 @@ +import { DateTime } from "luxon"; +import { + MONTH_RECURRENCE_INFO, + MonthYearRecurrenceType, + getDateStats, +} from "../event-recurrence-types"; +import { RRule } from "rrule"; +import { defaultOptions } from "./test-helpers"; + +describe("Monthly Recurrence -> weekdayBeforeEndOfMonth", () => { + const currentInfo = + MONTH_RECURRENCE_INFO.find( + (info) => + info.recurrenceType === + MonthYearRecurrenceType.weekdayBeforeEndOfMonth + ) ?? MONTH_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when bysetpos is an array with a positive number", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is an array with a negative number", () => { + it("should return true", () => { + const options = { + bysetpos: [-1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is a positive number", () => { + it("should return false", () => { + const options = { + bysetpos: 1, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is a negative number", () => { + it("should return true", () => { + const options = { + bysetpos: -1, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is undefined", () => { + it("should return false", () => { + const options = { + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is null", () => { + it("should return false", () => { + const options = { + bysetpos: null, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is undefined", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is null", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the weekdaysFromMonthEnd and weekday in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bysetpos).toMatchObject([ + dateStats.weekdaysFromMonthEnd * -1, + ]); + + expect(result.byweekday).toMatchObject([dateStats.weekday]); + }); + }); + + describe("filterProps", () => { + it("should only return the two expected keys in the options", () => { + const result = currentInfo.filterProps(defaultOptions); + + const resultKeys = Object.keys(result); + + expect(resultKeys).toHaveLength(2); + expect(resultKeys).toContain("bysetpos"); + expect(resultKeys).toContain("byweekday"); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2023-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 3rd to last Tuesday of the month"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/month-weekday-in-month.test.ts b/src/ui/components/event-recurrence-tests/month-weekday-in-month.test.ts new file mode 100644 index 0000000..7bdd676 --- /dev/null +++ b/src/ui/components/event-recurrence-tests/month-weekday-in-month.test.ts @@ -0,0 +1,158 @@ +import { DateTime } from "luxon"; +import { + MONTH_RECURRENCE_INFO, + MonthYearRecurrenceType, + getDateStats, +} from "../event-recurrence-types"; +import { RRule } from "rrule"; +import { defaultOptions } from "./test-helpers"; + +describe("Monthly Recurrence -> weekdayInMonth", () => { + const currentInfo = + MONTH_RECURRENCE_INFO.find( + (info) => + info.recurrenceType === MonthYearRecurrenceType.weekdayInMonth + ) ?? MONTH_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when bysetpos is an array with a positive number", () => { + it("should return true", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is an array with a negative number", () => { + it("should return false", () => { + const options = { + bysetpos: [-1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is a positive number", () => { + it("should return true", () => { + const options = { + bysetpos: 1, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is a negative number", () => { + it("should return false", () => { + const options = { + bysetpos: -1, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is undefined", () => { + it("should return false", () => { + const options = { + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is null", () => { + it("should return false", () => { + const options = { + bysetpos: null, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is undefined", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is null", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the weekdayInMonth and weekday in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bysetpos).toMatchObject([dateStats.weekdayInMonth]); + + expect(result.byweekday).toMatchObject([dateStats.weekday]); + }); + }); + + describe("filterProps", () => { + it("should only return the two expected keys in the options", () => { + const result = currentInfo.filterProps(defaultOptions); + + const resultKeys = Object.keys(result); + + expect(resultKeys).toHaveLength(2); + expect(resultKeys).toContain("bysetpos"); + expect(resultKeys).toContain("byweekday"); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2023-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 2nd Tuesday of the month"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/test-helpers.ts b/src/ui/components/event-recurrence-tests/test-helpers.ts new file mode 100644 index 0000000..2e710e7 --- /dev/null +++ b/src/ui/components/event-recurrence-tests/test-helpers.ts @@ -0,0 +1,23 @@ +import { Options, RRule } from "rrule"; + +export const defaultOptions: Partial = { + freq: RRule.DAILY, + dtstart: new Date(), + interval: 1, + wkst: RRule.SU, + count: 1, + until: new Date(), + tzid: "UTC", + bysetpos: [1], + bymonth: [1], + bymonthday: [2], + bynmonthday: [3], + byyearday: [20], + byweekno: [1], + byweekday: [RRule.WE], + bynweekday: [[1]], + byhour: [2], + byminute: [42], + bysecond: [21], + byeaster: 1, +}; diff --git a/src/ui/components/event-recurrence-tests/year-day-before-end-of-month.test.ts b/src/ui/components/event-recurrence-tests/year-day-before-end-of-month.test.ts new file mode 100644 index 0000000..523eebe --- /dev/null +++ b/src/ui/components/event-recurrence-tests/year-day-before-end-of-month.test.ts @@ -0,0 +1,157 @@ +import { DateTime } from "luxon"; +import { + MonthYearRecurrenceType, + YEAR_RECURRENCE_INFO, + getDateStats, +} from "../event-recurrence-types"; +import { defaultOptions } from "./test-helpers"; + +describe("Yearly Recurrence -> dayBeforeEndOfMonth", () => { + const currentInfo = + YEAR_RECURRENCE_INFO.find( + (info) => + info.recurrenceType === + MonthYearRecurrenceType.dayBeforeEndOfMonth + ) ?? YEAR_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when day is an array with a positive number", () => { + it("should return false", () => { + const options = { + bymonthday: [1], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is an array with a negative number", () => { + it("should return true", () => { + const options = { + bymonthday: [-1], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is a positive number", () => { + it("should return false", () => { + const options = { + bymonthday: 1, + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is a negative number", () => { + it("should return true", () => { + const options = { + bymonthday: -1, + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is undefined", () => { + it("should return false", () => { + const options = { + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is null", () => { + it("should return false", () => { + const options = { + bymonthday: null, + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when month is undefined", () => { + it("should return false", () => { + const options = { + bymonthday: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when month is null", () => { + it("should return false", () => { + const options = { + bymonthday: [1], + bymonth: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the dateStats days until end of month and month in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bymonthday).toMatchObject([-30]); + expect(result.bymonth).toMatchObject([9]); + }); + }); + + describe("filterProps", () => { + it("should return an options with the two expected properties", () => { + const result = currentInfo.filterProps(defaultOptions); + + const resultKeys = Object.keys(result); + + expect(resultKeys).toHaveLength(2); + expect(resultKeys).toContain("bymonthday"); + expect(resultKeys).toContain("bymonth"); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2023-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 19th to last day of September"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/year-day-before-end-of-year.test.ts b/src/ui/components/event-recurrence-tests/year-day-before-end-of-year.test.ts new file mode 100644 index 0000000..4f6399b --- /dev/null +++ b/src/ui/components/event-recurrence-tests/year-day-before-end-of-year.test.ts @@ -0,0 +1,120 @@ +import { DateTime } from "luxon"; +import { + MonthYearRecurrenceType, + YEAR_RECURRENCE_INFO, + getDateStats, +} from "../event-recurrence-types"; +import { defaultOptions } from "./test-helpers"; + +describe("Yearly Recurrence -> dayBeforeEndOfYear", () => { + const currentInfo = + YEAR_RECURRENCE_INFO.find( + (info) => + info.recurrenceType === + MonthYearRecurrenceType.dayBeforeEndOfYear + ) ?? YEAR_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2024-01-01")); + + describe("hasProps", () => { + describe("when day is an array with a positive number", () => { + it("should return false", () => { + const options = { + byyearday: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is an array with a negative number", () => { + it("should return true", () => { + const options = { + byyearday: [-1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is a positive number", () => { + it("should return false", () => { + const options = { + byyearday: 1, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is a negative number", () => { + it("should return true", () => { + const options = { + byyearday: -1, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is undefined", () => { + it("should return false", () => { + const options = {}; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is null", () => { + it("should return false", () => { + const options = { + byyearday: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the dateStats days until end of year in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.byyearday).toMatchObject([-366]); + }); + }); + + describe("filterProps", () => { + it("should return an options with the expected property", () => { + const result = currentInfo.filterProps(defaultOptions); + + expect(Object.keys(result)).toMatchObject(["byyearday"]); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2024-01-01") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 366th to last day of the year"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/year-day-of-month.test.ts b/src/ui/components/event-recurrence-tests/year-day-of-month.test.ts new file mode 100644 index 0000000..d940b69 --- /dev/null +++ b/src/ui/components/event-recurrence-tests/year-day-of-month.test.ts @@ -0,0 +1,156 @@ +import { DateTime } from "luxon"; +import { + MonthYearRecurrenceType, + YEAR_RECURRENCE_INFO, + getDateStats, +} from "../event-recurrence-types"; +import { defaultOptions } from "./test-helpers"; + +describe("Yearly Recurrence -> dayOfMonth", () => { + const currentInfo = + YEAR_RECURRENCE_INFO.find( + (info) => info.recurrenceType === MonthYearRecurrenceType.dayOfMonth + ) ?? YEAR_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when day is an array with a positive number", () => { + it("should return true", () => { + const options = { + bymonthday: [1], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is an array with a negative number", () => { + it("should return false", () => { + const options = { + bymonthday: [-1], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is a positive number", () => { + it("should return true", () => { + const options = { + bymonthday: 1, + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is a negative number", () => { + it("should return false", () => { + const options = { + bymonthday: -1, + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is undefined", () => { + it("should return false", () => { + const options = { + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is null", () => { + it("should return false", () => { + const options = { + bymonthday: null, + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when month is undefined", () => { + it("should return false", () => { + const options = { + bymonthday: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when month is null", () => { + it("should return false", () => { + const options = { + bymonthday: [1], + bymonth: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the dateStats month day and month in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bymonthday).toMatchObject([1]); + + expect(result.bymonth).toMatchObject([9]); + }); + }); + + describe("filterProps", () => { + it("should return an options with the two expected properties", () => { + const result = currentInfo.filterProps(defaultOptions); + + const resultKeys = Object.keys(result); + + expect(resultKeys).toHaveLength(2); + expect(resultKeys).toContain("bymonthday"); + expect(resultKeys).toContain("bymonth"); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2023-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on September 12th"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/year-day-of-year.test.ts b/src/ui/components/event-recurrence-tests/year-day-of-year.test.ts new file mode 100644 index 0000000..96462f4 --- /dev/null +++ b/src/ui/components/event-recurrence-tests/year-day-of-year.test.ts @@ -0,0 +1,118 @@ +import { DateTime } from "luxon"; +import { + MonthYearRecurrenceType, + YEAR_RECURRENCE_INFO, + getDateStats, +} from "../event-recurrence-types"; +import { defaultOptions } from "./test-helpers"; + +describe("Yearly Recurrence -> dayOfYear", () => { + const currentInfo = + YEAR_RECURRENCE_INFO.find( + (info) => info.recurrenceType === MonthYearRecurrenceType.dayOfYear + ) ?? YEAR_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-01-01")); + + describe("hasProps", () => { + describe("when day is an array with a positive number", () => { + it("should return true", () => { + const options = { + byyearday: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is an array with a negative number", () => { + it("should return false", () => { + const options = { + byyearday: [-1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is a positive number", () => { + it("should return true", () => { + const options = { + byyearday: 1, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when day is a negative number", () => { + it("should return false", () => { + const options = { + byyearday: -1, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is undefined", () => { + it("should return false", () => { + const options = {}; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when day is null", () => { + it("should return false", () => { + const options = { + byyearday: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the dateStats year day in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.byyearday).toMatchObject([1]); + }); + }); + + describe("filterProps", () => { + it("should return an options with the expected property", () => { + const result = currentInfo.filterProps(defaultOptions); + + expect(Object.keys(result)).toMatchObject(["byyearday"]); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2024-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 256th day of the year"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/year-weekday-before-end-of-month.test.ts b/src/ui/components/event-recurrence-tests/year-weekday-before-end-of-month.test.ts new file mode 100644 index 0000000..7afc5c3 --- /dev/null +++ b/src/ui/components/event-recurrence-tests/year-weekday-before-end-of-month.test.ts @@ -0,0 +1,199 @@ +import { DateTime } from "luxon"; +import { + MonthYearRecurrenceType, + YEAR_RECURRENCE_INFO, + getDateStats, +} from "../event-recurrence-types"; +import { RRule } from "rrule"; +import { defaultOptions } from "./test-helpers"; + +describe("Yearly Recurrence -> weekdayBeforeEndOfMonth", () => { + const currentInfo = + YEAR_RECURRENCE_INFO.find( + (info) => + info.recurrenceType === + MonthYearRecurrenceType.weekdayBeforeEndOfMonth + ) ?? YEAR_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when bysetpos is an array with a positive number", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is an array with a negative number", () => { + it("should return true", () => { + const options = { + bysetpos: [-1], + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is a positive number", () => { + it("should return false", () => { + const options = { + bysetpos: 1, + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is a negative number", () => { + it("should return true", () => { + const options = { + bysetpos: -1, + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is undefined", () => { + it("should return false", () => { + const options = { + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is null", () => { + it("should return false", () => { + const options = { + bysetpos: null, + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is undefined", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is null", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: null, + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when month is undefined", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when month is null", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + bymonth: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the weekdaysFromMonthEnd, weekday and month in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bysetpos).toMatchObject([ + dateStats.weekdaysFromMonthEnd * -1, + ]); + + expect(result.byweekday).toMatchObject([dateStats.weekday]); + + expect(result.bymonth).toMatchObject([dateStats.month]); + }); + }); + + describe("filterProps", () => { + it("should only return the three expected keys in the options", () => { + const result = currentInfo.filterProps(defaultOptions); + + const resultKeys = Object.keys(result); + + expect(resultKeys).toHaveLength(3); + expect(resultKeys).toContain("bysetpos"); + expect(resultKeys).toContain("byweekday"); + expect(resultKeys).toContain("bymonth"); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2023-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 3rd to last Tuesday of September"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/year-weekday-before-end-of-year.test.ts b/src/ui/components/event-recurrence-tests/year-weekday-before-end-of-year.test.ts new file mode 100644 index 0000000..f43ca13 --- /dev/null +++ b/src/ui/components/event-recurrence-tests/year-weekday-before-end-of-year.test.ts @@ -0,0 +1,161 @@ +import { DateTime } from "luxon"; +import { + MonthYearRecurrenceType, + YEAR_RECURRENCE_INFO, + getDateStats, +} from "../event-recurrence-types"; +import { RRule } from "rrule"; +import { defaultOptions } from "./test-helpers"; + +describe("Yearly Recurrence -> weekdayBeforeEndOfYear", () => { + const currentInfo = + YEAR_RECURRENCE_INFO.find( + (info) => + info.recurrenceType === + MonthYearRecurrenceType.weekdayBeforeEndOfYear + ) ?? YEAR_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when bysetpos is an array with a positive number", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is an array with a negative number", () => { + it("should return true", () => { + const options = { + bysetpos: [-1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is a positive number", () => { + it("should return false", () => { + const options = { + bysetpos: 1, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is a negative number", () => { + it("should return true", () => { + const options = { + bysetpos: -1, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is undefined", () => { + it("should return false", () => { + const options = { + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is null", () => { + it("should return false", () => { + const options = { + bysetpos: null, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is undefined", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is null", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the weekdaysFromYearEnd and weekday in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bysetpos).toMatchObject([ + dateStats.weekdaysFromYearEnd * -1, + ]); + + expect(result.byweekday).toMatchObject([dateStats.weekday]); + }); + }); + + describe("filterProps", () => { + it("should only return the two expected keys in the options", () => { + const result = currentInfo.filterProps(defaultOptions); + + const resultKeys = Object.keys(result); + + expect(resultKeys).toHaveLength(2); + expect(resultKeys).toContain("bysetpos"); + expect(resultKeys).toContain("byweekday"); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2024-01-01") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 53rd to last Monday of the year"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/year-weekday-in-month.test.ts b/src/ui/components/event-recurrence-tests/year-weekday-in-month.test.ts new file mode 100644 index 0000000..29a83c1 --- /dev/null +++ b/src/ui/components/event-recurrence-tests/year-weekday-in-month.test.ts @@ -0,0 +1,196 @@ +import { DateTime } from "luxon"; +import { + MonthYearRecurrenceType, + YEAR_RECURRENCE_INFO, + getDateStats, +} from "../event-recurrence-types"; +import { RRule } from "rrule"; +import { defaultOptions } from "./test-helpers"; + +describe("Yearly Recurrence -> weekdayInMonth", () => { + const currentInfo = + YEAR_RECURRENCE_INFO.find( + (info) => + info.recurrenceType === MonthYearRecurrenceType.weekdayInMonth + ) ?? YEAR_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when bysetpos is an array with a positive number", () => { + it("should return true", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is an array with a negative number", () => { + it("should return false", () => { + const options = { + bysetpos: [-1], + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is a positive number", () => { + it("should return true", () => { + const options = { + bysetpos: 1, + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is a negative number", () => { + it("should return false", () => { + const options = { + bysetpos: -1, + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is undefined", () => { + it("should return false", () => { + const options = { + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is null", () => { + it("should return false", () => { + const options = { + bysetpos: null, + byweekday: [RRule.MO.weekday], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is undefined", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is null", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: null, + bymonth: [9], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when month is undefined", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when month is null", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + bymonth: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the weekdayInMonth, weekday and month in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bysetpos).toMatchObject([dateStats.weekdayInMonth]); + + expect(result.byweekday).toMatchObject([dateStats.weekday]); + + expect(result.bymonth).toMatchObject([dateStats.month]); + }); + }); + + describe("filterProps", () => { + it("should only return the three expected keys in the options", () => { + const result = currentInfo.filterProps(defaultOptions); + + const resultKeys = Object.keys(result); + + expect(resultKeys).toHaveLength(3); + expect(resultKeys).toContain("bysetpos"); + expect(resultKeys).toContain("byweekday"); + expect(resultKeys).toContain("bymonth"); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2023-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 2nd Tuesday of September"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-tests/year-weekday-in-year.test.ts b/src/ui/components/event-recurrence-tests/year-weekday-in-year.test.ts new file mode 100644 index 0000000..2b60e7a --- /dev/null +++ b/src/ui/components/event-recurrence-tests/year-weekday-in-year.test.ts @@ -0,0 +1,158 @@ +import { DateTime } from "luxon"; +import { + MonthYearRecurrenceType, + YEAR_RECURRENCE_INFO, + getDateStats, +} from "../event-recurrence-types"; +import { RRule } from "rrule"; +import { defaultOptions } from "./test-helpers"; + +describe("Yearly Recurrence -> weekdayInYear", () => { + const currentInfo = + YEAR_RECURRENCE_INFO.find( + (info) => + info.recurrenceType === MonthYearRecurrenceType.weekdayInYear + ) ?? YEAR_RECURRENCE_INFO[0]; + + const dateStats = getDateStats(DateTime.fromISO("2023-09-01")); + + describe("hasProps", () => { + describe("when bysetpos is an array with a positive number", () => { + it("should return true", () => { + const options = { + bysetpos: [1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is an array with a negative number", () => { + it("should return false", () => { + const options = { + bysetpos: [-1], + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is a positive number", () => { + it("should return true", () => { + const options = { + bysetpos: 1, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(true); + }); + }); + + describe("when bysetpos is a negative number", () => { + it("should return false", () => { + const options = { + bysetpos: -1, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is undefined", () => { + it("should return false", () => { + const options = { + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when bysetpos is null", () => { + it("should return false", () => { + const options = { + bysetpos: null, + byweekday: [RRule.MO.weekday], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is undefined", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + + describe("when weekday is null", () => { + it("should return false", () => { + const options = { + bysetpos: [1], + byweekday: null, + }; + + const result = currentInfo.hasProps(options); + + expect(result).toBe(false); + }); + }); + }); + + describe("getProps", () => { + it("should put the weekdayInYear and weekday in the options", () => { + const result = currentInfo.getProps(dateStats); + + expect(result.bysetpos).toMatchObject([dateStats.weekdayInYear]); + + expect(result.byweekday).toMatchObject([dateStats.weekday]); + }); + }); + + describe("filterProps", () => { + it("should only return the two expected keys in the options", () => { + const result = currentInfo.filterProps(defaultOptions); + + const resultKeys = Object.keys(result); + + expect(resultKeys).toHaveLength(2); + expect(resultKeys).toContain("bysetpos"); + expect(resultKeys).toContain("byweekday"); + }); + }); + + describe("getDisplay", () => { + describe("when called with date stats", () => { + it("should return the expected text", () => { + const currentDateStats = getDateStats( + DateTime.fromISO("2023-09-12") + ); + + const result = currentInfo.getDisplay(currentDateStats); + + expect(result).toBe("on the 37th Tuesday of the year"); + }); + }); + }); +}); diff --git a/src/ui/components/event-recurrence-types.ts b/src/ui/components/event-recurrence-types.ts new file mode 100644 index 0000000..833859d --- /dev/null +++ b/src/ui/components/event-recurrence-types.ts @@ -0,0 +1,511 @@ +import { DateTime } from "luxon"; +import { Options, RRule } from "rrule"; + +/** + * This file declares the different types of recurrence supported by + * this plugin, specifically for Monthly and Yearly recurrences. + */ + +/** + * This enum lists all of the possible types of recurrence, both for + * monthly and yearly recurrence. + */ +export enum MonthYearRecurrenceType { + dayOfMonth = 0, + dayBeforeEndOfMonth = 1, + weekdayInMonth = 2, + weekdayBeforeEndOfMonth = 3, + dayOfYear = 4, + dayBeforeEndOfYear = 5, + weekdayInYear = 6, + weekdayBeforeEndOfYear = 7, +} + +/** + * An interface with the results from the getDateStats function. Contains + * any information that will be needed to calculate values for monthly or + * yearly recurrence. + */ +export interface DateStats { + monthDay: number; + weekday: number; + daysUntilEndMonth: number; + weekdayInMonth: number; + weekdaysFromMonthEnd: number; + dayName: string; + month: number; + monthName: string; + yearDay: number; + daysUntilEndYear: number; + weekdayInYear: number; + weekdaysFromYearEnd: number; +} + +/** + * Computes various values to be used in the recurrence rules for monthly + * or yearly recurrences. + * + * @param {DateTime} date - The date for which to compute statistics. + * @returns {DateStats} - Values related to the date, useful for computing + * recurrences. + */ +export const getDateStats = (date: DateTime): DateStats => { + const monthDay = Number(date.day); + const daysInMonth = date.daysInMonth; + const yearDay = date.ordinal; + const daysInYear = date.daysInYear; + + return { + monthDay, + weekday: date.weekday - 1, + daysUntilEndMonth: daysInMonth - monthDay + 1, + weekdayInMonth: Math.floor((monthDay - 1) / 7) + 1, + weekdaysFromMonthEnd: Math.floor((daysInMonth - monthDay) / 7) + 1, + dayName: date.weekdayLong, + month: date.month, + monthName: date.monthLong, + yearDay, + daysUntilEndYear: daysInYear - yearDay + 1, + weekdayInYear: Math.floor((yearDay - 1) / 7) + 1, + weekdaysFromYearEnd: Math.floor((daysInYear - yearDay) / 7) + 1, + }; +}; + +/** + * Formats the value as an ordinal. For example, 1 becomes '1st', 2 becomes + * '2nd', etc. + * @param {number} value - The value to be turned into an ordianl + * @returns {string} - The number as an ordinal. + */ +export const formatOrdinalNumber = (value: number): string => { + const tensValue = value % 100; + const isBetween10And20 = tensValue > 10 && tensValue < 20; + const onesDigit = value % 10; + if (onesDigit === 1 && !isBetween10And20) { + return `${value}st`; + } + + if (onesDigit === 2 && !isBetween10And20) { + return `${value}nd`; + } + + if (onesDigit === 3 && !isBetween10And20) { + return `${value}rd`; + } + + return `${value}th`; +}; + +/** + * Formats the argument value as an ordinal, including the string 'to last' + * at the end. If the argument is 1, then the string 'last' will be + * returned. + * + * Used to format a number as an ordinal relative to the end of a time + * period (for example, the 2nd to last Friday of the month). + * @param {number} value - The value to be converted into an ordinal + * @returns {string} - The display string of an ordinal relative to the end + * of a time period. + */ +export const formatLastOrdinalNumber = (value: number): string => { + if (value === 1) { + return "last"; + } + + return `${formatOrdinalNumber(value)} to last`; +}; + +/** + * A RecurrenceInfo declares all of the necessary functionality to + * calculate/display different recurrence types in the Month/Year select + * box. + */ +export interface RecurrenceInfo { + /** + * The type of recurrence declared by this RecurrenceInfo. The rest of + * the properties in this interface provide the necessary functionality + * to support this type of recurrence. + */ + recurrenceType: MonthYearRecurrenceType; + + /** + * Returns a boolean declaring whether the passed-in RRule options + * contain all of the properties necessary for this recurrence type. + * + * Used to correctly display a selected recurrence type in the view on + * initialization. + * + * @param {Partial} options - The current options selected for + * the current recurrence. + * @returns {boolean} - True if the current options contain all the + * information needed for this recurrence type, false otherwise. + */ + hasProps: (options: Partial) => boolean; + + /** + * Given the date stats for a certain date, this function will provide + * the RRule options necessary for this recurrence type. + * + * Used to provide the correct options for this recurrence type when + * the user selects it in the Month/Year select box. + * + * @param {DateStats} dateStats - The result of the getDateStats + * function for the necessary date. + * @returns {Partial} - The RRule options needed for this + * recurrence type. + */ + getProps: (dateStats: DateStats) => Partial; + + /** + * Given a set of RRule options, this will return RRule options only + * including the ones needed for this recurrence type. + * + * Used when initializing the EditEventRecurrence component to the + * correct values for the current selected recurrence type. + * + * @param {Partial} options - The RRule options provided in the + * props to the EditEventRecurrence component. + * @returns {Partial} - The provided RRule options, only + * including the properties necessary for this recurrence type. + */ + filterProps: (options: Partial) => Partial; + + /** + * Formats the display for this recurrence type. + * + * Used for the value displayed in the Month/Yearly recurrence select + * box. + * + * @param {DateStats} dateStats - The result of the getDateStats + * function for the necessary date. + * @returns {string} - The display string for this recurrence type. + */ + getDisplay: (dateStats: DateStats) => string; +} + +/** + * The supported recurrences for monthly recurrence. This array can be + * updated if we need to support any additional monthly recurrence types in + * the future. + */ +export const MONTH_RECURRENCE_INFO: RecurrenceInfo[] = [ + { + recurrenceType: MonthYearRecurrenceType.dayOfMonth, + hasProps: (options) => { + if (!options.bymonthday) { + return false; + } + + const bymonthday = Array.isArray(options.bymonthday) + ? options.bymonthday[0] + : options.bymonthday; + + return bymonthday > 0; + }, + getProps: (dateStats) => ({ + bymonthday: [dateStats.monthDay], + }), + filterProps: (options) => ({ + bymonthday: options.bymonthday, + }), + getDisplay: (dateStats) => + `on the ${formatOrdinalNumber( + dateStats.monthDay + )} day of the month`, + }, + { + recurrenceType: MonthYearRecurrenceType.dayBeforeEndOfMonth, + hasProps: (options) => { + if (!options.bymonthday) { + return false; + } + + const bymonthday = Array.isArray(options.bymonthday) + ? options.bymonthday[0] + : options.bymonthday; + + return bymonthday < 0; + }, + getProps: (dateStats) => ({ + bymonthday: [dateStats.daysUntilEndMonth * -1], + }), + filterProps: (options) => ({ + bymonthday: options.bymonthday, + }), + getDisplay: (dateStats) => + `on the ${formatLastOrdinalNumber( + dateStats.daysUntilEndMonth + )} day of the month`, + }, + { + recurrenceType: MonthYearRecurrenceType.weekdayInMonth, + hasProps: (options) => { + if (!options.bysetpos || !options.byweekday) { + return false; + } + + const bysetpos = Array.isArray(options.bysetpos) + ? options.bysetpos[0] + : options.bysetpos; + + return bysetpos > 0; + }, + getProps: (dateStats) => ({ + bysetpos: [dateStats.weekdayInMonth], + byweekday: [dateStats.weekday], + }), + filterProps: (options) => ({ + bysetpos: options.bysetpos, + byweekday: options.byweekday, + }), + getDisplay: (dateStats) => + `on the ${formatOrdinalNumber(dateStats.weekdayInMonth)} ${ + dateStats.dayName + } of the month`, + }, + { + recurrenceType: MonthYearRecurrenceType.weekdayBeforeEndOfMonth, + hasProps: (options) => { + if (!options.bysetpos || !options.byweekday) { + return false; + } + + const bysetpos = Array.isArray(options.bysetpos) + ? options.bysetpos[0] + : options.bysetpos; + + return bysetpos < 0; + }, + getProps: (dateStats) => ({ + bysetpos: [dateStats.weekdaysFromMonthEnd * -1], + byweekday: [dateStats.weekday], + }), + filterProps: (options) => ({ + bysetpos: options.bysetpos, + byweekday: options.byweekday, + }), + getDisplay: (dateStats) => + `on the ${formatLastOrdinalNumber( + dateStats.weekdaysFromMonthEnd + )} ${dateStats.dayName} of the month`, + }, +]; + +/** + * The supported recurrences for yearly recurrence. This array can be + * updated if we need to support any additional monthly recurrence types in + * the future. + */ +export const YEAR_RECURRENCE_INFO: RecurrenceInfo[] = [ + { + recurrenceType: MonthYearRecurrenceType.dayOfMonth, + hasProps: (options) => { + if (!options.bymonthday || !options.bymonth) { + return false; + } + + const bymonthday = Array.isArray(options.bymonthday) + ? options.bymonthday[0] + : options.bymonthday; + + return bymonthday > 0; + }, + getProps: (dateStats) => ({ + bymonthday: [dateStats.monthDay], + bymonth: [dateStats.month], + }), + filterProps: (options) => ({ + bymonthday: options.bymonthday, + bymonth: options.bymonth, + }), + getDisplay: (dateStats) => + `on ${dateStats.monthName} ${formatOrdinalNumber( + dateStats.monthDay + )}`, + }, + { + recurrenceType: MonthYearRecurrenceType.dayBeforeEndOfMonth, + hasProps: (options) => { + if (!options.bymonthday || !options.bymonth) { + return false; + } + + const bymonthday = Array.isArray(options.bymonthday) + ? options.bymonthday[0] + : options.bymonthday; + + return bymonthday < 0; + }, + getProps: (dateStats) => ({ + bymonthday: [dateStats.daysUntilEndMonth * -1], + bymonth: [dateStats.month], + }), + filterProps: (options) => ({ + bymonthday: options.bymonthday, + bymonth: options.bymonth, + }), + getDisplay: (dateStats) => + `on the ${formatLastOrdinalNumber( + dateStats.daysUntilEndMonth + )} day of ${dateStats.monthName}`, + }, + { + recurrenceType: MonthYearRecurrenceType.weekdayInMonth, + hasProps: (options) => { + if (!options.bysetpos || !options.byweekday || !options.bymonth) { + return false; + } + + const bysetpos = Array.isArray(options.bysetpos) + ? options.bysetpos[0] + : options.bysetpos; + + return bysetpos > 0; + }, + getProps: (dateStats) => ({ + bysetpos: [dateStats.weekdayInMonth], + byweekday: [dateStats.weekday], + bymonth: [dateStats.month], + }), + filterProps: (options) => ({ + bysetpos: options.bysetpos, + byweekday: options.byweekday, + bymonth: options.bymonth, + }), + getDisplay: (dateStats) => + `on the ${formatOrdinalNumber(dateStats.weekdayInMonth)} ${ + dateStats.dayName + } of ${dateStats.monthName}`, + }, + { + recurrenceType: MonthYearRecurrenceType.weekdayBeforeEndOfMonth, + hasProps: (options) => { + if (!options.bysetpos || !options.byweekday || !options.bymonth) { + return false; + } + + const bysetpos = Array.isArray(options.bysetpos) + ? options.bysetpos[0] + : options.bysetpos; + + return bysetpos < 0; + }, + getProps: (dateStats) => ({ + bysetpos: [dateStats.weekdaysFromMonthEnd * -1], + byweekday: [dateStats.weekday], + bymonth: [dateStats.month], + }), + filterProps: (options) => ({ + bysetpos: options.bysetpos, + byweekday: options.byweekday, + bymonth: options.bymonth, + }), + getDisplay: (dateStats) => + `on the ${formatLastOrdinalNumber( + dateStats.weekdaysFromMonthEnd + )} ${dateStats.dayName} of ${dateStats.monthName}`, + }, + { + recurrenceType: MonthYearRecurrenceType.dayOfYear, + hasProps: (options) => { + if (!options.byyearday) { + return false; + } + + const byyearday = Array.isArray(options.byyearday) + ? options.byyearday[0] + : options.byyearday; + + return byyearday > 0; + }, + getProps: (dateStats) => ({ + byyearday: [dateStats.yearDay], + }), + filterProps: (options) => ({ + byyearday: options.byyearday, + }), + getDisplay: (dateStats) => + `on the ${formatOrdinalNumber(dateStats.yearDay)} day of the year`, + }, + { + recurrenceType: MonthYearRecurrenceType.dayBeforeEndOfYear, + hasProps: (options) => { + if (!options.byyearday) { + return false; + } + + const byyearday = Array.isArray(options.byyearday) + ? options.byyearday[0] + : options.byyearday; + + return byyearday < 0; + }, + getProps: (dateStats) => ({ + byyearday: [dateStats.daysUntilEndYear * -1], + }), + filterProps: (options) => ({ + byyearday: options.byyearday, + }), + getDisplay: (dateStats) => + `on the ${formatLastOrdinalNumber( + dateStats.daysUntilEndYear + )} day of the year`, + }, + { + recurrenceType: MonthYearRecurrenceType.weekdayInYear, + hasProps: (options) => { + if (!options.bysetpos || !options.byweekday) { + return false; + } + + const bysetpos = Array.isArray(options.bysetpos) + ? options.bysetpos[0] + : options.bysetpos; + + return bysetpos > 0; + }, + getProps: (dateStats) => ({ + bysetpos: [dateStats.weekdayInYear], + byweekday: [dateStats.weekday], + }), + filterProps: (options) => ({ + bysetpos: options.bysetpos, + byweekday: options.byweekday, + }), + getDisplay: (dateStats) => + `on the ${formatOrdinalNumber(dateStats.weekdayInYear)} ${ + dateStats.dayName + } of the year`, + }, + { + recurrenceType: MonthYearRecurrenceType.weekdayBeforeEndOfYear, + hasProps: (options) => { + if (!options.bysetpos || !options.byweekday) { + return false; + } + + const bysetpos = Array.isArray(options.bysetpos) + ? options.bysetpos[0] + : options.bysetpos; + + return bysetpos < 0; + }, + getProps: (dateStats) => ({ + bysetpos: [dateStats.weekdaysFromYearEnd * -1], + byweekday: [dateStats.weekday], + }), + filterProps: (options) => ({ + bysetpos: options.bysetpos, + byweekday: options.byweekday, + }), + getDisplay: (dateStats) => + `on the ${formatLastOrdinalNumber(dateStats.weekdaysFromYearEnd)} ${ + dateStats.dayName + } of the year`, + }, +]; + +export const RECURRENCE_INFO_MAP: { [key: number]: RecurrenceInfo[] } = { + [RRule.MONTHLY]: MONTH_RECURRENCE_INFO, + [RRule.YEARLY]: YEAR_RECURRENCE_INFO, +}; diff --git a/src/ui/overrides.css b/src/ui/overrides.css index 6e43fd5..5c86ef8 100644 --- a/src/ui/overrides.css +++ b/src/ui/overrides.css @@ -80,3 +80,11 @@ border-color: black !important; border-width: 1px !important; } + +.fc-edit-control { + margin: 0 8px; +} + +.fc-recurring-control { + margin-left: 12px; +}