diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index 1834eb19e..000000000 --- a/jest.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { Config } from 'jest' - -type StorybookTestConfig = { testRunnerOptions: { play: boolean } } - -export { baseConfig } - -const baseConfig: Config & StorybookTestConfig = { - testEnvironment: 'jsdom', - - testRunner: 'storybook-test-runner', - testRunnerOptions: { - play: true, - }, -} diff --git a/package-lock.json b/package-lock.json index d72865d05..3ee9f5918 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,13 @@ "name": "@mparticle/aquarium", "version": "1.10.0", "license": "Apache-2.0", + "dependencies": { + "lodash.clonedeep": "^4.5.0" + }, "devDependencies": { "@commitlint/cli": "19.2.1", "@commitlint/config-conventional": "19.1.0", + "@faker-js/faker": "^8.4.1", "@semantic-release/changelog": "6.0.3", "@semantic-release/git": "10.0.1", "@storybook/addon-essentials": "8.0.0", @@ -34,8 +38,10 @@ "eslint-plugin-promise": "6.1.1", "eslint-plugin-react": "7.33.2", "eslint-plugin-react-hooks": "4.6.0", + "factory.ts": "^1.4.1", "http-server": "14.1.1", "husky": "8.0.3", + "jsdom": "^24.0.0", "prettier": "3.1.1", "storybook": "8.0.0", "stylelint": "16.2.0", @@ -3362,6 +3368,22 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@faker-js/faker": { + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-8.4.1.tgz", + "integrity": "sha512-XQ3cU+Q8Uqmrbf2e0cIC/QN43sTBSC8KF12u29Mb47tWrt2hAgBXSgpZMj4Ao8Uk0iJcU99QsOCaIL8934obCg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/fakerjs" + } + ], + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0", + "npm": ">=6.14.13" + } + }, "node_modules/@fal-works/esbuild-plugin-global-externals": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@fal-works/esbuild-plugin-global-externals/-/esbuild-plugin-global-externals-2.1.2.tgz", @@ -5101,19 +5123,6 @@ "node": ">=18" } }, - "node_modules/@semantic-release/github/node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "dev": true, - "peer": true, - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/@semantic-release/github/node_modules/aggregate-error": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-5.0.0.tgz", @@ -5181,20 +5190,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@semantic-release/github/node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", - "dev": true, - "peer": true, - "dependencies": { - "agent-base": "^7.0.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/@semantic-release/github/node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -9235,6 +9230,18 @@ "node": ">= 10.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -11413,6 +11420,18 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.0.1.tgz", + "integrity": "sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ==", + "dev": true, + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", @@ -11534,6 +11553,53 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/date-fns": { "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", @@ -11588,6 +11654,12 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", + "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", + "dev": true + }, "node_modules/dedent": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", @@ -13583,6 +13655,29 @@ "node": ">=4" } }, + "node_modules/factory.ts": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/factory.ts/-/factory.ts-1.4.1.tgz", + "integrity": "sha512-x5hrzGOZvQnw82ZK+fUo/p1nlbJGCi564FBx3jQWQix6xyEK8xvdCwjdgdmbaUiqfURWWfjgTJyBU5OSfs52tw==", + "dev": true, + "dependencies": { + "clone-deep": "^4.0.1", + "source-map-support": "^0.5.21" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/factory.ts/node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -15047,7 +15142,6 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "dev": true, - "peer": true, "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -15056,19 +15150,6 @@ "node": ">= 14" } }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "dev": true, - "peer": true, - "dependencies": { - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/http-server": { "version": "14.1.1", "resolved": "https://registry.npmjs.org/http-server/-/http-server-14.1.1.tgz", @@ -15096,6 +15177,19 @@ "node": ">=12" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", + "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "dev": true, + "dependencies": { + "agent-base": "^7.0.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -15825,6 +15919,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, "node_modules/is-regex": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", @@ -17757,6 +17857,140 @@ "signal-exit": "^3.0.2" } }, + "node_modules/jsdom": { + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-24.0.0.tgz", + "integrity": "sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A==", + "dev": true, + "dependencies": { + "cssstyle": "^4.0.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.2", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.7", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.3", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0", + "ws": "^8.16.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^2.11.2" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", + "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "dev": true, + "dependencies": { + "entities": "^4.4.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.0.0.tgz", + "integrity": "sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw==", + "dev": true, + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -18074,6 +18308,11 @@ "dev": true, "peer": true }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -21859,6 +22098,12 @@ "license": "ISC", "peer": true }, + "node_modules/nwsapi": { + "version": "2.2.7", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", + "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==", + "dev": true + }, "node_modules/nyc": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/nyc/-/nyc-15.1.0.tgz", @@ -23314,6 +23559,12 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "dev": true }, + "node_modules/psl": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", + "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==", + "dev": true + }, "node_modules/pump": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", @@ -23394,6 +23645,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -24732,6 +24989,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -24834,6 +25097,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz", @@ -26572,6 +26847,12 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, "node_modules/table": { "version": "6.8.1", "resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz", @@ -27059,6 +27340,30 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -27559,6 +27864,16 @@ "integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==", "dev": true }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -28220,6 +28535,18 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-on": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.2.0.tgz", @@ -28409,6 +28736,15 @@ "node": ">=0.10.0" } }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -28687,6 +29023,21 @@ "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", "dev": true }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 9dbe5bb15..a295e77be 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "devDependencies": { "@commitlint/cli": "19.2.1", "@commitlint/config-conventional": "19.1.0", + "@faker-js/faker": "^8.4.1", "@semantic-release/changelog": "6.0.3", "@semantic-release/git": "10.0.1", "@storybook/addon-essentials": "8.0.0", @@ -54,8 +55,10 @@ "eslint-plugin-promise": "6.1.1", "eslint-plugin-react": "7.33.2", "eslint-plugin-react-hooks": "4.6.0", + "factory.ts": "^1.4.1", "http-server": "14.1.1", "husky": "8.0.3", + "jsdom": "^24.0.0", "prettier": "3.1.1", "storybook": "8.0.0", "stylelint": "16.2.0", @@ -100,5 +103,8 @@ "commitizen": { "path": "./node_modules/cz-conventional-changelog" } + }, + "dependencies": { + "lodash.clonedeep": "^4.5.0" } } diff --git a/src/services/Cookies.ts b/src/services/Cookies.ts new file mode 100644 index 000000000..267b7c3e1 --- /dev/null +++ b/src/services/Cookies.ts @@ -0,0 +1,71 @@ +export function get(key: string): string | null { + const cookies = getAll() + return cookies?.[key] ? cookies[key] : null +} + +export function getAll(): Record { + return _parse(document.cookie) +} + +export function getObject(key: string): string | null { + const value = get(key) + return value ? JSON.parse(value) : value +} + +export function put(key: string, value: string | null, options: any /* TODO fix any */ = {}): void { + let expires = options.expires + if (value == null) expires = 'Thu, 01 Jan 1970 00:00:01 GMT' + if (typeof expires === 'string') expires = new Date(expires) + let str = `${_encode(key)}=${value != null ? _encode(value) : ''}` + if (options.path) str += `; path=${options.path}` + if (options.domain) str += `; domain=${options.domain}` + if (options.expires) str += `; expires=${expires.toUTCString()}` + if (options.secure) str += '; secure' + document.cookie = str +} + +export function putObject(key: string, value: Record, options = {}): void { + put(key, JSON.stringify(value), options) +} + +export function remove(key: string, options = {}): void { + put(key, null, options) +} + +export function removeAll(): void { + const cookies = getAll() + Object.keys(cookies).forEach(key => { + remove(key) + }) +} + +function _decode(value: string): string | null { + try { + return decodeURIComponent(value) + } catch (e) { + return null + } +} + +function _encode(value: string | number | boolean): string | null { + try { + return encodeURIComponent(value) + } catch (e) { + return null + } +} + +function _parse(str: string): Record { + const obj: Record = {} + const pairs = str.split(/ *; */) + if (pairs[0] === '') return obj + for (let i = 0, j = pairs.length; i < j; i += 1) { + const pair = pairs[i].split('=') + const key = _decode(pair[0]) + const value = _decode(pair[1]) + if (key && value) { + obj[key] = value + } + } + return obj +} diff --git a/src/services/composite-user-preferences.spec.ts b/src/services/composite-user-preferences.spec.ts new file mode 100644 index 000000000..5556cf800 --- /dev/null +++ b/src/services/composite-user-preferences.spec.ts @@ -0,0 +1,205 @@ +import { + CompositeUserPreferencesService, + makeBuilderFromDefinition, + TestUserPreferenceDefinitionsFakeFactory, + TestUserPreferenceId, + TestUserPreferencesFakeFactory, + type TestUserPreferencesFakeBuilder, + type UserPreferenceDefinition, + type UserPreferenceDefinitions, + type UserPreferences, + type UserPreferenceScope, + UserPreferenceScopeType, +} from 'src/services/user-preferences' + +import { describe, it, expect, beforeEach } from 'vitest' + +describe('When testing CompositeUserPreferencesService', () => { + let compositeUserPreferencesService: CompositeUserPreferencesService + let userPreferences: UserPreferences + let expectedScope: UserPreferenceScope + let definitions: UserPreferenceDefinitions + + beforeEach(() => { + definitions = TestUserPreferenceDefinitionsFakeFactory() as UserPreferenceDefinitions + const prefsBuilder = makeBuilderFromDefinition(definitions) + userPreferences = TestUserPreferencesFakeFactory([prefsBuilder]) as UserPreferences + expectedScope = Object.keys(userPreferences)[0] as UserPreferenceScope + + compositeUserPreferencesService = new CompositeUserPreferencesService() + // container.invoke(CompositeUserPreferencesService) as CompositeUserPreferencesService; + }) + + describe('and getting scoped user preferences', () => { + it('it should return defaults if preferences are empty', () => { + // arrange + userPreferences = {} + + // act + const actualScopedUserPreferences = compositeUserPreferencesService.getScopedUserPreferences( + userPreferences, + expectedScope, + definitions, + ) + + // assert + Object.entries(actualScopedUserPreferences).forEach(([preferenceId, actualPreference]) => { + const definition = definitions[preferenceId as TestUserPreferenceId] + const expectedScopedUserPreferences = { optedIn: definition?.isOptedInByDefault } + expect(actualPreference).toEqual(expectedScopedUserPreferences) + }) + }) + + it('it should get preferences in a single scope', () => { + // act + const actualScopedUserPreferences = compositeUserPreferencesService.getScopedUserPreferences( + userPreferences, + expectedScope, + definitions, + ) + + // assert + const expectedPreference = userPreferences[expectedScope] + expect(actualScopedUserPreferences).toEqual(expectedPreference) + expect(actualScopedUserPreferences).not.toBe(expectedPreference) + }) + + it.each([ + ['1-1-1', 'global', UserPreferenceScopeType.Global], + ['1-1-1', '1', UserPreferenceScopeType.LevelOneScope], + ['1-1-1', '1-1', UserPreferenceScopeType.LevelTwoScope], + ['1-1-1', '1-1-1', UserPreferenceScopeType.LevelThreeScope], + ])( + 'it should get preferences in the right scope', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + ( + currentScope: UserPreferenceScope, + expectedScope: UserPreferenceScope, + allowedScope: UserPreferenceScopeType, + ) => { + // arrange + const userPreferencesBuilder: TestUserPreferencesFakeBuilder[] = [ + { scope: expectedScope, userPreferenceIds: [TestUserPreferenceId.Default], optedIns: [true] }, + { wantsRandom: true }, + { wantsRandom: true }, + ] + userPreferences = TestUserPreferencesFakeFactory( + userPreferencesBuilder, + ) as UserPreferences + definitions = TestUserPreferenceDefinitionsFakeFactory([ + { + id: TestUserPreferenceId.Default, + isOptedInByDefault: true, + allowedScope, + }, + ]) as UserPreferenceDefinitions + + // act + const actualScopedUserPreferences = compositeUserPreferencesService.getScopedUserPreferences( + userPreferences, + currentScope, + definitions, + ) + + // assert + const expectedPreference = userPreferences[expectedScope] + expect(actualScopedUserPreferences).toEqual(expectedPreference) + expect(actualScopedUserPreferences).not.toBe(expectedPreference) + }, + ) + }) + + describe('and updating a user preference', () => { + it.each([ + ['1-1-1', 'global', UserPreferenceScopeType.Global], + ['1-1-1', '1', UserPreferenceScopeType.LevelOneScope], + ['1-1-1', '1-1', UserPreferenceScopeType.LevelTwoScope], + ['1-1-1', '1-1-1', UserPreferenceScopeType.LevelThreeScope], + ])( + 'it should update the preference in the correct scope', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + ( + currentScope: UserPreferenceScope, + expectedScope: UserPreferenceScope, + allowedScope: UserPreferenceScopeType, + ) => { + // arrange + const testPreferenceValue = true + const { preferenceId: builderPreferenceId } = getFirstDefinition(definitions) + const updatingId = builderPreferenceId + + const userPreferencesBuilder: TestUserPreferencesFakeBuilder[] = [ + { scope: expectedScope, userPreferenceIds: [updatingId], optedIns: [testPreferenceValue] }, + { wantsRandom: true }, + { wantsRandom: true }, + ] + userPreferences = TestUserPreferencesFakeFactory( + userPreferencesBuilder, + ) as UserPreferences + + // act + const expectedPreferenceValue = !testPreferenceValue + const actualUserPreferences = compositeUserPreferencesService.getUpdatedUserPreferenceStorageObject( + updatingId, + expectedPreferenceValue, + currentScope, + userPreferences, + allowedScope, + ) + + // assert + const actualUserPreference = actualUserPreferences?.[expectedScope]?.[updatingId] + expect(actualUserPreference?.optedIn).toEqual(expectedPreferenceValue) + expect(actualUserPreference).not.toBe(userPreferences) + }, + ) + + it.each([ + ['1-1-1', 'global', UserPreferenceScopeType.Global], + ['1-1-1', '1', UserPreferenceScopeType.LevelOneScope], + ['1-1-1', '1-1', UserPreferenceScopeType.LevelTwoScope], + ['1-1-1', '1-1-1', UserPreferenceScopeType.LevelThreeScope], + ])( + 'it should update the preference even if no preference exists', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + ( + currentScope: UserPreferenceScope, + expectedScope: UserPreferenceScope, + allowedScope: UserPreferenceScopeType, + ) => { + // arrange + const expectedPreferenceValue = true + const updatingId = TestUserPreferenceId.Default + + userPreferences = {} satisfies UserPreferences + + // act + const actualUserPreferences = compositeUserPreferencesService.getUpdatedUserPreferenceStorageObject( + updatingId, + expectedPreferenceValue, + expectedScope, + userPreferences, + allowedScope, + ) + + // assert + const actualUserPreference = actualUserPreferences?.[expectedScope]?.[updatingId] + expect(actualUserPreference?.optedIn).toEqual(expectedPreferenceValue) + expect(actualUserPreference).not.toBe(userPreferences) + }, + ) + }) +}) + +function getFirstDefinition(definitions: UserPreferenceDefinitions): { + definition?: UserPreferenceDefinition + preferenceId: TestUserPreferenceId +} { + const preferenceId = Object.keys(definitions)[0] as TestUserPreferenceId + const definition = definitions[preferenceId] + + return { definition, preferenceId } +} diff --git a/src/services/user-preferences-service.spec.ts b/src/services/user-preferences-service.spec.ts new file mode 100644 index 000000000..3bf69b0ac --- /dev/null +++ b/src/services/user-preferences-service.spec.ts @@ -0,0 +1,249 @@ +import { + CompositeUserPreferencesService, + type UserPreferenceScope, + type UserPreferences, + UserPreferencesService, + type UserPreferenceDefinitions, + TestUserPreferenceId, + makeBuilderFromDefinition, + TestUserPreferencesFakeFactory, + UserPreferenceScopeType, + TestUserPreferenceDefinitionsFakeFactory, +} from './user-preferences' + +import * as Cookies from './Cookies' + +import { describe, afterEach, it, expect } from 'vitest' + +describe('When testing the User Preferences Service', () => { + let userPreferencesService: UserPreferencesService + const cookieKey = 'mp_u_p' + const lowLevelScope: UserPreferenceScope = '1-1-1' + let userPreferences: UserPreferences + + let definitions: UserPreferenceDefinitions + const compositeUserPreferencesService = new CompositeUserPreferencesService() + + function setupPreferencesWithScope( + definition: UserPreferenceDefinitions, + scope: UserPreferenceScope | undefined, + ): void { + const scopedPreference = makeBuilderFromDefinition(definition, scope) + userPreferences = TestUserPreferencesFakeFactory([scopedPreference]) as UserPreferences + + Cookies.putObject(cookieKey, userPreferences) + } + + afterEach(() => { + Cookies.put(cookieKey, '') + }) + + describe('Testing stuff', () => { + const log = console.log + + it('should work', async () => { + // scope + const allowedScope = UserPreferenceScopeType.Global + definitions = TestUserPreferenceDefinitionsFakeFactory([ + { id: TestUserPreferenceId.Default, allowedScope }, + { id: TestUserPreferenceId.PreferenceOne, allowedScope }, + ]) as UserPreferenceDefinitions + + log(definitions) + + // setup + const scopedPreference = makeBuilderFromDefinition(definitions, lowLevelScope) + log(scopedPreference) + userPreferences = TestUserPreferencesFakeFactory([scopedPreference]) as UserPreferences + log(userPreferences) + Cookies.putObject(cookieKey, userPreferences) + + userPreferencesService = new UserPreferencesService( + definitions, + compositeUserPreferencesService, + cookieKey, + lowLevelScope, + () => new Date(), + ) + await userPreferencesService.init() + + log(userPreferencesService.preferences) + log(await userPreferencesService.isOptedIn(TestUserPreferenceId.Default)) + + expect(true).toBe(true) + }) + }) + + describe('and reading the data', () => { + it.each([ + [UserPreferenceScopeType.LevelOneScope], + [UserPreferenceScopeType.LevelTwoScope], + [UserPreferenceScopeType.LevelThreeScope], + ])('it should read preferences when there are scoped prefs', async (allowedScope: UserPreferenceScopeType) => { + // arrange + definitions = TestUserPreferenceDefinitionsFakeFactory([ + { id: TestUserPreferenceId.Default, allowedScope }, + { id: TestUserPreferenceId.PreferenceOne, allowedScope }, + ]) as UserPreferenceDefinitions + + setupPreferencesWithScope(definitions, lowLevelScope) + + userPreferencesService = new UserPreferencesService( + definitions, + compositeUserPreferencesService, + cookieKey, + lowLevelScope, + () => new Date(), + ) + await userPreferencesService.init() + + const scopedPreferences = userPreferences[lowLevelScope] + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + for (const [id, preference] of Object.entries(scopedPreferences)) { + // act + const isOptedIn = await userPreferencesService.isOptedIn(id as TestUserPreferenceId) + + // assert + const expectedIsOptedIn = preference.optedIn + expect(isOptedIn).not.toBeNull() + expect(isOptedIn).toBe(expectedIsOptedIn) + } + }) + + it('it should read preferences when there are no scoped prefs', async () => { + // arrange + definitions = TestUserPreferenceDefinitionsFakeFactory() as UserPreferenceDefinitions + + const someScope = '1' + setupPreferencesWithScope(definitions, someScope) + + const currentScope = '2' + userPreferencesService = new UserPreferencesService( + definitions, + compositeUserPreferencesService, + cookieKey, + currentScope, + () => new Date(), + ) + await userPreferencesService.init() + + for (const [id, definition] of Object.entries(definitions)) { + // act + const isOptedIn = await userPreferencesService.isOptedIn(id as TestUserPreferenceId) + + // assert + const expectedIsOptedIn = definition.isOptedInByDefault + expect(isOptedIn).not.toBeNull() + expect(isOptedIn).toBe(expectedIsOptedIn) + } + }) + + it("it should throw when the preference can't be found", async () => { + // console.log(Cookies.getAll()) + // arrange + definitions = TestUserPreferenceDefinitionsFakeFactory() as UserPreferenceDefinitions + // console.log(definitions) + + const someScope = '1' + setupPreferencesWithScope(definitions, someScope) + // console.log(Cookies.getAll()) + + userPreferencesService = new UserPreferencesService( + definitions, + compositeUserPreferencesService, + cookieKey, + someScope, + () => new Date(), + ) + await userPreferencesService.init() + + // act + const unknownId = 'unknown' + const isOptedInDelegate = userPreferencesService.isOptedIn.bind( + userPreferencesService, + // @ts-expect-error - we are testing an incorrect usage + unknownId, + ) + + // assert + await expect(isOptedInDelegate).rejects.toThrow(`Invalid Operation. A user preference could not be found.`) + }) + }) + + describe('and updating preferences', () => { + it.each([ + ['1', UserPreferenceScopeType.LevelOneScope], + ['1-1', UserPreferenceScopeType.LevelTwoScope], + ['1-1-1', UserPreferenceScopeType.LevelThreeScope], + ])( + 'it should be able to update a preferences when the preference exists', + async (expectedScope, allowedScope: UserPreferenceScopeType) => { + // arrange + const userPreferenceId = TestUserPreferenceId.Default + const testOptedInState = true + definitions = TestUserPreferenceDefinitionsFakeFactory([ + { id: userPreferenceId, allowedScope, isOptedInByDefault: testOptedInState }, + ]) as UserPreferenceDefinitions + + setupPreferencesWithScope(definitions, expectedScope as UserPreferenceScope) + + userPreferencesService = new UserPreferencesService( + definitions, + compositeUserPreferencesService, + cookieKey, + lowLevelScope, + () => new Date(), + ) + await userPreferencesService.init() + + // pre-assert + const testState = await userPreferencesService.isOptedIn(userPreferenceId) + + expect(testState).toBe(testOptedInState) + + // act + const expectedOptedInState = !testOptedInState + await userPreferencesService.setPreference(userPreferenceId, expectedOptedInState) + + // assert + const actualState = await userPreferencesService.isOptedIn(userPreferenceId) + + expect(actualState).toBe(expectedOptedInState) + }, + ) + + it('it should be able to update a preferences when the preference does not exist', async () => { + // arrange + const userPreferenceId = TestUserPreferenceId.Default + const allowedScope = UserPreferenceScopeType.LevelThreeScope + const testOptedInState = true + definitions = TestUserPreferenceDefinitionsFakeFactory([ + { id: userPreferenceId, allowedScope, isOptedInByDefault: testOptedInState }, + ]) as UserPreferenceDefinitions + + userPreferencesService = new UserPreferencesService( + definitions, + compositeUserPreferencesService, + cookieKey, + lowLevelScope, + () => new Date(), + ) + await userPreferencesService.init() + + // pre-assert + const testState = await userPreferencesService.isOptedIn(userPreferenceId) + + expect(testState).toBe(testOptedInState) + + // act + const expectedOptedInState = !testOptedInState + await userPreferencesService.setPreference(userPreferenceId, expectedOptedInState) + + // assert + const actualState = await userPreferencesService.isOptedIn(userPreferenceId) + + expect(actualState).toBe(expectedOptedInState) + }) + }) +}) diff --git a/src/services/user-preferences.ts b/src/services/user-preferences.ts new file mode 100644 index 000000000..fc3ab5067 --- /dev/null +++ b/src/services/user-preferences.ts @@ -0,0 +1,356 @@ +/* eslint-disable @typescript-eslint/no-extraneous-class,no-unused-vars,@typescript-eslint/no-unused-vars */ +import { type Sync } from 'factory.ts' +import { faker } from '@faker-js/faker' +import * as Cookies from './Cookies' +import cloneDeep from 'lodash/cloneDeep' + +export const USER_PREFERENCE_SCOPE_SEPARATOR = '-' +export const UserPreferenceGlobalScope = 'global' as const + +type UserPreferenceGlobalScopeType = `global` +type UserPreferenceLevelOneScopeType = `${number}` // Any string that can be coerced into a number +type UserPreferenceLevelTwoScopeType = `${number}-${number}` +type UserPreferenceLevelThreeScopeType = `${number}-${number}-${number}` + +export type UserPreferenceScope = + | UserPreferenceLevelOneScopeType + | UserPreferenceLevelTwoScopeType + | UserPreferenceLevelThreeScopeType + | UserPreferenceGlobalScopeType + +export interface UserPreference { + optedIn: boolean +} + +export enum UserPreferenceScopeType { + Global, + LevelOneScope, + LevelTwoScope, + LevelThreeScope, +} + +export interface UserPreferenceDefinition { + isOptedInByDefault: boolean + allowedScope: UserPreferenceScopeType +} + +export type UserPreferenceDefinitions = { + [Id in UserPreferenceId]?: UserPreferenceDefinition +} + +export type UserPreferences = { + [K in UserPreferenceScope]?: { [Id in UserPreferenceId]: UserPreference } +} + +export type CompositeUserPreferences = { + [Id in UserPreferenceId]: UserPreference +} + +export class CompositeUserPreferencesService { + public getScopedUserPreferences( + storedPreferences: UserPreferences, + currentScope: UserPreferenceScope, + definitions: UserPreferenceDefinitions, + ): CompositeUserPreferences { + const entriesByIdAndUserPreference = Object.entries(definitions).map<[TPreferenceIds, UserPreference]>( + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + this.createUserPreferenceEntryFromDefinition.bind(this, storedPreferences, currentScope), + ) + + const compositeUserPreferences = this.createCompositePreferencesFromEntries(entriesByIdAndUserPreference) + + return compositeUserPreferences + } + + public getUpdatedUserPreferenceStorageObject( + preferenceId: TPreferenceIds, + isOptedIn: boolean, + currentScope: UserPreferenceScope, + currentPreferences: UserPreferences, + allowedScope: UserPreferenceScopeType, + ): UserPreferences { + const userPreferencesToUpdate = currentPreferences ? cloneDeep(currentPreferences) : {} + + const effectiveScope = this.getEffectiveScope(currentScope, allowedScope) + + const scopedUserPreferences = userPreferencesToUpdate[effectiveScope] ?? {} + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + scopedUserPreferences[preferenceId] = { optedIn: isOptedIn } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + userPreferencesToUpdate[effectiveScope] = scopedUserPreferences + + return userPreferencesToUpdate + } + + private createUserPreferenceEntryFromDefinition( + storedPreferences: UserPreferences, + currentScope: UserPreferenceScope, + [definedUserPreferenceId, definition]: [TPreferenceIds, UserPreferenceDefinition], + ): [TPreferenceIds, UserPreference] { + if (!storedPreferences) { + const userPreferenceDefault = { optedIn: definition.isOptedInByDefault } + return this.createPreferenceEntry(definedUserPreferenceId, userPreferenceDefault) + } + + const { allowedScope } = definition + + const effectiveScope = this.getEffectiveScope(currentScope, allowedScope) + + const scopedUserPreferences = storedPreferences[effectiveScope] + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const userPreferenceForCurrentDefinition: UserPreference = scopedUserPreferences?.[definedUserPreferenceId] + + const optedIn = userPreferenceForCurrentDefinition?.optedIn ?? definition.isOptedInByDefault + const userPreference = { optedIn } + return this.createPreferenceEntry(definedUserPreferenceId, userPreference) + } + + private getEffectiveScope( + currentScope: UserPreferenceScope, + allowedScope: UserPreferenceScopeType, + ): UserPreferenceScope { + if (allowedScope === null) throw new Error('ArgumentError | An allowed scope must be provided.') + + if (allowedScope === UserPreferenceScopeType.Global) return UserPreferenceGlobalScope + + let scopeLength = 0 + switch (allowedScope) { + case UserPreferenceScopeType.LevelOneScope: + scopeLength = 1 + break + case UserPreferenceScopeType.LevelTwoScope: + scopeLength = 2 + break + case UserPreferenceScopeType.LevelThreeScope: + scopeLength = 3 + break + default: + // TODO throw and let the consumer decide what to do + // Logger.log( + // `CompositeUserPreferencesService | getEffectiveScope | Received unexpected allowed scope ${allowedScope}`, + // LogLevel.Warn, + // ) + break + } + + const currenScopeParts = currentScope.split(USER_PREFERENCE_SCOPE_SEPARATOR) + + currenScopeParts.length = scopeLength // Truncating scope parts that are too granular for the allowed scope + + const effectiveScope = currenScopeParts.join(USER_PREFERENCE_SCOPE_SEPARATOR) as UserPreferenceScope + + return effectiveScope + } + + private createPreferenceEntry( + userPreferenceId: TPreferenceIds, + userPreference: UserPreference, + ): [TPreferenceIds, UserPreference] { + return [userPreferenceId, userPreference] + } + + // TODO: Should be replaced with Object.fromEntries when the transpiler is updated + private createCompositePreferencesFromEntries( + entries: Array<[TPreferenceIds, UserPreference]>, + ): CompositeUserPreferences { + return entries.reduce( + ( + composite: CompositeUserPreferences, + [userPreferenceId, preference]: [TPreferenceIds, UserPreference], + ) => { + composite[userPreferenceId] = preference + return composite + }, + // eslint-disable-next-line + {} as CompositeUserPreferences, + ) + } +} + +export class UserPreferencesService { + public preferences!: CompositeUserPreferences + + constructor( + private readonly definitions: UserPreferenceDefinitions, + private readonly compositeUserPreferencesService: CompositeUserPreferencesService, + private readonly cookieKey: string, + private readonly currentScope: UserPreferenceScope, + public dateFormatter: () => Date, + ) {} + + public async init(): Promise { + const storedPreferences = await this.getStoredPreferences() + + this.preferences = this.compositeUserPreferencesService.getScopedUserPreferences( + storedPreferences, + this.currentScope, + this.definitions, + ) + } + + // I assume this is async because it could reach some sort of API in the future? + public async isOptedIn(userPreferenceId: TUserPreferenceId): Promise { + const userPreference = this.preferences[userPreferenceId] + + if (!userPreference) await Promise.reject(new Error(`Invalid Operation. A user preference could not be found.`)) + + return userPreference.optedIn + } + + public async setPreference(userPreferenceId: TUserPreferenceId, isOptedIn: boolean): Promise { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const { allowedScope } = this.definitions[userPreferenceId] + + const currentStoredPreferences = Cookies.getObject(this.cookieKey) + + const storedPreferences = this.compositeUserPreferencesService.getUpdatedUserPreferenceStorageObject( + userPreferenceId, + isOptedIn, + this.currentScope, + currentStoredPreferences as unknown as UserPreferences, + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + allowedScope, + ) + + await this.setStoredPreferences(storedPreferences) + + this.preferences = this.compositeUserPreferencesService.getScopedUserPreferences( + storedPreferences, + this.currentScope, + this.definitions, + ) + + // eslint-disable-next-line @typescript-eslint/return-await + return Promise.resolve() + } + + private async getStoredPreferences(): Promise> { + return await Promise.resolve(Cookies.getObject(this.cookieKey) ?? {}) + } + + private async setStoredPreferences(storedPreferences: UserPreferences): Promise { + Cookies.putObject(this.cookieKey, storedPreferences, { + // expires: DateFormatter.Utc().add(10, 'year').toDate(), + expires: this.dateFormatter(), + }) + + await Promise.resolve() + } +} + +// TEST + +export enum TestUserPreferenceId { + Default = 'default-id', + PreferenceOne = 'preference-one', +} + +export function TestUserPreferenceDefinitionsFakeFactory( + config?: Array<{ + id: TestUserPreferenceId + isOptedInByDefault?: boolean + allowedScope?: UserPreferenceScopeType + }>, +): Sync.Builder< + UserPreferenceDefinitions, + keyof UserPreferenceDefinitions +> { + if (!config) { + config = Object.values(TestUserPreferenceId).map(id => ({ id })) + } + + return config.reduce((definitions, { id, isOptedInByDefault, allowedScope }) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + definitions[id] = createDefinition({ isOptedInByDefault, allowedScope }) + + return definitions + }, {}) +} + +type Definition = { + isOptedInByDefault?: boolean + allowedScope?: UserPreferenceScopeType +} + +function createDefinition({ isOptedInByDefault, allowedScope }: Definition = {}): Definition { + return { + isOptedInByDefault: isOptedInByDefault ?? faker.datatype.boolean(), + allowedScope: allowedScope ?? faker.helpers.enumValue(UserPreferenceScopeType), + } +} + +export interface TestUserPreferencesFakeBuilder { + wantsRandom?: boolean + scope?: UserPreferenceScope + userPreferenceIds?: TestUserPreferenceId[] + optedIns?: boolean[] +} + +export function makeBuilderFromDefinition( + definitions: UserPreferenceDefinitions, + scope?: UserPreferenceScope, +): TestUserPreferencesFakeBuilder { + return { + scope: scope ?? getRandomScope({ excludeGlobal: true }), + userPreferenceIds: Object.keys(definitions) as TestUserPreferenceId[], + optedIns: Object.values(definitions).map(({ isOptedInByDefault }) => isOptedInByDefault), + } +} + +export function TestUserPreferencesFakeFactory( + scopes: TestUserPreferencesFakeBuilder[] = [], +): Sync.Builder, keyof UserPreferences> { + return scopes.reduce((scopedPreferences, { wantsRandom = false, scope, userPreferenceIds, optedIns }) => { + const effectiveScope = scope ?? getRandomScope({ excludeGlobal: true }) + if (wantsRandom) { + const numberOfValues = faker.number.int({ max: Object.keys(TestUserPreferenceId).length, min: 1 }) + userPreferenceIds = Array.from({ length: numberOfValues }, () => faker.helpers.enumValue(TestUserPreferenceId)) + optedIns = Array.from({ length: numberOfValues }, () => faker.datatype.boolean()) + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + scopedPreferences[effectiveScope] = userPreferenceIds.reduce((preferences, userPreferenceId, index) => { + const effectiveId = userPreferenceId ?? TestUserPreferenceId.Default + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const effectiveOptedInState = optedIns[index] ?? faker.datatype.boolean() + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + preferences[effectiveId] = { optedIn: effectiveOptedInState } + + return preferences + }, {}) + + return scopedPreferences + }, {}) as UserPreferences +} + +function getRandomScope({ maxScope = 3, excludeGlobal = false }): UserPreferenceScope { + const numberOfScopes = faker.number.int({ + max: maxScope, + min: excludeGlobal ? UserPreferenceScopeType.LevelOneScope : UserPreferenceScopeType.Global, + }) + + if (numberOfScopes === 0) return 'global' + + const scopeIdMaxLength = 9999 + const scopeIdOptions = { max: scopeIdMaxLength } + const scope = Array.from( + { length: numberOfScopes * 2 - 1 }, // Double the iterations, one for the number and + // one for the separator, removing the separator + (_, index: number): string => (!(index % 2) ? faker.number.int(scopeIdOptions).toString() : '-'), + ).join('') + + return scope as UserPreferenceScope +} diff --git a/src/utils/utils.spec.ts b/src/utils/utils.spec.ts index 7b1b2dc7a..538064812 100644 --- a/src/utils/utils.spec.ts +++ b/src/utils/utils.spec.ts @@ -29,9 +29,11 @@ describe('Testing utils', () => { it('it should return "Windows" when the user agent includes "Win"', () => { // arrange - global.navigator = { + const navigatorOverride: Partial = { userAgent: 'Windows', - } as Navigator + } + + global.navigator = { ...global.navigator, ...navigatorOverride } // act const actualOS = getOS() @@ -42,9 +44,11 @@ describe('Testing utils', () => { it('it should return "Macintosh" when the user agent includes "Mac"', () => { // arrange - global.navigator = { - userAgent: 'Macintosh', - } as Navigator + const navigatorOverride: Partial = { + userAgent: 'Windows', + } + + global.navigator = { ...global.navigator, ...navigatorOverride } // act const actualOS = getOS() @@ -53,4 +57,4 @@ describe('Testing utils', () => { expect(actualOS).toBe('Macintosh') }) }) -}) \ No newline at end of file +}) diff --git a/vite.config.js b/vite.config.js index ca9109457..2141f5c2b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -4,6 +4,9 @@ import dts from 'vite-plugin-dts' import svgr from 'vite-plugin-svgr' export default defineConfig({ + test: { + environment: 'jsdom', + }, resolve: { alias: { src: '/src',