diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a9e87a38..537dab32f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,15 @@ +# [1.13.0-ups-utils.2](https://github.com/mParticle/aquarium/compare/v1.13.0-ups-utils.1...v1.13.0-ups-utils.2) (2024-05-15) + +# [1.13.0-ups-utils.1](https://github.com/mParticle/aquarium/compare/v1.12.0...v1.13.0-ups-utils.1) (2024-05-14) + # [1.13.0](https://github.com/mParticle/aquarium/compare/v1.12.0...v1.13.0) (2024-05-15) ### Features * Adds SuitesReminder hook to get a consistent look across platforms for the reminder notification ([#221](https://github.com/mParticle/aquarium/issues/221)) ([533428e](https://github.com/mParticle/aquarium/commit/533428e64de4e48e8e4d016df7c73f807a54851b)) +* export UPS interfaces ([ffcff89](https://github.com/mParticle/aquarium/commit/ffcff89f694639a62713b45b573a5bd006805226)) +* uPS code ported from Nancy with tests passing ([182be3d](https://github.com/mParticle/aquarium/commit/182be3dc6cda64ed4a6d5a6665da771e5fea33db)) # [1.12.0](https://github.com/mParticle/aquarium/compare/v1.11.1...v1.12.0) (2024-05-14) 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 e93ba50c1..e2b51b736 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,20 @@ { "name": "@mparticle/aquarium", - "version": "1.13.0", + "version": "1.13.0-ups-utils.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mparticle/aquarium", - "version": "1.13.0", + "version": "1.13.0-ups-utils.2", "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 08ff5255b..d496962ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mparticle/aquarium", - "version": "1.13.0", + "version": "1.13.0-ups-utils.2", "description": "mParticle Component Library", "license": "Apache-2.0", "keywords": [ @@ -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/components/index.ts b/src/components/index.ts index fefbd167a..5c5a0f077 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -101,3 +101,25 @@ export type { } from './navigation/GlobalNavigation/WorkspaceSelector/WorkspaceSelectorItems' export { SuiteLogo } from './navigation/GlobalNavigation/SuiteLogo' export { Typography } from './general/Typography/Typography' + +// UPS +export { UserPreferencesService } from '../services/user-preferences/user-preferences' +export { CompositeUserPreferencesService } from '../services/user-preferences/composite-user-preferences-service' +export { type CompositeUserPreferences } from '../services/user-preferences/models/user-preferences/composite-user-preferences' +export { + UserPreferenceScopeType, + type UserPreferenceDefinition, + type UserPreferenceDefinitions, +} from '../services/user-preferences/models/definitions' +export { + type UserPreferences, + USER_PREFERENCE_SCOPE_SEPARATOR, + UserPreferenceGlobalScope, + type UserPreference, + type UserPreferenceScope, +} from '../services/user-preferences/models/storage-models' +export { + useNewExperienceReminder, + type INewExperienceReminderOptions, + type NewExperienceReminderHook, +} from '../hooks/NewExperienceReminder/useNewExperienceReminder' diff --git a/src/components/navigation/GlobalNavigation/GlobalNavigation.stories.tsx b/src/components/navigation/GlobalNavigation/GlobalNavigation.stories.tsx index ba0145a10..397748d87 100644 --- a/src/components/navigation/GlobalNavigation/GlobalNavigation.stories.tsx +++ b/src/components/navigation/GlobalNavigation/GlobalNavigation.stories.tsx @@ -9,7 +9,7 @@ import { } from 'src/components/navigation/GlobalNavigation/GlobalNavigationItems' import { generateOrgs } from 'src/components/navigation/GlobalNavigation/stories-utils' import { type INavigationOrg } from 'src/components/navigation/GlobalNavigation/WorkspaceSelector/WorkspaceSelectorItems' -import { useSuitesReminder } from 'src/hooks/SuitesReminder/useSuitesReminder' +import { useNewExperienceReminder } from 'src/hooks/NewExperienceReminder/useNewExperienceReminder' const defaultLogo: IGlobalNavigationLogo = { label: 'Aqua', @@ -999,7 +999,7 @@ export const WorkspaceSearchWithNoResults: Meta = { }, } -export const UseSuitesReminderHook: Story = { +export const UseNewExperienceReminderHook: Story = { play: async () => { const alert = fn().mockImplementation(() => {}) global.alert = alert @@ -1022,7 +1022,7 @@ export const UseSuitesReminderHook: Story = { await expect(alert).toBeCalledWith('Take me there') }, render: props => { - const [openNotification, contextHolder] = useSuitesReminder({ + const [openNotification, contextHolder] = useNewExperienceReminder({ onClose: () => { alert('Notification closed') }, diff --git a/src/components/navigation/GlobalNavigation/GlobalNavigation.tsx b/src/components/navigation/GlobalNavigation/GlobalNavigation.tsx index 9c71c4cff..20fb3aa3e 100644 --- a/src/components/navigation/GlobalNavigation/GlobalNavigation.tsx +++ b/src/components/navigation/GlobalNavigation/GlobalNavigation.tsx @@ -3,8 +3,8 @@ import './global-navigation.css' import { type IAvatarProps, Icon, Layout } from 'src/components' import { Flex } from 'src/components' import { Center } from 'src/components' +import { Popover } from 'src/components' import { type INavigationCreateProps } from 'src/components' -import { Tooltip } from 'src/components' import { type INavigationOrg } from 'src/components' import { type IGlobalNavigationLogo } from 'src/components' import { SuiteLogo } from 'src/components/navigation/GlobalNavigation/SuiteLogo' @@ -14,8 +14,7 @@ import { NavigationCreate } from 'src/components/navigation/GlobalNavigation/Nav import { WorkspaceSelector } from 'src/components/navigation/GlobalNavigation/WorkspaceSelector/WorkspaceSelector' import { type IGlobalNavigationItem } from 'src/components/navigation/GlobalNavigation/GlobalNavigationItems' import { NavigationItem } from 'src/components/navigation/GlobalNavigation/NavigationItem' -import { useSuitesReminder } from 'src/hooks/SuitesReminder/useSuitesReminder' -import { Popover } from 'antd' +import { useNewExperienceReminder } from 'src/hooks/NewExperienceReminder/useNewExperienceReminder' import MiniMap from 'src/components/navigation/MiniMap/MiniMap' export interface IGlobalNavigationProps { @@ -103,4 +102,4 @@ export const GlobalNavigation = (props: IGlobalNavigationProps) => { ) } -GlobalNavigation.useSuitesReminder = useSuitesReminder +GlobalNavigation.useNewExperienceReminder = useNewExperienceReminder diff --git a/src/hooks/NewExperienceReminder/new-experience-reminder.css b/src/hooks/NewExperienceReminder/new-experience-reminder.css new file mode 100644 index 000000000..7abb17e1b --- /dev/null +++ b/src/hooks/NewExperienceReminder/new-experience-reminder.css @@ -0,0 +1,3 @@ +.globalNavigation__newExperienceReminder.globalNavigation__newExperienceReminder-bottomLeft { + left: calc(var(--nav-width) + var(--margin-xs)) !important; +} \ No newline at end of file diff --git a/src/hooks/SuitesReminder/useSuitesReminder.tsx b/src/hooks/NewExperienceReminder/useNewExperienceReminder.tsx similarity index 84% rename from src/hooks/SuitesReminder/useSuitesReminder.tsx rename to src/hooks/NewExperienceReminder/useNewExperienceReminder.tsx index e23896bf2..0232091f3 100644 --- a/src/hooks/SuitesReminder/useSuitesReminder.tsx +++ b/src/hooks/NewExperienceReminder/useNewExperienceReminder.tsx @@ -1,10 +1,10 @@ import 'src/styles/_variables.css' -import './suites-reminder.css' +import './new-experience-reminder.css' import { type ReactNode } from 'react' import { Button, notification, Space } from 'src/components' import { FontWeightStrong } from 'src/styles/style' -export interface ISuitesReminderOptions { +export interface INewExperienceReminderOptions { onClose: () => void onRemindMeLater: () => void onTakeMeThere: () => void @@ -16,14 +16,14 @@ export interface ISuitesReminderOptions { type OpenNotificationFn = () => void type ContextHolder = ReactNode -export type SuitesReminderHook = [OpenNotificationFn, ContextHolder] +export type NewExperienceReminderHook = [OpenNotificationFn, ContextHolder] const DefaultReminderDuration = 4.5 as const // same as antd notification default duration const DefaultTitle = 'Join the new mParticle Experience!' as const const DefaultMessage = 'Managing your data is easier than ever with the new mParticle experience. Try out the latest features with ease, and switch back to the classic experience anytime from the side navigation.' as const -export const useSuitesReminder = (options: ISuitesReminderOptions): SuitesReminderHook => { +export const useNewExperienceReminder = (options: INewExperienceReminderOptions): NewExperienceReminderHook => { const { onClose, onRemindMeLater, @@ -34,7 +34,7 @@ export const useSuitesReminder = (options: ISuitesReminderOptions): SuitesRemind } = options const [notificationApi, contextHolder] = notification.useNotification({ - prefixCls: 'globalNavigation__suitesReminder', + prefixCls: 'globalNavigation__newExperienceReminder', duration, placement: 'bottomLeft', }) diff --git a/src/hooks/SuitesReminder/suites-reminder.css b/src/hooks/SuitesReminder/suites-reminder.css deleted file mode 100644 index dd42ee5e4..000000000 --- a/src/hooks/SuitesReminder/suites-reminder.css +++ /dev/null @@ -1,3 +0,0 @@ -.globalNavigation__suitesReminder.globalNavigation__suitesReminder-bottomLeft { - left: calc(var(--nav-width) + var(--margin-xs)) !important; -} \ No newline at end of file diff --git a/src/services/user-preferences/composite-user-preferences-service/composite-user-preferences-service.spec.ts b/src/services/user-preferences/composite-user-preferences-service/composite-user-preferences-service.spec.ts new file mode 100644 index 000000000..0aece0f1e --- /dev/null +++ b/src/services/user-preferences/composite-user-preferences-service/composite-user-preferences-service.spec.ts @@ -0,0 +1,203 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { CompositeUserPreferencesService } from 'src/services/user-preferences/composite-user-preferences-service' +import { type UserPreferences } from 'src/services/user-preferences/models/storage-models/user-preferences' +import { type UserPreferenceScope } from 'src/services/user-preferences/models/storage-models/user-preference-scope' +import { type UserPreferenceDefinitions } from 'src/services/user-preferences/models/definitions/user-preference-definitions' +import { UserPreferenceScopeType } from 'src/services/user-preferences/models/definitions/user-preference-scope-type' +import { type UserPreferenceDefinition } from 'src/services/user-preferences/models/definitions/user-preference-definition' +import { + makeBuilderFromDefinition, + TestUserPreferenceDefinitionsFakeFactory, + TestUserPreferenceId, + type TestUserPreferencesFakeBuilder, + TestUserPreferencesFakeFactory, +} from 'src/services/user-preferences/user-preferences-service.spec' + +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() + }) + + 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/composite-user-preferences-service/composite-user-preferences-service.ts b/src/services/user-preferences/composite-user-preferences-service/composite-user-preferences-service.ts new file mode 100644 index 000000000..f0a26eb03 --- /dev/null +++ b/src/services/user-preferences/composite-user-preferences-service/composite-user-preferences-service.ts @@ -0,0 +1,133 @@ +import { type UserPreferences } from 'src/services/user-preferences/models/storage-models/user-preferences' +import { + USER_PREFERENCE_SCOPE_SEPARATOR, + UserPreferenceGlobalScope, + type UserPreferenceScope, +} from 'src/services/user-preferences/models/storage-models/user-preference-scope' +import { type UserPreferenceDefinitions } from 'src/services/user-preferences/models/definitions/user-preference-definitions' +import { type CompositeUserPreferences } from 'src/services/user-preferences/models/user-preferences/composite-user-preferences' +import { type UserPreference } from 'src/services/user-preferences/models/storage-models/user-preference' +import { UserPreferenceScopeType } from 'src/services/user-preferences/models/definitions/user-preference-scope-type' +import cloneDeep from 'lodash/cloneDeep' +import { type UserPreferenceDefinition } from 'src/services/user-preferences/models/definitions/user-preference-definition' + +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), + ) + + return this.createCompositePreferencesFromEntries(entriesByIdAndUserPreference) + } + + 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: + throw new Error('ArgumentError | An invalid allowed scope was provided.') + } + + 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, + ) + } +} diff --git a/src/services/user-preferences/composite-user-preferences-service/index.ts b/src/services/user-preferences/composite-user-preferences-service/index.ts new file mode 100644 index 000000000..2f0e1612f --- /dev/null +++ b/src/services/user-preferences/composite-user-preferences-service/index.ts @@ -0,0 +1 @@ +export * from './composite-user-preferences-service' diff --git a/src/services/user-preferences/models/definitions/index.ts b/src/services/user-preferences/models/definitions/index.ts new file mode 100644 index 000000000..8da4ec469 --- /dev/null +++ b/src/services/user-preferences/models/definitions/index.ts @@ -0,0 +1,3 @@ +export * from './user-preference-definition' +export * from './user-preference-definitions' +export * from './user-preference-scope-type' diff --git a/src/services/user-preferences/models/definitions/user-preference-definition.ts b/src/services/user-preferences/models/definitions/user-preference-definition.ts new file mode 100644 index 000000000..22a5d34f8 --- /dev/null +++ b/src/services/user-preferences/models/definitions/user-preference-definition.ts @@ -0,0 +1,6 @@ +import { type UserPreferenceScopeType } from './user-preference-scope-type' + +export interface UserPreferenceDefinition { + isOptedInByDefault: boolean + allowedScope: UserPreferenceScopeType +} diff --git a/src/services/user-preferences/models/definitions/user-preference-definitions.ts b/src/services/user-preferences/models/definitions/user-preference-definitions.ts new file mode 100644 index 000000000..d9c5e752c --- /dev/null +++ b/src/services/user-preferences/models/definitions/user-preference-definitions.ts @@ -0,0 +1,5 @@ +import { type UserPreferenceDefinition } from './user-preference-definition' + +export type UserPreferenceDefinitions = { + [Id in UserPreferenceId]?: UserPreferenceDefinition +} diff --git a/src/services/user-preferences/models/definitions/user-preference-scope-type.ts b/src/services/user-preferences/models/definitions/user-preference-scope-type.ts new file mode 100644 index 000000000..8d2b771b1 --- /dev/null +++ b/src/services/user-preferences/models/definitions/user-preference-scope-type.ts @@ -0,0 +1,6 @@ +export enum UserPreferenceScopeType { + Global, + LevelOneScope, + LevelTwoScope, + LevelThreeScope, +} diff --git a/src/services/user-preferences/models/storage-models/index.ts b/src/services/user-preferences/models/storage-models/index.ts new file mode 100644 index 000000000..e9256b06c --- /dev/null +++ b/src/services/user-preferences/models/storage-models/index.ts @@ -0,0 +1,3 @@ +export * from './user-preference' +export * from './user-preference-scope' +export * from './user-preferences' diff --git a/src/services/user-preferences/models/storage-models/user-preference-scope.ts b/src/services/user-preferences/models/storage-models/user-preference-scope.ts new file mode 100644 index 000000000..2de1e0453 --- /dev/null +++ b/src/services/user-preferences/models/storage-models/user-preference-scope.ts @@ -0,0 +1,13 @@ +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 diff --git a/src/services/user-preferences/models/storage-models/user-preference.ts b/src/services/user-preferences/models/storage-models/user-preference.ts new file mode 100644 index 000000000..56f487db5 --- /dev/null +++ b/src/services/user-preferences/models/storage-models/user-preference.ts @@ -0,0 +1,3 @@ +export interface UserPreference { + optedIn: boolean +} diff --git a/src/services/user-preferences/models/storage-models/user-preferences.ts b/src/services/user-preferences/models/storage-models/user-preferences.ts new file mode 100644 index 000000000..95b255d78 --- /dev/null +++ b/src/services/user-preferences/models/storage-models/user-preferences.ts @@ -0,0 +1,6 @@ +import { type UserPreferenceScope } from './user-preference-scope' +import { type UserPreference } from './user-preference' + +export type UserPreferences = { + [K in UserPreferenceScope]?: { [Id in UserPreferenceId]: UserPreference } +} diff --git a/src/services/user-preferences/models/user-preferences/composite-user-preferences.ts b/src/services/user-preferences/models/user-preferences/composite-user-preferences.ts new file mode 100644 index 000000000..30ebb2113 --- /dev/null +++ b/src/services/user-preferences/models/user-preferences/composite-user-preferences.ts @@ -0,0 +1,5 @@ +import { type UserPreference } from '../storage-models/user-preference' + +export type CompositeUserPreferences = { + [Id in UserPreferenceId]: UserPreference +} diff --git a/src/services/user-preferences/user-preferences-service.spec.ts b/src/services/user-preferences/user-preferences-service.spec.ts new file mode 100644 index 000000000..786bbb2e2 --- /dev/null +++ b/src/services/user-preferences/user-preferences-service.spec.ts @@ -0,0 +1,313 @@ +/* eslint-disable @typescript-eslint/no-use-before-define */ +import * as Cookies from '../../utils/Cookies' + +import { describe, afterEach, it, expect } from 'vitest' +import { UserPreferencesService } from 'src/services/user-preferences/user-preferences' +import { type UserPreferenceScope } from 'src/services/user-preferences/models/storage-models/user-preference-scope' +import { type UserPreferences } from 'src/services/user-preferences/models/storage-models/user-preferences' +import { type UserPreferenceDefinitions } from 'src/services/user-preferences/models/definitions/user-preference-definitions' +import { CompositeUserPreferencesService } from 'src/services/user-preferences/composite-user-preferences-service' +import { UserPreferenceScopeType } from 'src/services/user-preferences/models/definitions/user-preference-scope-type' +import { type Sync } from 'factory.ts' +import { faker } from '@faker-js/faker' + +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('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 () => { + // arrange + definitions = TestUserPreferenceDefinitionsFakeFactory() as UserPreferenceDefinitions + + const someScope = '1' + setupPreferencesWithScope(definitions, someScope) + + 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) + }) + }) +}) + +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/services/user-preferences/user-preferences.ts b/src/services/user-preferences/user-preferences.ts new file mode 100644 index 000000000..e29eea686 --- /dev/null +++ b/src/services/user-preferences/user-preferences.ts @@ -0,0 +1,82 @@ +/* eslint-disable @typescript-eslint/no-extraneous-class,no-unused-vars,@typescript-eslint/no-unused-vars */ +import * as Cookies from '../../utils/Cookies' +import { type UserPreferences } from 'src/services/user-preferences/models/storage-models/user-preferences' +import { type CompositeUserPreferences } from 'src/services/user-preferences/models/user-preferences/composite-user-preferences' +import { type UserPreferenceScope } from 'src/services/user-preferences/models/storage-models/user-preference-scope' +import { type UserPreferenceDefinitions } from 'src/services/user-preferences/models/definitions/user-preference-definitions' +import { type CompositeUserPreferencesService } from 'src/services/user-preferences/composite-user-preferences-service' + +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, + private readonly onUpdate?: (resolvedPreferences: CompositeUserPreferences) => void, + ) {} + + public async init(): Promise { + const storedPreferences = await this.getStoredPreferences() + + this.preferences = this.compositeUserPreferencesService.getScopedUserPreferences( + storedPreferences, + this.currentScope, + this.definitions, + ) + + this.onUpdate?.(this.preferences) + } + + 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, + ) + + this.onUpdate?.(this.preferences) + + // 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: this.dateFormatter(), + }) + + await Promise.resolve() + } +} diff --git a/src/utils/Cookies.ts b/src/utils/Cookies.ts new file mode 100644 index 000000000..267b7c3e1 --- /dev/null +++ b/src/utils/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/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',