From 7d642208522d36ba6f9a10a533972d1448817be8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Apr 2024 17:35:48 +0000 Subject: [PATCH 01/82] chore(deps): update dependency babel-plugin-formatjs to v10.5.14 --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37648b1c..091761a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,7 @@ "@edx/browserslist-config": "^1.1.1", "@edx/reactifex": "1.1.0", "@openedx/frontend-build": "13.0.28", - "babel-plugin-formatjs": "10.5.13", + "babel-plugin-formatjs": "10.5.14", "eslint-plugin-import": "2.29.1", "glob": "7.2.3", "history": "5.3.0", @@ -7022,9 +7022,9 @@ } }, "node_modules/babel-plugin-formatjs": { - "version": "10.5.13", - "resolved": "https://registry.npmjs.org/babel-plugin-formatjs/-/babel-plugin-formatjs-10.5.13.tgz", - "integrity": "sha512-0dtMhoa6q0P5lUHBphLd8/y+CRlh5IG3Rq+Wk64kOEDwUVZF8xq1qMSjC3iw1wH4tmP2qTzBL+Bz1xEjyBd/ew==", + "version": "10.5.14", + "resolved": "https://registry.npmjs.org/babel-plugin-formatjs/-/babel-plugin-formatjs-10.5.14.tgz", + "integrity": "sha512-F++4Txa2bxrjbvspLxIXBDwcbKOftUvGgBDlz0hGe/fp7eXSPBJcu49xWj0/5VyMgSad4gKmBzWE5bfC27x19Q==", "dependencies": { "@babel/core": "^7.10.4", "@babel/helper-plugin-utils": "^7.10.4", diff --git a/package.json b/package.json index 3aa662c1..02a75d25 100644 --- a/package.json +++ b/package.json @@ -74,7 +74,7 @@ "@edx/browserslist-config": "^1.1.1", "@edx/reactifex": "1.1.0", "@openedx/frontend-build": "13.0.28", - "babel-plugin-formatjs": "10.5.13", + "babel-plugin-formatjs": "10.5.14", "eslint-plugin-import": "2.29.1", "glob": "7.2.3", "history": "5.3.0", From faf4ff84889a43ea21f5e03a4b30d47950c3c25a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Apr 2024 19:13:00 +0000 Subject: [PATCH 02/82] fix(deps): update dependency @edx/frontend-platform to v7.1.3 --- package-lock.json | 24 ++++++++++++------------ package.json | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 091761a3..33a45337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "AGPL-3.0", "dependencies": { "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", - "@edx/frontend-platform": "7.1.2", + "@edx/frontend-platform": "7.1.3", "@edx/openedx-atlas": "^0.6.0", "@fortawesome/fontawesome-svg-core": "6.5.1", "@fortawesome/free-brands-svg-icons": "6.5.1", @@ -2283,9 +2283,9 @@ } }, "node_modules/@edx/frontend-platform": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-7.1.2.tgz", - "integrity": "sha512-iKd8n6QoFjaATEfLOYe+gsJLHFoGWpZbX2Z4D6hM+OthTISE98K8+/7Gx7PAcT9vlJEA33OVIThncZYwYaaluw==", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@edx/frontend-platform/-/frontend-platform-7.1.3.tgz", + "integrity": "sha512-klw0pvFnAo8k8iVPxcR9WCPLaYqV2AFdnz1COF6hJ6gOIFuj2Mb5cuEy9di89INpZFO/0FEHq1IBVojm1rbJcg==", "dependencies": { "@cospired/i18n-iso-languages": "4.2.0", "@formatjs/intl-pluralrules": "4.3.3", @@ -2304,7 +2304,7 @@ "lodash.merge": "4.6.2", "lodash.snakecase": "4.1.1", "pubsub-js": "1.9.4", - "react-intl": "6.6.2", + "react-intl": "6.6.3", "universal-cookie": "4.0.4" }, "bin": { @@ -2573,9 +2573,9 @@ } }, "node_modules/@formatjs/intl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-2.10.0.tgz", - "integrity": "sha512-X3xT9guVkKDS86EKV80lS0KxoazUglkJTGZO66sKY7otgl0VeStPA8B3u8UkKT47PexVV98fUzjpkchYmbe9nw==", + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@formatjs/intl/-/intl-2.10.1.tgz", + "integrity": "sha512-dsLG15U7xDi8yzKf4hcAWSsCaez3XrjTO2oaRHPyHtXLm1aEzYbDw6bClo/HMHu+iwS5GbDqT3DV+hYP2ylScg==", "dependencies": { "@formatjs/ecma402-abstract": "1.18.2", "@formatjs/fast-memoize": "2.2.0", @@ -18118,13 +18118,13 @@ } }, "node_modules/react-intl": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.2.tgz", - "integrity": "sha512-IpW2IkLtGENSFlX3vfH11rjuCIsW0VyjT0Q1pPKMZPtT2z1FxLt4weFT5Ezti2TScT1xiyb3aQBFth9EB7jzAg==", + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-6.6.3.tgz", + "integrity": "sha512-vLKI0f+Q3pXD5szmCUPikTY7CJDPtxCBMG5YABQZ3IGEKzNB47zlvXyasUFfT25zpgQXeOfhRCdx4q6ubuR6bA==", "dependencies": { "@formatjs/ecma402-abstract": "1.18.2", "@formatjs/icu-messageformat-parser": "2.7.6", - "@formatjs/intl": "2.10.0", + "@formatjs/intl": "2.10.1", "@formatjs/intl-displaynames": "6.6.6", "@formatjs/intl-listformat": "7.5.5", "@types/hoist-non-react-statics": "^3.3.1", diff --git a/package.json b/package.json index 02a75d25..57a05824 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", - "@edx/frontend-platform": "7.1.2", + "@edx/frontend-platform": "7.1.3", "@edx/openedx-atlas": "^0.6.0", "@fortawesome/fontawesome-svg-core": "6.5.1", "@fortawesome/free-brands-svg-icons": "6.5.1", From cf2b50005bea8255194fc281303b1f1706153f01 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 10 Apr 2024 21:26:38 +0000 Subject: [PATCH 03/82] fix(deps): update font awesome to v6.5.2 --- package-lock.json | 36 ++++++++++++++++++------------------ package.json | 6 +++--- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33a45337..c6bf1d4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,9 @@ "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/frontend-platform": "7.1.3", "@edx/openedx-atlas": "^0.6.0", - "@fortawesome/fontawesome-svg-core": "6.5.1", - "@fortawesome/free-brands-svg-icons": "6.5.1", - "@fortawesome/free-solid-svg-icons": "6.5.1", + "@fortawesome/fontawesome-svg-core": "6.5.2", + "@fortawesome/free-brands-svg-icons": "6.5.2", + "@fortawesome/free-solid-svg-icons": "6.5.2", "@fortawesome/react-fontawesome": "0.2.0", "@openedx/paragon": "^22.1.1", "@optimizely/react-sdk": "^2.9.1", @@ -2694,45 +2694,45 @@ } }, "node_modules/@fortawesome/fontawesome-common-types": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", - "integrity": "sha512-GkWzv+L6d2bI5f/Vk6ikJ9xtl7dfXtoRu3YGE6nq0p/FFqA1ebMOAWg3XgRyb0I6LYyYkiAo+3/KrwuBp8xG7A==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz", + "integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==", "hasInstallScript": true, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/fontawesome-svg-core": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.1.tgz", - "integrity": "sha512-MfRCYlQPXoLlpem+egxjfkEuP9UQswTrlCOsknus/NcMoblTH2g0jPrapbcIb04KGA7E2GZxbAccGZfWoYgsrQ==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz", + "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.5.2" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-brands-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.1.tgz", - "integrity": "sha512-093l7DAkx0aEtBq66Sf19MgoZewv1zeY9/4C7vSKPO4qMwEsW/2VYTUTpBtLwfb9T2R73tXaRDPmE4UqLCYHfg==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.2.tgz", + "integrity": "sha512-zi5FNYdmKLnEc0jc0uuHH17kz/hfYTg4Uei0wMGzcoCL/4d3WM3u1VMc0iGGa31HuhV5i7ZK8ZlTCQrHqRHSGQ==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.5.2" }, "engines": { "node": ">=6" } }, "node_modules/@fortawesome/free-solid-svg-icons": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.1.tgz", - "integrity": "sha512-S1PPfU3mIJa59biTtXJz1oI0+KAXW6bkAb31XKhxdxtuXDiUIFsih4JR1v5BbxY7hVHsD1RKq+jRkVRaf773NQ==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz", + "integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==", "hasInstallScript": true, "dependencies": { - "@fortawesome/fontawesome-common-types": "6.5.1" + "@fortawesome/fontawesome-common-types": "6.5.2" }, "engines": { "node": ">=6" diff --git a/package.json b/package.json index 57a05824..28904b24 100644 --- a/package.json +++ b/package.json @@ -35,9 +35,9 @@ "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/frontend-platform": "7.1.3", "@edx/openedx-atlas": "^0.6.0", - "@fortawesome/fontawesome-svg-core": "6.5.1", - "@fortawesome/free-brands-svg-icons": "6.5.1", - "@fortawesome/free-solid-svg-icons": "6.5.1", + "@fortawesome/fontawesome-svg-core": "6.5.2", + "@fortawesome/free-brands-svg-icons": "6.5.2", + "@fortawesome/free-solid-svg-icons": "6.5.2", "@fortawesome/react-fontawesome": "0.2.0", "@openedx/paragon": "^22.1.1", "@optimizely/react-sdk": "^2.9.1", From 65e95a4d1bc5570fd0322d599f497b7d2312bd78 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 01:48:26 +0000 Subject: [PATCH 04/82] chore(deps): update dependency @openedx/frontend-build to v13.1.4 --- package-lock.json | 450 +++++++++++++++++++++++++++++++++++++++------- package.json | 2 +- 2 files changed, 387 insertions(+), 65 deletions(-) diff --git a/package-lock.json b/package-lock.json index c6bf1d4d..6ef5d2e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,7 +50,7 @@ "devDependencies": { "@edx/browserslist-config": "^1.1.1", "@edx/reactifex": "1.1.0", - "@openedx/frontend-build": "13.0.28", + "@openedx/frontend-build": "13.1.4", "babel-plugin-formatjs": "10.5.14", "eslint-plugin-import": "2.29.1", "glob": "7.2.3", @@ -2167,9 +2167,9 @@ } }, "node_modules/@csstools/cascade-layer-name-parser": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.7.tgz", - "integrity": "sha512-9J4aMRJ7A2WRjaRLvsMeWrL69FmEuijtiW1XlK/sG+V0UJiHVYUyvj9mY4WAXfU/hGIiGOgL8e0jJcRyaZTjDQ==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.9.tgz", + "integrity": "sha512-RRqNjxTZDUhx7pxYOBG/AkCVmPS3zYzfE47GEhIGkFuWFTQGJBgWOUUkKNo5MfxIfjDz5/1L3F3rF1oIsYaIpw==", "funding": [ { "type": "github", @@ -2184,14 +2184,14 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^2.6.1", + "@csstools/css-tokenizer": "^2.2.4" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.5.0.tgz", - "integrity": "sha512-abypo6m9re3clXA00eu5syw+oaPHbJTPapu9C4pzNsJ4hdZDzushT50Zhu+iIYXgEe1CxnRMn7ngsbV+MLrlpQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.6.1.tgz", + "integrity": "sha512-ubEkAaTfVZa+WwGhs5jbo5Xfqpeaybr/RvWzvFxRs4jfq16wH8l8Ty/QEEpINxll4xhuGfdMbipRyz5QZh9+FA==", "funding": [ { "type": "github", @@ -2206,13 +2206,13 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-tokenizer": "^2.2.4" } }, "node_modules/@csstools/css-tokenizer": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.2.3.tgz", - "integrity": "sha512-pp//EvZ9dUmGuGtG1p+n17gTHEOqu9jO+FiCUjNN3BDmyhdA2Jq9QsVeR7K8/2QCK17HSsioPlTW9ZkzoWb3Lg==", + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.2.4.tgz", + "integrity": "sha512-PuWRAewQLbDhGeTvFuq2oClaSCKPIBmHyIobCV39JHRYN0byDcUWJl5baPeNUcqrjtdMNqFooE0FGl31I3JOqw==", "funding": [ { "type": "github", @@ -2228,9 +2228,9 @@ } }, "node_modules/@csstools/media-query-list-parser": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.7.tgz", - "integrity": "sha512-lHPKJDkPUECsyAvD60joYfDmp8UERYxHGkFfyLJFTVK/ERJe0sVlIFLXU5XFxdjNDTerp5L4KeaKG+Z5S94qxQ==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.9.tgz", + "integrity": "sha512-qqGuFfbn4rUmyOB0u8CVISIp5FfJ5GAR3mBrZ9/TKndHakdnm6pY0L/fbLcpPnrzwCyyTEZl1nUcXAYHEWneTA==", "funding": [ { "type": "github", @@ -2245,8 +2245,8 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.5.0", - "@csstools/css-tokenizer": "^2.2.3" + "@csstools/css-parser-algorithms": "^2.6.1", + "@csstools/css-tokenizer": "^2.2.4" } }, "node_modules/@discoveryjs/json-ext": { @@ -3932,9 +3932,9 @@ } }, "node_modules/@openedx/frontend-build": { - "version": "13.0.28", - "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-13.0.28.tgz", - "integrity": "sha512-9CSTpA7EGlC5lHaH03X3E1YpCp2SPsJ5It7905KimacGlliK0yf8FZIyluOCm3jSGQ5u/xjvZyw6xbl0XJCKZA==", + "version": "13.1.4", + "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-13.1.4.tgz", + "integrity": "sha512-YqXU6KgFnmDD/vGLvq/A9NP6R8lHfaEx64ajQC50ebFMlF3J7HWUruct4PuroeTupq9UAfRZUilzQHYQZjV08A==", "dependencies": { "@babel/cli": "7.22.5", "@babel/core": "7.22.5", @@ -3950,7 +3950,7 @@ "@fullhuman/postcss-purgecss": "5.0.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.11", "@svgr/webpack": "8.1.0", - "autoprefixer": "10.4.16", + "autoprefixer": "10.4.19", "babel-jest": "26.6.3", "babel-loader": "9.1.3", "babel-plugin-formatjs": "^10.4.0", @@ -3964,19 +3964,20 @@ "dotenv-webpack": "8.0.1", "eslint": "8.44.0", "eslint-config-airbnb": "19.0.4", + "eslint-plugin-formatjs": "^4.12.2", "eslint-plugin-import": "2.27.5", "eslint-plugin-jsx-a11y": "6.7.1", "eslint-plugin-react": "7.32.2", "eslint-plugin-react-hooks": "4.6.0", - "express": "4.18.2", + "express": "^4.18.2", "file-loader": "6.2.0", "html-webpack-plugin": "5.6.0", "identity-obj-proxy": "3.0.0", "image-minimizer-webpack-plugin": "3.8.3", "jest": "26.6.3", "mini-css-extract-plugin": "1.6.2", - "postcss": "8.4.33", - "postcss-custom-media": "10.0.2", + "postcss": "8.4.38", + "postcss-custom-media": "10.0.4", "postcss-loader": "7.3.4", "postcss-rtlcss": "5.1.0", "react-dev-utils": "12.0.1", @@ -3988,11 +3989,11 @@ "source-map-loader": "4.0.2", "style-loader": "3.3.4", "url-loader": "4.1.1", - "webpack": "5.89.0", - "webpack-bundle-analyzer": "4.10.1", - "webpack-cli": "5.1.4", - "webpack-dev-server": "4.15.1", - "webpack-merge": "5.10.0" + "webpack": "^5.89.0", + "webpack-bundle-analyzer": "^4.10.1", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.10.0" }, "bin": { "fedx-scripts": "bin/fedx-scripts.js" @@ -4792,9 +4793,9 @@ } }, "node_modules/@openedx/frontend-build/node_modules/jest-snapshot/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dependencies": { "lru-cache": "^6.0.0" }, @@ -6007,6 +6008,11 @@ "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==" }, + "node_modules/@types/picomatch": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@types/picomatch/-/picomatch-2.3.3.tgz", + "integrity": "sha512-Yll76ZHikRFCyz/pffKGjrCwe/le2CDwOP5F210KQo27kpRE46U2rDnzikNlVn6/ezH3Mhn46bJMTfeVTtcYMg==" + }, "node_modules/@types/prettier": { "version": "2.7.3", "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", @@ -6074,6 +6080,11 @@ "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" }, + "node_modules/@types/semver": { + "version": "7.5.8", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", + "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==" + }, "node_modules/@types/send": { "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", @@ -6140,6 +6151,229 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz", + "integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz", + "integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==", + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz", + "integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/visitor-keys": "6.21.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "minimatch": "9.0.3", + "semver": "^7.5.4", + "ts-api-utils": "^1.0.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", + "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/@typescript-eslint/utils": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz", + "integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "@types/json-schema": "^7.0.12", + "@types/semver": "^7.5.0", + "@typescript-eslint/scope-manager": "6.21.0", + "@typescript-eslint/types": "6.21.0", + "@typescript-eslint/typescript-estree": "6.21.0", + "semver": "^7.5.4" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/semver": { + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", + "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz", + "integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==", + "dependencies": { + "@typescript-eslint/types": "6.21.0", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^16.0.0 || >=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", @@ -6838,9 +7072,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.16", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.16.tgz", - "integrity": "sha512-7vd3UC6xKp0HLfua5IjZlcXvGAGy7cBAXTg2lyQ/8WpNhd6SiZ8Be+xm3FyBSYJx5GKcpRCzBh7RH4/0dnY+uQ==", + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", "funding": [ { "type": "opencollective", @@ -6856,9 +7090,9 @@ } ], "dependencies": { - "browserslist": "^4.21.10", - "caniuse-lite": "^1.0.30001538", - "fraction.js": "^4.3.6", + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", + "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.0", "postcss-value-parser": "^4.2.0" @@ -7413,9 +7647,9 @@ "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", + "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", "funding": [ { "type": "opencollective", @@ -7431,8 +7665,8 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", + "caniuse-lite": "^1.0.30001587", + "electron-to-chromium": "^1.4.668", "node-releases": "^2.0.14", "update-browserslist-db": "^1.0.13" }, @@ -7564,9 +7798,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001579", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", - "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", + "version": "1.0.30001608", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001608.tgz", + "integrity": "sha512-cjUJTQkk9fQlJR2s4HMuPMvTiRggl0rAVMtthQuyOlDWuqHXqN8azLq+pi8B2TjwKJ32diHjUqRIKeFX4z1FoA==", "funding": [ { "type": "opencollective", @@ -9015,9 +9249,9 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, "node_modules/electron-to-chromium": { - "version": "1.4.643", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.643.tgz", - "integrity": "sha512-QHscvvS7gt155PtoRC0dR2ilhL8E9LHhfTQEq1uD5AL0524rBLAwpAREFH06f87/e45B9XkR6Ki5dbhbCsVEIg==" + "version": "1.4.733", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.733.tgz", + "integrity": "sha512-gUI9nhI2iBGF0OaYYLKOaOtliFMl+Bt1rY7VmEjwxOxqoYLub/D9xmduPEhbw2imE6gYkJKhIE5it+KE2ulVxQ==" }, "node_modules/email-prop-type": { "version": "3.0.1", @@ -9427,6 +9661,59 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-formatjs": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-formatjs/-/eslint-plugin-formatjs-4.13.0.tgz", + "integrity": "sha512-sxgHQNyVclNRO7aydGwxohwxYR03/oRDW0uUXFWayNMPTlnb9sET3LCovBjvQF7qAHDGFDcLwg4ECSyui4nG8A==", + "dependencies": { + "@formatjs/icu-messageformat-parser": "2.7.6", + "@formatjs/ts-transformer": "3.13.12", + "@types/eslint": "7 || 8", + "@types/picomatch": "^2.3.0", + "@typescript-eslint/utils": "^6.18.1", + "emoji-regex": "^10.2.1", + "magic-string": "^0.30.0", + "picomatch": "^2.3.1", + "tslib": "2.6.2", + "typescript": "5", + "unicode-emoji-utils": "^1.2.0" + }, + "peerDependencies": { + "eslint": "7 || 8" + } + }, + "node_modules/eslint-plugin-formatjs/node_modules/@formatjs/ts-transformer": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@formatjs/ts-transformer/-/ts-transformer-3.13.12.tgz", + "integrity": "sha512-uf1+DgbsCrzHAg7uIf0QlzpIkHYxRSRig5iJa9FaoUNIDZzNEE2oW/uLLLq7I9Z2FLIPhbmgq8hbW40FoQv+Fg==", + "dependencies": { + "@formatjs/icu-messageformat-parser": "2.7.6", + "@types/json-stable-stringify": "^1.0.32", + "@types/node": "14 || 16 || 17", + "chalk": "^4.0.0", + "json-stable-stringify": "^1.0.1", + "tslib": "^2.4.0", + "typescript": "5" + }, + "peerDependencies": { + "ts-jest": ">=27" + }, + "peerDependenciesMeta": { + "ts-jest": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-formatjs/node_modules/@types/node": { + "version": "17.0.45", + "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==" + }, + "node_modules/eslint-plugin-formatjs/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, "node_modules/eslint-plugin-import": { "version": "2.29.1", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", @@ -15628,6 +15915,17 @@ "lz-string": "bin/bin.js" } }, + "node_modules/magic-string": { + "version": "0.30.9", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.9.tgz", + "integrity": "sha512-S1+hd+dIrC8EZqKyT9DstTH/0Z+f76kmmvZnkfQVmOpDEF9iVgdYif3Q/pIWHmCoo59bQVGW0kVL3e2nl+9+Sw==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/mailto-link": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-2.0.0.tgz", @@ -16917,9 +17215,9 @@ } }, "node_modules/postcss": { - "version": "8.4.33", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.33.tgz", - "integrity": "sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==", + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "funding": [ { "type": "opencollective", @@ -16937,7 +17235,7 @@ "dependencies": { "nanoid": "^3.3.7", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" @@ -16991,9 +17289,9 @@ } }, "node_modules/postcss-custom-media": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-10.0.2.tgz", - "integrity": "sha512-zcEFNRmDm2fZvTPdI1pIW3W//UruMcLosmMiCdpQnrCsTRzWlKQPYMa1ud9auL0BmrryKK1+JjIGn19K0UjO/w==", + "version": "10.0.4", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-10.0.4.tgz", + "integrity": "sha512-Ubs7O3wj2prghaKRa68VHBvuy3KnTQ0zbGwqDYY1mntxJD0QL2AeiAy+AMfl3HBedTCVr2IcFNktwty9YpSskA==", "funding": [ { "type": "github", @@ -17005,10 +17303,10 @@ } ], "dependencies": { - "@csstools/cascade-layer-name-parser": "^1.0.5", - "@csstools/css-parser-algorithms": "^2.3.2", - "@csstools/css-tokenizer": "^2.2.1", - "@csstools/media-query-list-parser": "^2.1.5" + "@csstools/cascade-layer-name-parser": "^1.0.9", + "@csstools/css-parser-algorithms": "^2.6.1", + "@csstools/css-tokenizer": "^2.2.4", + "@csstools/media-query-list-parser": "^2.1.9" }, "engines": { "node": "^14 || ^16 || >=18" @@ -20042,9 +20340,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "engines": { "node": ">=0.10.0" } @@ -20980,6 +21278,17 @@ "node": ">=8" } }, + "node_modules/ts-api-utils": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", + "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "typescript": ">=4.2.0" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -21211,6 +21520,19 @@ "node": ">=4" } }, + "node_modules/unicode-emoji-utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/unicode-emoji-utils/-/unicode-emoji-utils-1.2.0.tgz", + "integrity": "sha512-djUB91p/6oYpgps4W5K/MAvM+UspoAANHSUW495BrxeLRoned3iNPEDQgrKx9LbLq93VhNz0NWvI61vcfrwYoA==", + "dependencies": { + "emoji-regex": "10.3.0" + } + }, + "node_modules/unicode-emoji-utils/node_modules/emoji-regex": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.3.0.tgz", + "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==" + }, "node_modules/unicode-match-property-ecmascript": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", diff --git a/package.json b/package.json index 28904b24..5b938c2c 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "devDependencies": { "@edx/browserslist-config": "^1.1.1", "@edx/reactifex": "1.1.0", - "@openedx/frontend-build": "13.0.28", + "@openedx/frontend-build": "13.1.4", "babel-plugin-formatjs": "10.5.14", "eslint-plugin-import": "2.29.1", "glob": "7.2.3", From 564ec70d9e3d1bdd4b904184f0be047717776f83 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 04:08:08 +0000 Subject: [PATCH 05/82] fix(deps): update dependency @openedx/paragon to v22.2.1 --- package-lock.json | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ef5d2e5..824226d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5055,9 +5055,16 @@ } }, "node_modules/@openedx/paragon": { - "version": "22.1.1", - "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.1.1.tgz", - "integrity": "sha512-XPRuV9zn7BeCIYfU5kE2XZ4YevjA0wfS/fuydB8Ta/aNY1dw9fQ7CjHOIfkZqDic4Jygusj/uhE/1WYJD8kvyw==", + "version": "22.2.1", + "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.2.1.tgz", + "integrity": "sha512-Dd7PzvHwNnUokqbFkuOpugJZ9dHaUBOcYwqAA2aMoN7tgi4xEZWsfDFyP1+se2UPuR7NvNGammEesLAwGQ0Ylw==", + "workspaces": [ + "example", + "component-generator", + "www", + "icons", + "dependent-usage-analyzer" + ], "dependencies": { "@fortawesome/fontawesome-svg-core": "^6.1.1", "@fortawesome/react-fontawesome": "^0.1.18", From 611af073268759470187c8f3edf863c73ec85548 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 06:43:02 +0000 Subject: [PATCH 06/82] fix(deps): update dependency algoliasearch to v4.23.3 --- package-lock.json | 183 +++++++++++++++++++++++++--------------------- 1 file changed, 101 insertions(+), 82 deletions(-) diff --git a/package-lock.json b/package-lock.json index 824226d7..5dad67b3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,74 +69,74 @@ } }, "node_modules/@algolia/cache-browser-local-storage": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.22.1.tgz", - "integrity": "sha512-Sw6IAmOCvvP6QNgY9j+Hv09mvkvEIDKjYW8ow0UDDAxSXy664RBNQk3i/0nt7gvceOJ6jGmOTimaZoY1THmU7g==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.23.3.tgz", + "integrity": "sha512-vRHXYCpPlTDE7i6UOy2xE03zHF2C8MEFjPN2v7fRbqVpcOvAUQK81x3Kc21xyb5aSIpYCjWCZbYZuz8Glyzyyg==", "dependencies": { - "@algolia/cache-common": "4.22.1" + "@algolia/cache-common": "4.23.3" } }, "node_modules/@algolia/cache-common": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.22.1.tgz", - "integrity": "sha512-TJMBKqZNKYB9TptRRjSUtevJeQVXRmg6rk9qgFKWvOy8jhCPdyNZV1nB3SKGufzvTVbomAukFR8guu/8NRKBTA==" + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/cache-common/-/cache-common-4.23.3.tgz", + "integrity": "sha512-h9XcNI6lxYStaw32pHpB1TMm0RuxphF+Ik4o7tcQiodEdpKK+wKufY6QXtba7t3k8eseirEMVB83uFFF3Nu54A==" }, "node_modules/@algolia/cache-in-memory": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.22.1.tgz", - "integrity": "sha512-ve+6Ac2LhwpufuWavM/aHjLoNz/Z/sYSgNIXsinGofWOysPilQZPUetqLj8vbvi+DHZZaYSEP9H5SRVXnpsNNw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/cache-in-memory/-/cache-in-memory-4.23.3.tgz", + "integrity": "sha512-yvpbuUXg/+0rbcagxNT7un0eo3czx2Uf0y4eiR4z4SD7SiptwYTpbuS0IHxcLHG3lq22ukx1T6Kjtk/rT+mqNg==", "dependencies": { - "@algolia/cache-common": "4.22.1" + "@algolia/cache-common": "4.23.3" } }, "node_modules/@algolia/client-account": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.22.1.tgz", - "integrity": "sha512-k8m+oegM2zlns/TwZyi4YgCtyToackkOpE+xCaKCYfBfDtdGOaVZCM5YvGPtK+HGaJMIN/DoTL8asbM3NzHonw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-account/-/client-account-4.23.3.tgz", + "integrity": "sha512-hpa6S5d7iQmretHHF40QGq6hz0anWEHGlULcTIT9tbUssWUriN9AUXIFQ8Ei4w9azD0hc1rUok9/DeQQobhQMA==", "dependencies": { - "@algolia/client-common": "4.22.1", - "@algolia/client-search": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/client-common": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/client-analytics": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.22.1.tgz", - "integrity": "sha512-1ssi9pyxyQNN4a7Ji9R50nSdISIumMFDwKNuwZipB6TkauJ8J7ha/uO60sPJFqQyqvvI+px7RSNRQT3Zrvzieg==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-4.23.3.tgz", + "integrity": "sha512-LBsEARGS9cj8VkTAVEZphjxTjMVCci+zIIiRhpFun9jGDUlS1XmhCW7CTrnaWeIuCQS/2iPyRqSy1nXPjcBLRA==", "dependencies": { - "@algolia/client-common": "4.22.1", - "@algolia/client-search": "4.22.1", - "@algolia/requester-common": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/client-common": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/client-common": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.22.1.tgz", - "integrity": "sha512-IvaL5v9mZtm4k4QHbBGDmU3wa/mKokmqNBqPj0K7lcR8ZDKzUorhcGp/u8PkPC/e0zoHSTvRh7TRkGX3Lm7iOQ==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-4.23.3.tgz", + "integrity": "sha512-l6EiPxdAlg8CYhroqS5ybfIczsGUIAC47slLPOMDeKSVXYG1n0qGiz4RjAHLw2aD0xzh2EXZ7aRguPfz7UKDKw==", "dependencies": { - "@algolia/requester-common": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/client-personalization": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.22.1.tgz", - "integrity": "sha512-sl+/klQJ93+4yaqZ7ezOttMQ/nczly/3GmgZXJ1xmoewP5jmdP/X/nV5U7EHHH3hCUEHeN7X1nsIhGPVt9E1cQ==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-4.23.3.tgz", + "integrity": "sha512-3E3yF3Ocr1tB/xOZiuC3doHQBQ2zu2MPTYZ0d4lpfWads2WTKG7ZzmGnsHmm63RflvDeLK/UVx7j2b3QuwKQ2g==", "dependencies": { - "@algolia/client-common": "4.22.1", - "@algolia/requester-common": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/client-common": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/client-search": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.22.1.tgz", - "integrity": "sha512-yb05NA4tNaOgx3+rOxAmFztgMTtGBi97X7PC3jyNeGiwkAjOZc2QrdZBYyIdcDLoI09N0gjtpClcackoTN0gPA==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-4.23.3.tgz", + "integrity": "sha512-P4VAKFHqU0wx9O+q29Q8YVuaowaZ5EM77rxfmGnkHUJggh28useXQdopokgwMeYw2XUht49WX5RcTQ40rZIabw==", "dependencies": { - "@algolia/client-common": "4.22.1", - "@algolia/requester-common": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/client-common": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/events": { @@ -145,47 +145,65 @@ "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==" }, "node_modules/@algolia/logger-common": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.22.1.tgz", - "integrity": "sha512-OnTFymd2odHSO39r4DSWRFETkBufnY2iGUZNrMXpIhF5cmFE8pGoINNPzwg02QLBlGSaLqdKy0bM8S0GyqPLBg==" + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/logger-common/-/logger-common-4.23.3.tgz", + "integrity": "sha512-y9kBtmJwiZ9ZZ+1Ek66P0M68mHQzKRxkW5kAAXYN/rdzgDN0d2COsViEFufxJ0pb45K4FRcfC7+33YB4BLrZ+g==" }, "node_modules/@algolia/logger-console": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.22.1.tgz", - "integrity": "sha512-O99rcqpVPKN1RlpgD6H3khUWylU24OXlzkavUAMy6QZd1776QAcauE3oP8CmD43nbaTjBexZj2nGsBH9Tc0FVA==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/logger-console/-/logger-console-4.23.3.tgz", + "integrity": "sha512-8xoiseoWDKuCVnWP8jHthgaeobDLolh00KJAdMe9XPrWPuf1by732jSpgy2BlsLTaT9m32pHI8CRfrOqQzHv3A==", "dependencies": { - "@algolia/logger-common": "4.22.1" + "@algolia/logger-common": "4.23.3" + } + }, + "node_modules/@algolia/recommend": { + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-4.23.3.tgz", + "integrity": "sha512-9fK4nXZF0bFkdcLBRDexsnGzVmu4TSYZqxdpgBW2tEyfuSSY54D4qSRkLmNkrrz4YFvdh2GM1gA8vSsnZPR73w==", + "dependencies": { + "@algolia/cache-browser-local-storage": "4.23.3", + "@algolia/cache-common": "4.23.3", + "@algolia/cache-in-memory": "4.23.3", + "@algolia/client-common": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/logger-common": "4.23.3", + "@algolia/logger-console": "4.23.3", + "@algolia/requester-browser-xhr": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/requester-node-http": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/@algolia/requester-browser-xhr": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.22.1.tgz", - "integrity": "sha512-dtQGYIg6MteqT1Uay3J/0NDqD+UciHy3QgRbk7bNddOJu+p3hzjTRYESqEnoX/DpEkaNYdRHUKNylsqMpgwaEw==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.23.3.tgz", + "integrity": "sha512-jDWGIQ96BhXbmONAQsasIpTYWslyjkiGu0Quydjlowe+ciqySpiDUrJHERIRfELE5+wFc7hc1Q5hqjGoV7yghw==", "dependencies": { - "@algolia/requester-common": "4.22.1" + "@algolia/requester-common": "4.23.3" } }, "node_modules/@algolia/requester-common": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.22.1.tgz", - "integrity": "sha512-dgvhSAtg2MJnR+BxrIFqlLtkLlVVhas9HgYKMk2Uxiy5m6/8HZBL40JVAMb2LovoPFs9I/EWIoFVjOrFwzn5Qg==" + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/requester-common/-/requester-common-4.23.3.tgz", + "integrity": "sha512-xloIdr/bedtYEGcXCiF2muajyvRhwop4cMZo+K2qzNht0CMzlRkm8YsDdj5IaBhshqfgmBb3rTg4sL4/PpvLYw==" }, "node_modules/@algolia/requester-node-http": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.22.1.tgz", - "integrity": "sha512-JfmZ3MVFQkAU+zug8H3s8rZ6h0ahHZL/SpMaSasTCGYR5EEJsCc8SI5UZ6raPN2tjxa5bxS13BRpGSBUens7EA==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-4.23.3.tgz", + "integrity": "sha512-zgu++8Uj03IWDEJM3fuNl34s746JnZOWn1Uz5taV1dFyJhVM/kTNw9Ik7YJWiUNHJQXcaD8IXD1eCb0nq/aByA==", "dependencies": { - "@algolia/requester-common": "4.22.1" + "@algolia/requester-common": "4.23.3" } }, "node_modules/@algolia/transporter": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.22.1.tgz", - "integrity": "sha512-kzWgc2c9IdxMa3YqA6TN0NW5VrKYYW/BELIn7vnLyn+U/RFdZ4lxxt9/8yq3DKV5snvoDzzO4ClyejZRdV3lMQ==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/@algolia/transporter/-/transporter-4.23.3.tgz", + "integrity": "sha512-Wjl5gttqnf/gQKJA+dafnD0Y6Yw97yvfY8R9h0dQltX1GXTgNs1zWgvtWW0tHl1EgMdhAyw189uWiZMnL3QebQ==", "dependencies": { - "@algolia/cache-common": "4.22.1", - "@algolia/logger-common": "4.22.1", - "@algolia/requester-common": "4.22.1" + "@algolia/cache-common": "4.23.3", + "@algolia/logger-common": "4.23.3", + "@algolia/requester-common": "4.23.3" } }, "node_modules/@ampproject/remapping": { @@ -6741,24 +6759,25 @@ } }, "node_modules/algoliasearch": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.22.1.tgz", - "integrity": "sha512-jwydKFQJKIx9kIZ8Jm44SdpigFwRGPESaxZBaHSV0XWN2yBJAOT4mT7ppvlrpA4UGzz92pqFnVKr/kaZXrcreg==", + "version": "4.23.3", + "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-4.23.3.tgz", + "integrity": "sha512-Le/3YgNvjW9zxIQMRhUHuhiUjAlKY/zsdZpfq4dlLqg6mEm0nL6yk+7f2hDOtLpxsgE4jSzDmvHL7nXdBp5feg==", "dependencies": { - "@algolia/cache-browser-local-storage": "4.22.1", - "@algolia/cache-common": "4.22.1", - "@algolia/cache-in-memory": "4.22.1", - "@algolia/client-account": "4.22.1", - "@algolia/client-analytics": "4.22.1", - "@algolia/client-common": "4.22.1", - "@algolia/client-personalization": "4.22.1", - "@algolia/client-search": "4.22.1", - "@algolia/logger-common": "4.22.1", - "@algolia/logger-console": "4.22.1", - "@algolia/requester-browser-xhr": "4.22.1", - "@algolia/requester-common": "4.22.1", - "@algolia/requester-node-http": "4.22.1", - "@algolia/transporter": "4.22.1" + "@algolia/cache-browser-local-storage": "4.23.3", + "@algolia/cache-common": "4.23.3", + "@algolia/cache-in-memory": "4.23.3", + "@algolia/client-account": "4.23.3", + "@algolia/client-analytics": "4.23.3", + "@algolia/client-common": "4.23.3", + "@algolia/client-personalization": "4.23.3", + "@algolia/client-search": "4.23.3", + "@algolia/logger-common": "4.23.3", + "@algolia/logger-console": "4.23.3", + "@algolia/recommend": "4.23.3", + "@algolia/requester-browser-xhr": "4.23.3", + "@algolia/requester-common": "4.23.3", + "@algolia/requester-node-http": "4.23.3", + "@algolia/transporter": "4.23.3" } }, "node_modules/algoliasearch-helper": { From dba93333fded652989e9a02d4b168a84fb3fbc21 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 11 Apr 2024 09:17:34 +0000 Subject: [PATCH 07/82] fix(deps): update dependency algoliasearch-helper to v3.17.0 --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5dad67b3..19e3f81c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6781,9 +6781,9 @@ } }, "node_modules/algoliasearch-helper": { - "version": "3.16.3", - "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.16.3.tgz", - "integrity": "sha512-1OuJT6sONAa9PxcOmWo5WCAT3jQSpCR9/m5Azujja7nhUQwAUDvaaAYrcmUySsrvHh74usZHbE3jFfGnWtZj8w==", + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/algoliasearch-helper/-/algoliasearch-helper-3.17.0.tgz", + "integrity": "sha512-R5422OiQjvjlK3VdpNQ/Qk7KsTIGeM5ACm8civGifOVWdRRV/3SgXuKmeNxe94Dz6fwj/IgpVmXbHutU4mHubg==", "dependencies": { "@algolia/events": "^4.0.1" }, From c663f6fa30e6e848dfe551c69207cf1de0039d64 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:00:28 +0500 Subject: [PATCH 08/82] Rebase 2u main with master (#1228) * chore(deps): update dependency babel-plugin-formatjs to v10.5.14 * fix(deps): update dependency @edx/frontend-platform to v7.1.3 * fix(deps): update font awesome to v6.5.2 * chore(deps): update dependency @openedx/frontend-build to v13.1.4 * fix(deps): update dependency @openedx/paragon to v22.2.1 * fix(deps): update dependency algoliasearch to v4.23.3 * fix(deps): update dependency algoliasearch-helper to v3.17.0 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> From 6f325c20c323f36bc53082f27db6bcad2f530728 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Wed, 3 Apr 2024 11:28:39 +0500 Subject: [PATCH 09/82] feat: implement multi step registration experiment --- src/common-components/messages.jsx | 6 + src/config/index.js | 2 + src/logistration/Logistration.jsx | 55 ++- src/logistration/Logistration.test.jsx | 7 + src/register/RegistrationPage.jsx | 341 +++++++++++++----- src/register/RegistrationPage.test.jsx | 5 + .../ConfigurableRegistrationForm.jsx | 24 +- .../components/RegistrationFailure.jsx | 11 +- .../ConfigurableRegistrationForm.test.jsx | 8 + .../tests/RegistrationFailure.test.jsx | 5 + .../components/tests/ThirdPartyAuth.test.jsx | 5 + src/register/data/actions.js | 20 +- .../data/optimizelyExperiment/helper.js | 105 ++++++ ...ltiStepRegistrationExperimentVariation.jsx | 54 +++ src/register/data/reducers.js | 25 ++ src/register/data/sagas.js | 7 +- src/register/data/tests/reducers.test.js | 5 + src/register/data/utils.js | 37 +- src/register/messages.jsx | 23 ++ src/sass/_registration.scss | 4 - 20 files changed, 610 insertions(+), 139 deletions(-) create mode 100644 src/register/data/optimizelyExperiment/helper.js create mode 100644 src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx diff --git a/src/common-components/messages.jsx b/src/common-components/messages.jsx index 08e88b8f..6f0e69ca 100644 --- a/src/common-components/messages.jsx +++ b/src/common-components/messages.jsx @@ -132,6 +132,12 @@ const messages = defineMessages({ defaultMessage: 'Company or school credentials', description: 'Company or school login link text.', }, + // multi step registration experiment messages + 'tab.back.btn.text': { + id: 'tab.back.btn.text', + defaultMessage: 'Back', + description: 'Tab back button text', + }, }); export default messages; diff --git a/src/config/index.js b/src/config/index.js index fa3613aa..5d221faa 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -34,6 +34,8 @@ const configuration = { ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL, ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '', ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '', + // Multi Step Registration Experiment + MULTI_STEP_REGISTRATION_EXPERIMENT_ID: process.env.MULTI_STEP_REGISTRATION_EXPERIMENT_ID || '', }; export default configuration; diff --git a/src/logistration/Logistration.jsx b/src/logistration/Logistration.jsx index 9451aa27..5199e1b3 100644 --- a/src/logistration/Logistration.jsx +++ b/src/logistration/Logistration.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -7,10 +7,11 @@ import { getAuthService } from '@edx/frontend-platform/auth'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, + IconButton, Tab, Tabs, } from '@openedx/paragon'; -import { ChevronLeft } from '@openedx/paragon/icons'; +import { ArrowBackIos, ChevronLeft } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import { Navigate, useNavigate } from 'react-router-dom'; @@ -27,7 +28,11 @@ import { import { LoginPage } from '../login'; import { backupLoginForm } from '../login/data/actions'; import { RegistrationPage } from '../register'; -import { backupRegistrationForm } from '../register/data/actions'; +import { backupRegistrationForm, setMultiStepRegistrationExpData } from '../register/data/actions'; +import { + FIRST_STEP, + getMultiStepRegistrationPreviousStep, +} from '../register/data/optimizelyExperiment/helper'; const Logistration = (props) => { const { selectedPage, tpaProviders } = props; @@ -42,6 +47,10 @@ const Logistration = (props) => { const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false; const hideRegistrationLink = getConfig().SHOW_REGISTRATION_LINKS === false; + const dispatch = useDispatch(); + const multiStepRegExpVariation = useSelector(state => state.register.multiStepRegExpVariation); + const multiStepRegistrationPageStep = useSelector(state => state.register.multiStepRegistrationPageStep); + useEffect(() => { const authService = getAuthService(); if (authService) { @@ -91,6 +100,39 @@ const Logistration = (props) => { ); + /** + * Temporary function created to resolve the complexity in tabs conditioning for multi-step + * registration experiment + */ + const getTabs = () => { + if (multiStepRegistrationPageStep !== FIRST_STEP) { + const prevStep = getMultiStepRegistrationPreviousStep(multiStepRegistrationPageStep); + return ( +
+ { + dispatch(setMultiStepRegistrationExpData(multiStepRegExpVariation, prevStep)); + }} + variant="primary" + size="inline" + className="mr-1" + /> + {formatMessage(messages['tab.back.btn.text'])} +
+ ); + } + return ( + handleOnSelect(tabKey, selectedPage)}> + + + + ); + }; + const isValidTpaHint = () => { const { provider } = getTpaProvider(tpaHint, providers, secondaryProviders); return !!provider; @@ -123,12 +165,7 @@ const Logistration = (props) => { ) - : (!isValidTpaHint() && !hideRegistrationLink && ( - handleOnSelect(tabKey, selectedPage)}> - - - - ))} + : (!isValidTpaHint() && !hideRegistrationLink && getTabs())} { key && ( )} diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index 87bf3e70..ce7af88f 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -15,12 +15,16 @@ import { } from '../data/constants'; import { backupLoginForm } from '../login/data/actions'; import { backupRegistrationForm } from '../register/data/actions'; +import { FIRST_STEP, NOT_INITIALIZED } from '../register/data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); +jest.mock('../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const mockStore = configureStore(); const IntlLogistration = injectIntl(Logistration); @@ -63,6 +67,8 @@ describe('Logistration', () => { registrationError: {}, usernameSuggestions: [], validationApiRateLimited: false, + multiStepRegExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, }, commonComponents: { thirdPartyAuthContext: { @@ -83,6 +89,7 @@ describe('Logistration', () => { username: 'test-user', })), })); + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); configure({ loggingService: { logError: jest.fn() }, diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 80ac1f6b..70897a31 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -17,14 +17,29 @@ import RegistrationFailure from './components/RegistrationFailure'; import { backupRegistrationFormBegin, clearRegistrationBackendError, + fetchRealtimeValidations, registerNewUser, setEmailSuggestionInStore, + setMultiStepRegistrationExpData, setUserPipelineDataLoaded, } from './data/actions'; import { FORM_SUBMISSION_ERROR, TPA_AUTHENTICATION_FAILURE, } from './data/constants'; +import { + CONTROL, + FIRST_STEP, + getMultiStepRegistrationNextStep, + getRegisterButtonLabelInExperiment, + getRegisterButtonSubmitStateInExperiment, + MULTI_STEP_REGISTRATION_EXP_VARIATION, + NOT_INITIALIZED, + SECOND_STEP, + shouldDisplayFieldInExperiment, THIRD_STEP, +} from './data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from './data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import getBackendValidations from './data/selectors'; import { isFormValid, prepareRegistrationPayload, @@ -73,6 +88,13 @@ const RegistrationPage = (props) => { const shouldBackupState = useSelector(state => state.register.shouldBackupState); const userPipelineDataLoaded = useSelector(state => state.register.userPipelineDataLoaded); const submitState = useSelector(state => state.register.submitState); + const backendValidations = useSelector(getBackendValidations); + const multiStepRegExpVariation = useSelector(state => state.register.multiStepRegExpVariation); + const multiStepRegistrationPageStep = useSelector(state => state.register.multiStepRegistrationPageStep); + const isValidatingMultiStepRegistrationPage = useSelector( + state => state.register.isValidatingMultiStepRegistrationPage, + ); + const validationsSubmitState = useSelector(state => state.register.validationsSubmitState); const fieldDescriptions = useSelector(state => state.commonComponents.fieldDescriptions); const optionalFields = useSelector(state => state.commonComponents.optionalFields); @@ -85,7 +107,6 @@ const RegistrationPage = (props) => { const secondaryProviders = useSelector(state => state.commonComponents.thirdPartyAuthContext.secondaryProviders); const pipelineUserDetails = useSelector(state => state.commonComponents.thirdPartyAuthContext.pipelineUserDetails); - const backendValidations = useSelector(getBackendValidations); const queryParams = useMemo(() => getAllPossibleQueryParams(), []); const tpaHint = useMemo(() => getTpaHint(), []); @@ -102,6 +123,26 @@ const RegistrationPage = (props) => { ? formatMessage(messages['create.account.cta.button'], { label: cta }) : formatMessage(messages['create.account.for.free.button']); + /** + * Multi-Step Registration Page Experiment + */ + const multiStepRegistrationExpVariation = useMultiStepRegistrationExperimentVariation( + multiStepRegExpVariation, registrationEmbedded, tpaHint, currentProvider, thirdPartyAuthApiStatus, + ); + + useEffect(() => { + if (isValidatingMultiStepRegistrationPage && backendValidations + && Object.values(backendValidations).every(value => value === '') + ) { + setErrorCode({ type: '', count: 0 }); + const nextStep = getMultiStepRegistrationNextStep(multiStepRegistrationPageStep); + dispatch(setMultiStepRegistrationExpData(multiStepRegistrationExpVariation, nextStep)); + } + }, [ // eslint-disable-line react-hooks/exhaustive-deps + isValidatingMultiStepRegistrationPage, + backendValidations, + ]); + /** * Set the userPipelineDetails data in formFields for only first time */ @@ -148,8 +189,11 @@ const RegistrationPage = (props) => { formFields: { ...formFields }, errors: { ...errors }, })); + dispatch(setMultiStepRegistrationExpData( + multiStepRegistrationExpVariation, multiStepRegistrationPageStep, false, + )); } - }, [shouldBackupState, configurableFormFields, formFields, errors, dispatch, backedUpFormData]); + }, [shouldBackupState]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (backendValidations) { @@ -169,13 +213,21 @@ const RegistrationPage = (props) => { useEffect(() => { if (registrationResult.success) { + let registeredEventProps = {}; + + if (multiStepRegistrationExpVariation !== NOT_INITIALIZED) { + registeredEventProps = { + variation: multiStepRegistrationExpVariation, + }; + } + // This event is used by GTM - sendTrackEvent('edx.bi.user.account.registered.client', {}); + sendTrackEvent('edx.bi.user.account.registered.client', registeredEventProps); // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); } - }, [registrationResult]); + }, [registrationResult]); // eslint-disable-line react-hooks/exhaustive-deps const handleOnChange = (event) => { const { name } = event.target; @@ -247,7 +299,56 @@ const RegistrationPage = (props) => { const handleSubmit = (e) => { e.preventDefault(); - registerUser(); + + if (multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION + && multiStepRegistrationPageStep !== THIRD_STEP) { + let formFieldsPayload = {}; + + if (multiStepRegistrationPageStep === FIRST_STEP) { + // We only want to validate email in the first step of registration + // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. + formFieldsPayload = { email: formFields.email }; + } else if (multiStepRegistrationPageStep === SECOND_STEP) { + // We only want to validate name and password field in the second step of registration + // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. + formFieldsPayload = { name: formFields.name, password: formFields.password }; + } + + const { isValid, fieldErrors } = isFormValid( + formFieldsPayload, errors, {}, {}, formatMessage, + ); + setErrors(prevErrors => ({ + ...prevErrors, + ...fieldErrors, + })); + // returning if not valid + if (!isValid) { + setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 })); + } else { + dispatch(fetchRealtimeValidations(formFieldsPayload, true)); + } + } else if (multiStepRegistrationExpVariation === CONTROL && multiStepRegistrationPageStep !== SECOND_STEP) { + // We only want to validate name, email and password fields in the first step of CONTROL registration + // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. + const formFieldsPayload = { name: formFields.name, email: formFields.email, password: formFields.password }; + + const { isValid, fieldErrors } = isFormValid( + formFieldsPayload, errors, {}, {}, formatMessage, + ); + + setErrors(prevErrors => ({ + ...prevErrors, + ...fieldErrors, + })); + // returning if not valid + if (!isValid) { + setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 })); + } else { + dispatch(fetchRealtimeValidations(formFieldsPayload, true)); + } + } else { + registerUser(); + } }; useEffect(() => { @@ -282,104 +383,146 @@ const RegistrationPage = (props) => { getConfig().ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN && !!Object.keys(optionalFields.fields).length } /> - {autoSubmitRegForm && !errorCode.type ? ( -
- -
- ) : ( -
- - -
- - - - {!currentProvider && ( - + {(autoSubmitRegForm && !errorCode.type) + || (!multiStepRegistrationExpVariation && !(registrationEmbedded || !!tpaHint || !!currentProvider)) + ? ( +
+ +
+ ) : ( +
+ - e.preventDefault()} + - {!registrationEmbedded && ( - + {(multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION + && multiStepRegistrationPageStep === SECOND_STEP) && ( +

+ {formatMessage(messages['multistep.registration.username.second.step.guideline.content'])} +

+ )} + {((multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION + && multiStepRegistrationPageStep === THIRD_STEP) + || (multiStepRegistrationExpVariation === CONTROL && multiStepRegistrationPageStep === SECOND_STEP)) + && ( +

+ {formatMessage(messages['multistep.registration.username.third.step.guideline.content'])} +

+ )} + {shouldDisplayFieldInExperiment( + 'name', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + ) && ( + + )} + {shouldDisplayFieldInExperiment( + 'email', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + ) && ( + + )} + {shouldDisplayFieldInExperiment( + 'username', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + ) && ( + + )} + {!currentProvider && shouldDisplayFieldInExperiment( + 'password', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + ) && ( + + )} + - )} - -
- )} - + e.preventDefault()} + /> + {(!registrationEmbedded && shouldDisplayFieldInExperiment( + 'ThirdPartyAuth', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) + && ( + + )} + +
+ )} ); }; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 2634713f..1f6fd1a3 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -17,6 +17,9 @@ import { setUserPipelineDataLoaded, } from './data/actions'; import { INTERNAL_SERVER_ERROR } from './data/constants'; +import { NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from './data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from './RegistrationPage'; import { AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, @@ -30,6 +33,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('./data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -128,6 +132,7 @@ describe('RegistrationPage', () => { institutionLogin: false, }; window.location = { search: '' }; + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index be1f9c27..5e69c7c0 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import { FormFieldRenderer } from '../../field-renderer'; import { FIELDS } from '../data/constants'; +import { FIRST_STEP, shouldDisplayFieldInExperiment } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields'; @@ -31,6 +32,8 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors, setFormFields, autoSubmitRegistrationForm, + multiStepRegistrationExpVariation, + multiStepRegistrationPageStep, } = props; const countryList = useMemo(() => getCountryList(getLocale()), []); @@ -105,7 +108,9 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors(prevErrors => ({ ...prevErrors, [name]: '' })); }; - if (flags.showConfigurableRegistrationFields) { + if (flags.showConfigurableRegistrationFields && shouldDisplayFieldInExperiment( + 'other_fields', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { Object.keys(fieldDescriptions).forEach(fieldName => { const fieldData = fieldDescriptions[fieldName]; switch (fieldData.name) { @@ -157,7 +162,9 @@ const ConfigurableRegistrationForm = (props) => { }); } - if (flags.showConfigurableEdxFields || showCountryField) { + if ((flags.showConfigurableEdxFields || showCountryField) && shouldDisplayFieldInExperiment( + 'country', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { formFieldDescriptions.push( { ); } - if (flags.showMarketingEmailOptInCheckbox) { + if (flags.showMarketingEmailOptInCheckbox && shouldDisplayFieldInExperiment( + 'marketing_email_opt_in', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { formFieldDescriptions.push( { ); } - if (flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) { + if ((flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) + && shouldDisplayFieldInExperiment( + 'honor_code', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { formFieldDescriptions.push( @@ -227,11 +239,15 @@ ConfigurableRegistrationForm.propTypes = { setFieldErrors: PropTypes.func.isRequired, setFormFields: PropTypes.func.isRequired, autoSubmitRegistrationForm: PropTypes.bool, + multiStepRegistrationExpVariation: PropTypes.string, + multiStepRegistrationPageStep: PropTypes.string, }; ConfigurableRegistrationForm.defaultProps = { fieldDescriptions: {}, autoSubmitRegistrationForm: false, + multiStepRegistrationExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, }; export default ConfigurableRegistrationForm; diff --git a/src/register/components/RegistrationFailure.jsx b/src/register/components/RegistrationFailure.jsx index c34c2af7..f2a83585 100644 --- a/src/register/components/RegistrationFailure.jsx +++ b/src/register/components/RegistrationFailure.jsx @@ -13,12 +13,13 @@ import { TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../data/constants'; +import { FIRST_STEP } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; const RegistrationFailureMessage = (props) => { const { formatMessage } = useIntl(); const { - context, errorCode, failureCount, + context, errorCode, failureCount, multiStepRegistrationPageStep, } = props; useEffect(() => { @@ -49,7 +50,11 @@ const RegistrationFailureMessage = (props) => { errorMessage = formatMessage(messages['registration.tpa.session.expired'], { provider: context.provider }); break; default: - errorMessage = formatMessage(messages['registration.empty.form.submission.error']); + if (multiStepRegistrationPageStep !== FIRST_STEP) { + errorMessage = formatMessage(messages['multistep.registration.form.submission.error']); + } else { + errorMessage = formatMessage(messages['registration.empty.form.submission.error']); + } break; } @@ -65,6 +70,7 @@ RegistrationFailureMessage.defaultProps = { context: { errorMessage: null, }, + multiStepRegistrationPageStep: FIRST_STEP, }; RegistrationFailureMessage.propTypes = { @@ -74,6 +80,7 @@ RegistrationFailureMessage.propTypes = { }), errorCode: PropTypes.string.isRequired, failureCount: PropTypes.number.isRequired, + multiStepRegistrationPageStep: PropTypes.string, }; export default RegistrationFailureMessage; diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 537d11eb..28269072 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -11,6 +11,9 @@ import configureStore from 'redux-mock-store'; import { registerNewUser } from '../../data/actions'; import { FIELDS } from '../../data/constants'; +import { FIRST_STEP, NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; @@ -22,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlConfigurableRegistrationForm = injectIntl(ConfigurableRegistrationForm); const IntlRegistrationPage = injectIntl(RegistrationPage); @@ -93,6 +97,9 @@ describe('ConfigurableRegistrationForm', () => { registrationError: {}, registrationFormData, usernameSuggestions: [], + multiStepRegistrationPageStep: FIRST_STEP, + multiStepRegExpVariation: '', + isValidatingMultiStepRegistrationPage: false, }, commonComponents: { thirdPartyAuthApiStatus: null, @@ -121,6 +128,7 @@ describe('ConfigurableRegistrationForm', () => { }; window.location = { search: '' }; getLocale.mockImplementationOnce(() => ('en-us')); + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/RegistrationFailure.test.jsx b/src/register/components/tests/RegistrationFailure.test.jsx index 003cc966..b9667c9c 100644 --- a/src/register/components/tests/RegistrationFailure.test.jsx +++ b/src/register/components/tests/RegistrationFailure.test.jsx @@ -12,6 +12,9 @@ import configureStore from 'redux-mock-store'; import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import RegistrationFailureMessage from '../RegistrationFailure'; @@ -23,6 +26,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage); @@ -121,6 +125,7 @@ describe('RegistrationFailure', () => { institutionLogin: false, }; window.location = { search: '' }; + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/ThirdPartyAuth.test.jsx b/src/register/components/tests/ThirdPartyAuth.test.jsx index 917f10f9..45672ad5 100644 --- a/src/register/components/tests/ThirdPartyAuth.test.jsx +++ b/src/register/components/tests/ThirdPartyAuth.test.jsx @@ -12,6 +12,9 @@ import configureStore from 'redux-mock-store'; import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, } from '../../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -22,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -120,6 +124,7 @@ describe('ThirdPartyAuth', () => { institutionLogin: false, }; window.location = { search: '' }; + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/data/actions.js b/src/register/data/actions.js index 9fa5aed5..530f3bc8 100644 --- a/src/register/data/actions.js +++ b/src/register/data/actions.js @@ -8,6 +8,7 @@ export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERRO export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE'; export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED'; export const REGISTER_SET_EMAIL_SUGGESTIONS = 'REGISTER_SET_EMAIL_SUGGESTIONS'; +export const REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA = 'REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA'; // Backup registration form export const backupRegistrationForm = () => ({ @@ -20,18 +21,19 @@ export const backupRegistrationFormBegin = (data) => ({ }); // Validate fields from the backend -export const fetchRealtimeValidations = (formPayload) => ({ +export const fetchRealtimeValidations = (formPayload, isValidatingMultiStepRegistrationPage) => ({ type: REGISTER_FORM_VALIDATIONS.BASE, - payload: { formPayload }, + payload: { formPayload, isValidatingMultiStepRegistrationPage }, }); -export const fetchRealtimeValidationsBegin = () => ({ +export const fetchRealtimeValidationsBegin = (isValidatingMultiStepRegistrationPage) => ({ type: REGISTER_FORM_VALIDATIONS.BEGIN, + payload: { isValidatingMultiStepRegistrationPage }, }); -export const fetchRealtimeValidationsSuccess = (validations) => ({ +export const fetchRealtimeValidationsSuccess = (validations, isValidatingMultiStepRegistrationPage) => ({ type: REGISTER_FORM_VALIDATIONS.SUCCESS, - payload: { validations }, + payload: { validations, isValidatingMultiStepRegistrationPage }, }); export const fetchRealtimeValidationsFailure = () => ({ @@ -83,3 +85,11 @@ export const setUserPipelineDataLoaded = (value) => ({ type: REGISTER_SET_USER_PIPELINE_DATA_LOADED, payload: { value }, }); + +// Multi Step Registration Experiment Actions +export const setMultiStepRegistrationExpData = ( + multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage, +) => ({ + type: REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, + payload: { multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage }, +}); diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js new file mode 100644 index 00000000..e59ec718 --- /dev/null +++ b/src/register/data/optimizelyExperiment/helper.js @@ -0,0 +1,105 @@ +/** + * This file contains data for Multi Step Registration Optimizely experiment + */ +import { getConfig } from '@edx/frontend-platform'; + +import messages from '../../messages'; + +export const NOT_INITIALIZED = 'experiment-not-initialized'; +export const CONTROL = 'control-registration-page'; +export const MULTI_STEP_REGISTRATION_EXP_VARIATION = 'multi-step-registration-page'; + +export const FIRST_STEP = 'first-step'; +export const SECOND_STEP = 'second-step'; +export const THIRD_STEP = 'third-step'; + +export const CONTROL_REGISTER_PAGE_FIRST_STEP_FIELDS = ['name', 'email', 'password', 'marketing_email_opt_in', 'ThirdPartyAuth']; +export const CONTROL_REGISTER_PAGE_SECOND_STEP_FIELDS = ['username', 'country']; +export const CONTROL_REGISTER_PAGE_COMMON_FIELDS = ['tos_and_honor_code', 'honor_code']; + +export const MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS = ['email', 'marketing_email_opt_in', 'ThirdPartyAuth']; +export const MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS = ['name', 'password']; +export const MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS = ['username', 'country']; +export const MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS = ['tos_and_honor_code', 'honor_code']; + +const MULTI_STEP_REGISTRATION_EXP_PAGE = 'authn_register_page'; + +export function getMultiStepRegistrationExperimentVariation() { + try { + if (window.optimizely + && window.optimizely.get('data').experiments[getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID]) { + const selectedVariant = window.optimizely.get('state').getVariationMap()[ + getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID + ]; + return selectedVariant?.name; + } + } catch (e) { /* empty */ } + return ''; +} + +export function activateMultiStepRegistrationExperiment() { + window.optimizely = window.optimizely || []; + window.optimizely.push({ + type: 'page', + pageName: MULTI_STEP_REGISTRATION_EXP_PAGE, + }); +} + +/** + * We want to display username and honor_code fields in second page if user is in multi-step + * registration page experiment + */ +export const shouldDisplayFieldInExperiment = (fieldName, expVariation, registerPageStep) => ( + !expVariation || expVariation === NOT_INITIALIZED + || (expVariation === CONTROL + && ( + CONTROL_REGISTER_PAGE_COMMON_FIELDS.includes(fieldName) + || (registerPageStep === FIRST_STEP && CONTROL_REGISTER_PAGE_FIRST_STEP_FIELDS.includes(fieldName)) + || (registerPageStep === SECOND_STEP && CONTROL_REGISTER_PAGE_SECOND_STEP_FIELDS.includes(fieldName)) + )) + || (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION + && ( + MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS.includes(fieldName) + || (registerPageStep === FIRST_STEP && MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS.includes(fieldName)) + || (registerPageStep === SECOND_STEP && MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS.includes(fieldName)) + || (registerPageStep === THIRD_STEP && MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS.includes(fieldName)) + )) +); + +export const getRegisterButtonLabelInExperiment = ( + existingButtonLabel, expVariation, registerPageStep, formatMessage, +) => { + if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep === FIRST_STEP) { + return formatMessage(messages['multistep.registration.exp.continue.button']); + } + return existingButtonLabel; +}; + +export const getRegisterButtonSubmitStateInExperiment = ( + registerSubmitState, validationsSubmitState, expVariation, registerPageStep, +) => { + if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep !== THIRD_STEP) { + return validationsSubmitState; + } + return registerSubmitState; +}; + +export const getMultiStepRegistrationPreviousStep = (currentStep) => { + if (currentStep === THIRD_STEP) { + return SECOND_STEP; + } + if (currentStep === SECOND_STEP) { + return FIRST_STEP; + } + return currentStep; +}; + +export const getMultiStepRegistrationNextStep = (currentStep) => { + if (currentStep === FIRST_STEP) { + return SECOND_STEP; + } + if (currentStep === SECOND_STEP) { + return THIRD_STEP; + } + return currentStep; +}; diff --git a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx new file mode 100644 index 00000000..692101b7 --- /dev/null +++ b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +import { + activateMultiStepRegistrationExperiment, + getMultiStepRegistrationExperimentVariation, + NOT_INITIALIZED, +} from './helper'; +import { COMPLETE_STATE } from '../../../data/constants'; + +/** + * This hook returns activates multi step registration experiment and returns the experiment + * variation for the user. + */ +const useMultiStepRegistrationExperimentVariation = ( + initExpVariation, + registrationEmbedded, + tpaHint, + currentProvider, + thirdPartyAuthApiStatus, +) => { + const [variation, setVariation] = useState(initExpVariation); + + useEffect(() => { + if (initExpVariation || registrationEmbedded || !!tpaHint || !!currentProvider + || thirdPartyAuthApiStatus !== COMPLETE_STATE) { + return variation; + } + + const getVariation = () => { + const expVariation = getMultiStepRegistrationExperimentVariation(); + if (expVariation) { + setVariation(expVariation); + } else { + // This is to handle the case when user dont get variation for some reason, the register page + // shows unlimited spinner. + setVariation(NOT_INITIALIZED); + } + }; + + activateMultiStepRegistrationExperiment(); + + const timer = setTimeout(getVariation, 300); + + return () => { + clearTimeout(timer); + }; + }, [ // eslint-disable-line react-hooks/exhaustive-deps + currentProvider, initExpVariation, registrationEmbedded, thirdPartyAuthApiStatus, tpaHint, + ]); + + return variation; +}; + +export default useMultiStepRegistrationExperimentVariation; diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index 70c3a994..32438877 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -5,9 +5,11 @@ import { REGISTER_NEW_USER, REGISTER_SET_COUNTRY_CODE, REGISTER_SET_EMAIL_SUGGESTIONS, + REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from './actions'; +import { FIRST_STEP } from './optimizelyExperiment/helper'; import { DEFAULT_STATE, PENDING_STATE, @@ -35,10 +37,14 @@ export const defaultState = { }, validations: null, submitState: DEFAULT_STATE, + validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + multiStepRegExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, + isValidatingMultiStepRegistrationPage: false, }; const reducer = (state = defaultState, action = {}) => { @@ -85,12 +91,22 @@ const reducer = (state = defaultState, action = {}) => { registrationError: { ...registrationErrorTemp }, }; } + case REGISTER_FORM_VALIDATIONS.BEGIN: { + return { + ...state, + validationsSubmitState: action.payload?.isValidatingMultiStepRegistrationPage + ? PENDING_STATE + : state.validationsSubmitState, + }; + } case REGISTER_FORM_VALIDATIONS.SUCCESS: { const { usernameSuggestions, ...validationWithoutUsernameSuggestions } = action.payload.validations; return { ...state, validations: validationWithoutUsernameSuggestions, + isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, usernameSuggestions: usernameSuggestions || state.usernameSuggestions, + validationsSubmitState: DEFAULT_STATE, }; } case REGISTER_FORM_VALIDATIONS.FAILURE: @@ -98,6 +114,7 @@ const reducer = (state = defaultState, action = {}) => { ...state, validationApiRateLimited: true, validations: null, + validationsSubmitState: DEFAULT_STATE, }; case REGISTER_CLEAR_USERNAME_SUGGESTIONS: return { @@ -129,6 +146,14 @@ const reducer = (state = defaultState, action = {}) => { emailSuggestion: action.payload.emailSuggestion, }, }; + case REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA: { + return { + ...state, + multiStepRegExpVariation: action.payload.multiStepRegExpVariation, + multiStepRegistrationPageStep: action.payload.multiStepRegistrationPageStep, + isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, + }; + } default: return { ...state, diff --git a/src/register/data/sagas.js b/src/register/data/sagas.js index 0325cd4b..3edd395e 100644 --- a/src/register/data/sagas.js +++ b/src/register/data/sagas.js @@ -40,10 +40,13 @@ export function* handleNewUserRegistration(action) { export function* fetchRealtimeValidations(action) { try { - yield put(fetchRealtimeValidationsBegin()); + yield put(fetchRealtimeValidationsBegin(action.payload?.isValidatingMultiStepRegistrationPage)); const { fieldValidations } = yield call(getFieldsValidations, action.payload.formPayload); - yield put(fetchRealtimeValidationsSuccess(camelCaseObject(fieldValidations))); + yield put(fetchRealtimeValidationsSuccess( + camelCaseObject(fieldValidations), + action.payload?.isValidatingMultiStepRegistrationPage, + )); } catch (e) { if (e.response && e.response.status === 403) { yield put(fetchRealtimeValidationsFailure()); diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index 3e2270ab..909704d2 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -11,6 +11,7 @@ import { REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from '../actions'; +import { FIRST_STEP } from '../optimizelyExperiment/helper'; import reducer from '../reducers'; describe('Registration Reducer Tests', () => { @@ -34,10 +35,14 @@ describe('Registration Reducer Tests', () => { }, validations: null, submitState: DEFAULT_STATE, + validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + multiStepRegExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, + isValidatingMultiStepRegistrationPage: false, }; it('should return the initial state', () => { diff --git a/src/register/data/utils.js b/src/register/data/utils.js index b0cff129..747f2f2c 100644 --- a/src/register/data/utils.js +++ b/src/register/data/utils.js @@ -43,30 +43,39 @@ export const isFormValid = ( Object.keys(payload).forEach(key => { switch (key) { case 'name': - fieldErrors.name = validateName(payload.name, formatMessage); + if (!fieldErrors.name) { + fieldErrors.name = validateName(payload.name, formatMessage); + } if (fieldErrors.name) { isValid = false; } break; case 'email': { - const { - fieldError, confirmEmailError, suggestion, - } = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage); - if (fieldError) { - fieldErrors.email = fieldError; - isValid = false; + if (!fieldErrors.email) { + const { + fieldError, confirmEmailError, suggestion, + } = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage); + if (fieldError) { + fieldErrors.email = fieldError; + isValid = false; + } + if (confirmEmailError) { + fieldErrors.confirm_email = confirmEmailError; + isValid = false; + } + emailSuggestion = suggestion; } - if (confirmEmailError) { - fieldErrors.confirm_email = confirmEmailError; - isValid = false; - } - emailSuggestion = suggestion; + if (fieldErrors.email) { isValid = false; } break; } case 'username': - fieldErrors.username = validateUsername(payload.username, formatMessage); + if (!fieldErrors.username) { + fieldErrors.username = validateUsername(payload.username, formatMessage); + } if (fieldErrors.username) { isValid = false; } break; case 'password': - fieldErrors.password = validatePasswordField(payload.password, formatMessage); + if (!fieldErrors.password) { + fieldErrors.password = validatePasswordField(payload.password, formatMessage); + } if (fieldErrors.password) { isValid = false; } break; default: diff --git a/src/register/messages.jsx b/src/register/messages.jsx index 39d9e7f5..cea5e5df 100644 --- a/src/register/messages.jsx +++ b/src/register/messages.jsx @@ -201,6 +201,29 @@ const messages = defineMessages({ defaultMessage: 'Did you mean', description: 'Did you mean alert suggestion', }, + // MultiStep Registration experiment + 'multistep.registration.exp.continue.button': { + id: 'multistep.registration.exp.continue.button', + defaultMessage: 'Continue', + description: 'Label text for multistep registration page second step', + }, + 'multistep.registration.username.second.step.guideline.content': { + id: 'multistep.registration.username.second.step.guideline.content', + defaultMessage: 'Finish Registration', + description: 'Guideline content for username field in multi-step registration experiment step 2', + }, + 'multistep.registration.username.third.step.guideline.content': { + id: 'multistep.registration.username.third.step.guideline.content', + defaultMessage: 'To finalize your registration, please confirm your country of residence ' + + 'and create a public username that will identify you in your course communication forums. ' + + 'The username cannot be changed.', + description: 'Guideline content for username field in multi-step registration experiment step 2', + }, + 'multistep.registration.form.submission.error': { + id: 'multistep.registration.form.submission.error', + defaultMessage: 'Please check your responses for this and the previous step and try again.', + description: 'Error message that appears on top of the form when invalid form is submitted', + }, }); export default messages; diff --git a/src/sass/_registration.scss b/src/sass/_registration.scss index 75889601..4126da9a 100644 --- a/src/sass/_registration.scss +++ b/src/sass/_registration.scss @@ -1,7 +1,3 @@ -.register-button { - min-width: 14.4rem; -} - .pgn__form-autosuggest__wrapper > .pgn__form-group { margin-bottom: 0 !important; } From 90f650ce3eb0778e5328727184cecfc81b6a41d5 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Thu, 18 Apr 2024 11:04:28 +0500 Subject: [PATCH 10/82] feat: add multi step registration eventing (#1226) * feat: implement multi step registration experiment * feat: add multi step registration eventing --- src/common-components/SocialAuthProviders.jsx | 14 ++++- src/common-components/ThirdPartyAuth.jsx | 4 ++ src/register/RegistrationPage.jsx | 35 +++++++++++- .../data/optimizelyExperiment/track.js | 56 +++++++++++++++++++ ...ltiStepRegistrationExperimentVariation.jsx | 2 + 5 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 src/register/data/optimizelyExperiment/track.js diff --git a/src/common-components/SocialAuthProviders.jsx b/src/common-components/SocialAuthProviders.jsx index abe06da7..5cdb04e0 100644 --- a/src/common-components/SocialAuthProviders.jsx +++ b/src/common-components/SocialAuthProviders.jsx @@ -9,14 +9,24 @@ import PropTypes from 'prop-types'; import messages from './messages'; import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import { CONTROL, MULTI_STEP_REGISTRATION_EXP_VARIATION } from '../register/data/optimizelyExperiment/helper'; +import { trackMultiStepRegistrationSSOBtnClicked } from '../register/data/optimizelyExperiment/track'; const SocialAuthProviders = (props) => { const { formatMessage } = useIntl(); - const { referrer, socialAuthProviders } = props; + const { + referrer, + socialAuthProviders, + multiStepRegistrationExpVariation, + } = props; function handleSubmit(e) { e.preventDefault(); + if (multiStepRegistrationExpVariation === CONTROL + || multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION) { + trackMultiStepRegistrationSSOBtnClicked(multiStepRegistrationExpVariation); + } const url = e.currentTarget.dataset.providerUrl; window.location.href = getConfig().LMS_BASE_URL + url; } @@ -60,6 +70,7 @@ const SocialAuthProviders = (props) => { SocialAuthProviders.defaultProps = { referrer: LOGIN_PAGE, socialAuthProviders: [], + multiStepRegistrationExpVariation: '', }; SocialAuthProviders.propTypes = { @@ -73,6 +84,7 @@ SocialAuthProviders.propTypes = { registerUrl: PropTypes.string, skipRegistrationForm: PropTypes.bool, })), + multiStepRegistrationExpVariation: PropTypes.string, }; export default SocialAuthProviders; diff --git a/src/common-components/ThirdPartyAuth.jsx b/src/common-components/ThirdPartyAuth.jsx index 3b782036..d64615e0 100644 --- a/src/common-components/ThirdPartyAuth.jsx +++ b/src/common-components/ThirdPartyAuth.jsx @@ -32,6 +32,7 @@ const ThirdPartyAuth = (props) => { handleInstitutionLogin, thirdPartyAuthApiStatus, isLoginPage, + multiStepRegistrationExpVariation, } = props; const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider; const isSocialAuthActive = !!providers.length && !currentProvider; @@ -78,6 +79,7 @@ const ThirdPartyAuth = (props) => { )} @@ -93,6 +95,7 @@ ThirdPartyAuth.defaultProps = { secondaryProviders: [], thirdPartyAuthApiStatus: PENDING_STATE, isLoginPage: false, + multiStepRegistrationExpVariation: '', }; ThirdPartyAuth.propTypes = { @@ -120,6 +123,7 @@ ThirdPartyAuth.propTypes = { ), thirdPartyAuthApiStatus: PropTypes.string, isLoginPage: PropTypes.bool, + multiStepRegistrationExpVariation: PropTypes.string, }; export default ThirdPartyAuth; diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 70897a31..19b3d774 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -34,10 +34,17 @@ import { getRegisterButtonLabelInExperiment, getRegisterButtonSubmitStateInExperiment, MULTI_STEP_REGISTRATION_EXP_VARIATION, - NOT_INITIALIZED, SECOND_STEP, shouldDisplayFieldInExperiment, THIRD_STEP, } from './data/optimizelyExperiment/helper'; +import { + trackMultiStepRegistrationFormSubmitBtnClicked, + trackMultiStepRegistrationStep1SubmitBtnClicked, + trackMultiStepRegistrationStep2SubmitBtnClicked, + trackMultiStepRegistrationStep2Viewed, + trackMultiStepRegistrationStep3SubmitBtnClicked, + trackMultiStepRegistrationStep3Viewed, +} from './data/optimizelyExperiment/track'; import useMultiStepRegistrationExperimentVariation from './data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import getBackendValidations from './data/selectors'; @@ -136,6 +143,17 @@ const RegistrationPage = (props) => { ) { setErrorCode({ type: '', count: 0 }); const nextStep = getMultiStepRegistrationNextStep(multiStepRegistrationPageStep); + if (nextStep === SECOND_STEP) { + trackMultiStepRegistrationStep2Viewed(multiStepRegistrationExpVariation); + if (multiStepRegistrationExpVariation === CONTROL) { + trackMultiStepRegistrationFormSubmitBtnClicked(multiStepRegistrationExpVariation); + } + } else if (nextStep === THIRD_STEP) { + trackMultiStepRegistrationStep3Viewed(); + if (multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION) { + trackMultiStepRegistrationFormSubmitBtnClicked(multiStepRegistrationExpVariation); + } + } dispatch(setMultiStepRegistrationExpData(multiStepRegistrationExpVariation, nextStep)); } }, [ // eslint-disable-line react-hooks/exhaustive-deps @@ -215,7 +233,8 @@ const RegistrationPage = (props) => { if (registrationResult.success) { let registeredEventProps = {}; - if (multiStepRegistrationExpVariation !== NOT_INITIALIZED) { + if (multiStepRegistrationExpVariation === CONTROL + || multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION) { registeredEventProps = { variation: multiStepRegistrationExpVariation, }; @@ -300,15 +319,25 @@ const RegistrationPage = (props) => { const handleSubmit = (e) => { e.preventDefault(); + if (multiStepRegistrationExpVariation === CONTROL + && multiStepRegistrationPageStep === SECOND_STEP) { + trackMultiStepRegistrationStep2SubmitBtnClicked(multiStepRegistrationExpVariation); + } + if (multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION + && multiStepRegistrationPageStep === THIRD_STEP) { + trackMultiStepRegistrationStep3SubmitBtnClicked(); + } if (multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && multiStepRegistrationPageStep !== THIRD_STEP) { let formFieldsPayload = {}; if (multiStepRegistrationPageStep === FIRST_STEP) { + trackMultiStepRegistrationStep1SubmitBtnClicked(multiStepRegistrationExpVariation); // We only want to validate email in the first step of registration // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. formFieldsPayload = { email: formFields.email }; } else if (multiStepRegistrationPageStep === SECOND_STEP) { + trackMultiStepRegistrationStep2SubmitBtnClicked(multiStepRegistrationExpVariation); // We only want to validate name and password field in the second step of registration // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. formFieldsPayload = { name: formFields.name, password: formFields.password }; @@ -328,6 +357,7 @@ const RegistrationPage = (props) => { dispatch(fetchRealtimeValidations(formFieldsPayload, true)); } } else if (multiStepRegistrationExpVariation === CONTROL && multiStepRegistrationPageStep !== SECOND_STEP) { + trackMultiStepRegistrationStep1SubmitBtnClicked(multiStepRegistrationExpVariation); // We only want to validate name, email and password fields in the first step of CONTROL registration // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. const formFieldsPayload = { name: formFields.name, email: formFields.email, password: formFields.password }; @@ -518,6 +548,7 @@ const RegistrationPage = (props) => { secondaryProviders={secondaryProviders} handleInstitutionLogin={handleInstitutionLogin} thirdPartyAuthApiStatus={thirdPartyAuthApiStatus} + multiStepRegistrationExpVariation={multiStepRegistrationExpVariation} /> )} diff --git a/src/register/data/optimizelyExperiment/track.js b/src/register/data/optimizelyExperiment/track.js new file mode 100644 index 00000000..8db71809 --- /dev/null +++ b/src/register/data/optimizelyExperiment/track.js @@ -0,0 +1,56 @@ +import { sendTrackEvent } from '@edx/frontend-platform/analytics'; + +export const eventNames = { + multiStepRegistrationStep1Viewed: 'edx.bi.user.multistepregistration.step1.viewed', + multiStepRegistrationStep2Viewed: 'edx.bi.user.multistepregistration.step2.viewed', + multiStepRegistrationStep3Viewed: 'edx.bi.user.multistepregistration.step3.viewed', + multiStepRegistrationStep1SubmitBtnClicked: 'edx.bi.user.registration.step1.submit.click', + multiStepRegistrationStep2SubmitBtnClicked: 'edx.bi.user.registration.step2.submit.click', + multiStepRegistrationStep3SubmitBtnClicked: 'edx.bi.user.registration.step3.submit.click', + multiStepRegistrationFormSubmitBtnClicked: 'edx.bi.user.registration.form.submit.click', + multiStepRegistrationSSOBtnClicked: 'edx.bi.user.registration.sso.btn.click', +}; + +export const trackMultiStepRegistrationStep1Viewed = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationStep1Viewed, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationStep2Viewed = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationStep2Viewed, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationStep3Viewed = () => { + sendTrackEvent(eventNames.multiStepRegistrationStep3Viewed, {}); +}; + +export const trackMultiStepRegistrationStep1SubmitBtnClicked = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationStep1SubmitBtnClicked, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationStep2SubmitBtnClicked = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationStep2SubmitBtnClicked, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationStep3SubmitBtnClicked = () => { + sendTrackEvent(eventNames.multiStepRegistrationStep3SubmitBtnClicked, {}); +}; + +export const trackMultiStepRegistrationFormSubmitBtnClicked = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationFormSubmitBtnClicked, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationSSOBtnClicked = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationSSOBtnClicked, { + variation: expVariation, + }); +}; diff --git a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx index 692101b7..97d0a233 100644 --- a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx +++ b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx @@ -5,6 +5,7 @@ import { getMultiStepRegistrationExperimentVariation, NOT_INITIALIZED, } from './helper'; +import { trackMultiStepRegistrationStep1Viewed } from './track'; import { COMPLETE_STATE } from '../../../data/constants'; /** @@ -30,6 +31,7 @@ const useMultiStepRegistrationExperimentVariation = ( const expVariation = getMultiStepRegistrationExperimentVariation(); if (expVariation) { setVariation(expVariation); + trackMultiStepRegistrationStep1Viewed(expVariation); } else { // This is to handle the case when user dont get variation for some reason, the register page // shows unlimited spinner. From b219fe3683c97e4547257e2b61e936e67caefa20 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah Date: Mon, 22 Apr 2024 11:34:47 +0500 Subject: [PATCH 11/82] fix: fix register button width --- src/register/RegistrationPage.jsx | 6 +++++- src/register/data/optimizelyExperiment/helper.js | 7 +++++++ src/sass/_registration.scss | 8 ++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 19b3d774..7bca6255 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -31,6 +31,7 @@ import { CONTROL, FIRST_STEP, getMultiStepRegistrationNextStep, + getRegisterButtonClassInExperiment, getRegisterButtonLabelInExperiment, getRegisterButtonSubmitStateInExperiment, MULTI_STEP_REGISTRATION_EXP_VARIATION, @@ -522,7 +523,10 @@ const RegistrationPage = (props) => { name="register-user" type="submit" variant="brand" - className="mt-4 mb-4" + className={` + mt-4 mb-4 + ${getRegisterButtonClassInExperiment(multiStepRegistrationExpVariation, multiStepRegistrationPageStep)} + `} state={getRegisterButtonSubmitStateInExperiment( submitState, validationsSubmitState, diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js index e59ec718..7140d5ec 100644 --- a/src/register/data/optimizelyExperiment/helper.js +++ b/src/register/data/optimizelyExperiment/helper.js @@ -75,6 +75,13 @@ export const getRegisterButtonLabelInExperiment = ( return existingButtonLabel; }; +export const getRegisterButtonClassInExperiment = (expVariation, registerPageStep) => { + if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep === FIRST_STEP) { + return 'continue-button'; + } + return 'register-button'; +}; + export const getRegisterButtonSubmitStateInExperiment = ( registerSubmitState, validationsSubmitState, expVariation, registerPageStep, ) => { diff --git a/src/sass/_registration.scss b/src/sass/_registration.scss index 4126da9a..884af10d 100644 --- a/src/sass/_registration.scss +++ b/src/sass/_registration.scss @@ -1,3 +1,11 @@ +.register-button { + min-width: 14.4rem; +} + +.continue-button { + min-width: 7rem; +} + .pgn__form-autosuggest__wrapper > .pgn__form-group { margin-bottom: 0 !important; } From 378250398394c86d4821efc3eadcc9dd3ce5cadf Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah Date: Mon, 22 Apr 2024 15:17:08 +0500 Subject: [PATCH 12/82] fix: fix register button loader for control --- src/register/data/optimizelyExperiment/helper.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js index 7140d5ec..f3d21381 100644 --- a/src/register/data/optimizelyExperiment/helper.js +++ b/src/register/data/optimizelyExperiment/helper.js @@ -85,7 +85,8 @@ export const getRegisterButtonClassInExperiment = (expVariation, registerPageSte export const getRegisterButtonSubmitStateInExperiment = ( registerSubmitState, validationsSubmitState, expVariation, registerPageStep, ) => { - if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep !== THIRD_STEP) { + if ((expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep !== THIRD_STEP) + || (expVariation === CONTROL && registerPageStep !== SECOND_STEP)) { return validationsSubmitState; } return registerSubmitState; From 03d1666c2cf7a341f57c9e0c93c1ab518f0e1af0 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Thu, 25 Apr 2024 15:28:05 +0500 Subject: [PATCH 13/82] feat: capture marketing lead in experiment events (#1243) --- src/register/RegistrationPage.jsx | 3 ++- src/register/data/optimizelyExperiment/track.js | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 7bca6255..e4da4b0a 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -145,7 +145,8 @@ const RegistrationPage = (props) => { setErrorCode({ type: '', count: 0 }); const nextStep = getMultiStepRegistrationNextStep(multiStepRegistrationPageStep); if (nextStep === SECOND_STEP) { - trackMultiStepRegistrationStep2Viewed(multiStepRegistrationExpVariation); + const isMarketingLead = formFields.email && configurableFormFields?.marketingEmailsOptIn; + trackMultiStepRegistrationStep2Viewed(multiStepRegistrationExpVariation, isMarketingLead); if (multiStepRegistrationExpVariation === CONTROL) { trackMultiStepRegistrationFormSubmitBtnClicked(multiStepRegistrationExpVariation); } diff --git a/src/register/data/optimizelyExperiment/track.js b/src/register/data/optimizelyExperiment/track.js index 8db71809..68a6347a 100644 --- a/src/register/data/optimizelyExperiment/track.js +++ b/src/register/data/optimizelyExperiment/track.js @@ -17,9 +17,10 @@ export const trackMultiStepRegistrationStep1Viewed = (expVariation) => { }); }; -export const trackMultiStepRegistrationStep2Viewed = (expVariation) => { +export const trackMultiStepRegistrationStep2Viewed = (expVariation, isMarketingLead) => { sendTrackEvent(eventNames.multiStepRegistrationStep2Viewed, { variation: expVariation, + is_marketing_lead: isMarketingLead, }); }; From d8947a4c0ad7fd4dcd28224f49fa68545a589601 Mon Sep 17 00:00:00 2001 From: mubbsharanwar Date: Mon, 6 May 2024 15:57:54 +0500 Subject: [PATCH 14/82] revert: multistep registration experiment revert multistep registration experiment changes VAN-1930 --- src/common-components/SocialAuthProviders.jsx | 14 +- src/common-components/ThirdPartyAuth.jsx | 4 - src/common-components/messages.jsx | 6 - src/config/index.js | 2 - src/logistration/Logistration.jsx | 55 +-- src/logistration/Logistration.test.jsx | 7 - src/register/RegistrationPage.jsx | 377 +++++------------- src/register/RegistrationPage.test.jsx | 5 - .../ConfigurableRegistrationForm.jsx | 24 +- .../components/RegistrationFailure.jsx | 11 +- .../ConfigurableRegistrationForm.test.jsx | 8 - .../tests/RegistrationFailure.test.jsx | 5 - .../components/tests/ThirdPartyAuth.test.jsx | 5 - src/register/data/actions.js | 21 +- .../data/optimizelyExperiment/helper.js | 113 ------ .../data/optimizelyExperiment/track.js | 57 --- ...ltiStepRegistrationExperimentVariation.jsx | 56 --- src/register/data/reducers.js | 25 -- src/register/data/sagas.js | 7 +- src/register/data/tests/reducers.test.js | 5 - src/register/data/utils.js | 36 +- src/register/messages.jsx | 23 -- src/sass/_registration.scss | 4 - 23 files changed, 136 insertions(+), 734 deletions(-) delete mode 100644 src/register/data/optimizelyExperiment/helper.js delete mode 100644 src/register/data/optimizelyExperiment/track.js delete mode 100644 src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx diff --git a/src/common-components/SocialAuthProviders.jsx b/src/common-components/SocialAuthProviders.jsx index 5cdb04e0..abe06da7 100644 --- a/src/common-components/SocialAuthProviders.jsx +++ b/src/common-components/SocialAuthProviders.jsx @@ -9,24 +9,14 @@ import PropTypes from 'prop-types'; import messages from './messages'; import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; -import { CONTROL, MULTI_STEP_REGISTRATION_EXP_VARIATION } from '../register/data/optimizelyExperiment/helper'; -import { trackMultiStepRegistrationSSOBtnClicked } from '../register/data/optimizelyExperiment/track'; const SocialAuthProviders = (props) => { const { formatMessage } = useIntl(); - const { - referrer, - socialAuthProviders, - multiStepRegistrationExpVariation, - } = props; + const { referrer, socialAuthProviders } = props; function handleSubmit(e) { e.preventDefault(); - if (multiStepRegistrationExpVariation === CONTROL - || multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION) { - trackMultiStepRegistrationSSOBtnClicked(multiStepRegistrationExpVariation); - } const url = e.currentTarget.dataset.providerUrl; window.location.href = getConfig().LMS_BASE_URL + url; } @@ -70,7 +60,6 @@ const SocialAuthProviders = (props) => { SocialAuthProviders.defaultProps = { referrer: LOGIN_PAGE, socialAuthProviders: [], - multiStepRegistrationExpVariation: '', }; SocialAuthProviders.propTypes = { @@ -84,7 +73,6 @@ SocialAuthProviders.propTypes = { registerUrl: PropTypes.string, skipRegistrationForm: PropTypes.bool, })), - multiStepRegistrationExpVariation: PropTypes.string, }; export default SocialAuthProviders; diff --git a/src/common-components/ThirdPartyAuth.jsx b/src/common-components/ThirdPartyAuth.jsx index d64615e0..3b782036 100644 --- a/src/common-components/ThirdPartyAuth.jsx +++ b/src/common-components/ThirdPartyAuth.jsx @@ -32,7 +32,6 @@ const ThirdPartyAuth = (props) => { handleInstitutionLogin, thirdPartyAuthApiStatus, isLoginPage, - multiStepRegistrationExpVariation, } = props; const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider; const isSocialAuthActive = !!providers.length && !currentProvider; @@ -79,7 +78,6 @@ const ThirdPartyAuth = (props) => { )} @@ -95,7 +93,6 @@ ThirdPartyAuth.defaultProps = { secondaryProviders: [], thirdPartyAuthApiStatus: PENDING_STATE, isLoginPage: false, - multiStepRegistrationExpVariation: '', }; ThirdPartyAuth.propTypes = { @@ -123,7 +120,6 @@ ThirdPartyAuth.propTypes = { ), thirdPartyAuthApiStatus: PropTypes.string, isLoginPage: PropTypes.bool, - multiStepRegistrationExpVariation: PropTypes.string, }; export default ThirdPartyAuth; diff --git a/src/common-components/messages.jsx b/src/common-components/messages.jsx index 6f0e69ca..08e88b8f 100644 --- a/src/common-components/messages.jsx +++ b/src/common-components/messages.jsx @@ -132,12 +132,6 @@ const messages = defineMessages({ defaultMessage: 'Company or school credentials', description: 'Company or school login link text.', }, - // multi step registration experiment messages - 'tab.back.btn.text': { - id: 'tab.back.btn.text', - defaultMessage: 'Back', - description: 'Tab back button text', - }, }); export default messages; diff --git a/src/config/index.js b/src/config/index.js index 5d221faa..fa3613aa 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -34,8 +34,6 @@ const configuration = { ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL, ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '', ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '', - // Multi Step Registration Experiment - MULTI_STEP_REGISTRATION_EXPERIMENT_ID: process.env.MULTI_STEP_REGISTRATION_EXPERIMENT_ID || '', }; export default configuration; diff --git a/src/logistration/Logistration.jsx b/src/logistration/Logistration.jsx index 5199e1b3..9451aa27 100644 --- a/src/logistration/Logistration.jsx +++ b/src/logistration/Logistration.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { connect, useDispatch, useSelector } from 'react-redux'; +import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -7,11 +7,10 @@ import { getAuthService } from '@edx/frontend-platform/auth'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, - IconButton, Tab, Tabs, } from '@openedx/paragon'; -import { ArrowBackIos, ChevronLeft } from '@openedx/paragon/icons'; +import { ChevronLeft } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import { Navigate, useNavigate } from 'react-router-dom'; @@ -28,11 +27,7 @@ import { import { LoginPage } from '../login'; import { backupLoginForm } from '../login/data/actions'; import { RegistrationPage } from '../register'; -import { backupRegistrationForm, setMultiStepRegistrationExpData } from '../register/data/actions'; -import { - FIRST_STEP, - getMultiStepRegistrationPreviousStep, -} from '../register/data/optimizelyExperiment/helper'; +import { backupRegistrationForm } from '../register/data/actions'; const Logistration = (props) => { const { selectedPage, tpaProviders } = props; @@ -47,10 +42,6 @@ const Logistration = (props) => { const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false; const hideRegistrationLink = getConfig().SHOW_REGISTRATION_LINKS === false; - const dispatch = useDispatch(); - const multiStepRegExpVariation = useSelector(state => state.register.multiStepRegExpVariation); - const multiStepRegistrationPageStep = useSelector(state => state.register.multiStepRegistrationPageStep); - useEffect(() => { const authService = getAuthService(); if (authService) { @@ -100,39 +91,6 @@ const Logistration = (props) => { ); - /** - * Temporary function created to resolve the complexity in tabs conditioning for multi-step - * registration experiment - */ - const getTabs = () => { - if (multiStepRegistrationPageStep !== FIRST_STEP) { - const prevStep = getMultiStepRegistrationPreviousStep(multiStepRegistrationPageStep); - return ( -
- { - dispatch(setMultiStepRegistrationExpData(multiStepRegExpVariation, prevStep)); - }} - variant="primary" - size="inline" - className="mr-1" - /> - {formatMessage(messages['tab.back.btn.text'])} -
- ); - } - return ( - handleOnSelect(tabKey, selectedPage)}> - - - - ); - }; - const isValidTpaHint = () => { const { provider } = getTpaProvider(tpaHint, providers, secondaryProviders); return !!provider; @@ -165,7 +123,12 @@ const Logistration = (props) => { ) - : (!isValidTpaHint() && !hideRegistrationLink && getTabs())} + : (!isValidTpaHint() && !hideRegistrationLink && ( + handleOnSelect(tabKey, selectedPage)}> + + + + ))} { key && ( )} diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index ce7af88f..87bf3e70 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -15,16 +15,12 @@ import { } from '../data/constants'; import { backupLoginForm } from '../login/data/actions'; import { backupRegistrationForm } from '../register/data/actions'; -import { FIRST_STEP, NOT_INITIALIZED } from '../register/data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from '../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); -jest.mock('../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const mockStore = configureStore(); const IntlLogistration = injectIntl(Logistration); @@ -67,8 +63,6 @@ describe('Logistration', () => { registrationError: {}, usernameSuggestions: [], validationApiRateLimited: false, - multiStepRegExpVariation: '', - multiStepRegistrationPageStep: FIRST_STEP, }, commonComponents: { thirdPartyAuthContext: { @@ -89,7 +83,6 @@ describe('Logistration', () => { username: 'test-user', })), })); - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); configure({ loggingService: { logError: jest.fn() }, diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index e4da4b0a..80ac1f6b 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -17,37 +17,14 @@ import RegistrationFailure from './components/RegistrationFailure'; import { backupRegistrationFormBegin, clearRegistrationBackendError, - fetchRealtimeValidations, registerNewUser, setEmailSuggestionInStore, - setMultiStepRegistrationExpData, setUserPipelineDataLoaded, } from './data/actions'; import { FORM_SUBMISSION_ERROR, TPA_AUTHENTICATION_FAILURE, } from './data/constants'; -import { - CONTROL, - FIRST_STEP, - getMultiStepRegistrationNextStep, - getRegisterButtonClassInExperiment, - getRegisterButtonLabelInExperiment, - getRegisterButtonSubmitStateInExperiment, - MULTI_STEP_REGISTRATION_EXP_VARIATION, - SECOND_STEP, - shouldDisplayFieldInExperiment, THIRD_STEP, -} from './data/optimizelyExperiment/helper'; -import { - trackMultiStepRegistrationFormSubmitBtnClicked, - trackMultiStepRegistrationStep1SubmitBtnClicked, - trackMultiStepRegistrationStep2SubmitBtnClicked, - trackMultiStepRegistrationStep2Viewed, - trackMultiStepRegistrationStep3SubmitBtnClicked, - trackMultiStepRegistrationStep3Viewed, -} from './data/optimizelyExperiment/track'; -import useMultiStepRegistrationExperimentVariation - from './data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import getBackendValidations from './data/selectors'; import { isFormValid, prepareRegistrationPayload, @@ -96,13 +73,6 @@ const RegistrationPage = (props) => { const shouldBackupState = useSelector(state => state.register.shouldBackupState); const userPipelineDataLoaded = useSelector(state => state.register.userPipelineDataLoaded); const submitState = useSelector(state => state.register.submitState); - const backendValidations = useSelector(getBackendValidations); - const multiStepRegExpVariation = useSelector(state => state.register.multiStepRegExpVariation); - const multiStepRegistrationPageStep = useSelector(state => state.register.multiStepRegistrationPageStep); - const isValidatingMultiStepRegistrationPage = useSelector( - state => state.register.isValidatingMultiStepRegistrationPage, - ); - const validationsSubmitState = useSelector(state => state.register.validationsSubmitState); const fieldDescriptions = useSelector(state => state.commonComponents.fieldDescriptions); const optionalFields = useSelector(state => state.commonComponents.optionalFields); @@ -115,6 +85,7 @@ const RegistrationPage = (props) => { const secondaryProviders = useSelector(state => state.commonComponents.thirdPartyAuthContext.secondaryProviders); const pipelineUserDetails = useSelector(state => state.commonComponents.thirdPartyAuthContext.pipelineUserDetails); + const backendValidations = useSelector(getBackendValidations); const queryParams = useMemo(() => getAllPossibleQueryParams(), []); const tpaHint = useMemo(() => getTpaHint(), []); @@ -131,38 +102,6 @@ const RegistrationPage = (props) => { ? formatMessage(messages['create.account.cta.button'], { label: cta }) : formatMessage(messages['create.account.for.free.button']); - /** - * Multi-Step Registration Page Experiment - */ - const multiStepRegistrationExpVariation = useMultiStepRegistrationExperimentVariation( - multiStepRegExpVariation, registrationEmbedded, tpaHint, currentProvider, thirdPartyAuthApiStatus, - ); - - useEffect(() => { - if (isValidatingMultiStepRegistrationPage && backendValidations - && Object.values(backendValidations).every(value => value === '') - ) { - setErrorCode({ type: '', count: 0 }); - const nextStep = getMultiStepRegistrationNextStep(multiStepRegistrationPageStep); - if (nextStep === SECOND_STEP) { - const isMarketingLead = formFields.email && configurableFormFields?.marketingEmailsOptIn; - trackMultiStepRegistrationStep2Viewed(multiStepRegistrationExpVariation, isMarketingLead); - if (multiStepRegistrationExpVariation === CONTROL) { - trackMultiStepRegistrationFormSubmitBtnClicked(multiStepRegistrationExpVariation); - } - } else if (nextStep === THIRD_STEP) { - trackMultiStepRegistrationStep3Viewed(); - if (multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION) { - trackMultiStepRegistrationFormSubmitBtnClicked(multiStepRegistrationExpVariation); - } - } - dispatch(setMultiStepRegistrationExpData(multiStepRegistrationExpVariation, nextStep)); - } - }, [ // eslint-disable-line react-hooks/exhaustive-deps - isValidatingMultiStepRegistrationPage, - backendValidations, - ]); - /** * Set the userPipelineDetails data in formFields for only first time */ @@ -209,11 +148,8 @@ const RegistrationPage = (props) => { formFields: { ...formFields }, errors: { ...errors }, })); - dispatch(setMultiStepRegistrationExpData( - multiStepRegistrationExpVariation, multiStepRegistrationPageStep, false, - )); } - }, [shouldBackupState]); // eslint-disable-line react-hooks/exhaustive-deps + }, [shouldBackupState, configurableFormFields, formFields, errors, dispatch, backedUpFormData]); useEffect(() => { if (backendValidations) { @@ -233,22 +169,13 @@ const RegistrationPage = (props) => { useEffect(() => { if (registrationResult.success) { - let registeredEventProps = {}; - - if (multiStepRegistrationExpVariation === CONTROL - || multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION) { - registeredEventProps = { - variation: multiStepRegistrationExpVariation, - }; - } - // This event is used by GTM - sendTrackEvent('edx.bi.user.account.registered.client', registeredEventProps); + sendTrackEvent('edx.bi.user.account.registered.client', {}); // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); } - }, [registrationResult]); // eslint-disable-line react-hooks/exhaustive-deps + }, [registrationResult]); const handleOnChange = (event) => { const { name } = event.target; @@ -320,67 +247,7 @@ const RegistrationPage = (props) => { const handleSubmit = (e) => { e.preventDefault(); - - if (multiStepRegistrationExpVariation === CONTROL - && multiStepRegistrationPageStep === SECOND_STEP) { - trackMultiStepRegistrationStep2SubmitBtnClicked(multiStepRegistrationExpVariation); - } - if (multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION - && multiStepRegistrationPageStep === THIRD_STEP) { - trackMultiStepRegistrationStep3SubmitBtnClicked(); - } - if (multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION - && multiStepRegistrationPageStep !== THIRD_STEP) { - let formFieldsPayload = {}; - - if (multiStepRegistrationPageStep === FIRST_STEP) { - trackMultiStepRegistrationStep1SubmitBtnClicked(multiStepRegistrationExpVariation); - // We only want to validate email in the first step of registration - // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. - formFieldsPayload = { email: formFields.email }; - } else if (multiStepRegistrationPageStep === SECOND_STEP) { - trackMultiStepRegistrationStep2SubmitBtnClicked(multiStepRegistrationExpVariation); - // We only want to validate name and password field in the second step of registration - // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. - formFieldsPayload = { name: formFields.name, password: formFields.password }; - } - - const { isValid, fieldErrors } = isFormValid( - formFieldsPayload, errors, {}, {}, formatMessage, - ); - setErrors(prevErrors => ({ - ...prevErrors, - ...fieldErrors, - })); - // returning if not valid - if (!isValid) { - setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 })); - } else { - dispatch(fetchRealtimeValidations(formFieldsPayload, true)); - } - } else if (multiStepRegistrationExpVariation === CONTROL && multiStepRegistrationPageStep !== SECOND_STEP) { - trackMultiStepRegistrationStep1SubmitBtnClicked(multiStepRegistrationExpVariation); - // We only want to validate name, email and password fields in the first step of CONTROL registration - // Doing manual validations to avoid the case where user clicks CTA without focusing out of field. - const formFieldsPayload = { name: formFields.name, email: formFields.email, password: formFields.password }; - - const { isValid, fieldErrors } = isFormValid( - formFieldsPayload, errors, {}, {}, formatMessage, - ); - - setErrors(prevErrors => ({ - ...prevErrors, - ...fieldErrors, - })); - // returning if not valid - if (!isValid) { - setErrorCode(prevState => ({ type: FORM_SUBMISSION_ERROR, count: prevState.count + 1 })); - } else { - dispatch(fetchRealtimeValidations(formFieldsPayload, true)); - } - } else { - registerUser(); - } + registerUser(); }; useEffect(() => { @@ -415,150 +282,104 @@ const RegistrationPage = (props) => { getConfig().ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN && !!Object.keys(optionalFields.fields).length } /> - {(autoSubmitRegForm && !errorCode.type) - || (!multiStepRegistrationExpVariation && !(registrationEmbedded || !!tpaHint || !!currentProvider)) - ? ( -
- -
- ) : ( -
+ +
+ ) : ( +
+ + +
+ + + + {!currentProvider && ( + )} - > - - e.preventDefault()} /> - - {(multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION - && multiStepRegistrationPageStep === SECOND_STEP) && ( -

- {formatMessage(messages['multistep.registration.username.second.step.guideline.content'])} -

- )} - {((multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION - && multiStepRegistrationPageStep === THIRD_STEP) - || (multiStepRegistrationExpVariation === CONTROL && multiStepRegistrationPageStep === SECOND_STEP)) - && ( -

- {formatMessage(messages['multistep.registration.username.third.step.guideline.content'])} -

- )} - {shouldDisplayFieldInExperiment( - 'name', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - ) && ( - - )} - {shouldDisplayFieldInExperiment( - 'email', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - ) && ( - - )} - {shouldDisplayFieldInExperiment( - 'username', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - ) && ( - - )} - {!currentProvider && shouldDisplayFieldInExperiment( - 'password', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - ) && ( - - )} - - e.preventDefault()} - /> - {(!registrationEmbedded && shouldDisplayFieldInExperiment( - 'ThirdPartyAuth', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - )) - && ( - - )} - -
- )} + )} + + + )} + ); }; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 1f6fd1a3..2634713f 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -17,9 +17,6 @@ import { setUserPipelineDataLoaded, } from './data/actions'; import { INTERNAL_SERVER_ERROR } from './data/constants'; -import { NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from './data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from './RegistrationPage'; import { AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, @@ -33,7 +30,6 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); -jest.mock('./data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -132,7 +128,6 @@ describe('RegistrationPage', () => { institutionLogin: false, }; window.location = { search: '' }; - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index 5e69c7c0..be1f9c27 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -6,7 +6,6 @@ import PropTypes from 'prop-types'; import { FormFieldRenderer } from '../../field-renderer'; import { FIELDS } from '../data/constants'; -import { FIRST_STEP, shouldDisplayFieldInExperiment } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields'; @@ -32,8 +31,6 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors, setFormFields, autoSubmitRegistrationForm, - multiStepRegistrationExpVariation, - multiStepRegistrationPageStep, } = props; const countryList = useMemo(() => getCountryList(getLocale()), []); @@ -108,9 +105,7 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors(prevErrors => ({ ...prevErrors, [name]: '' })); }; - if (flags.showConfigurableRegistrationFields && shouldDisplayFieldInExperiment( - 'other_fields', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - )) { + if (flags.showConfigurableRegistrationFields) { Object.keys(fieldDescriptions).forEach(fieldName => { const fieldData = fieldDescriptions[fieldName]; switch (fieldData.name) { @@ -162,9 +157,7 @@ const ConfigurableRegistrationForm = (props) => { }); } - if ((flags.showConfigurableEdxFields || showCountryField) && shouldDisplayFieldInExperiment( - 'country', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - )) { + if (flags.showConfigurableEdxFields || showCountryField) { formFieldDescriptions.push( { ); } - if (flags.showMarketingEmailOptInCheckbox && shouldDisplayFieldInExperiment( - 'marketing_email_opt_in', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - )) { + if (flags.showMarketingEmailOptInCheckbox) { formFieldDescriptions.push( { ); } - if ((flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) - && shouldDisplayFieldInExperiment( - 'honor_code', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - )) { + if (flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) { formFieldDescriptions.push( @@ -239,15 +227,11 @@ ConfigurableRegistrationForm.propTypes = { setFieldErrors: PropTypes.func.isRequired, setFormFields: PropTypes.func.isRequired, autoSubmitRegistrationForm: PropTypes.bool, - multiStepRegistrationExpVariation: PropTypes.string, - multiStepRegistrationPageStep: PropTypes.string, }; ConfigurableRegistrationForm.defaultProps = { fieldDescriptions: {}, autoSubmitRegistrationForm: false, - multiStepRegistrationExpVariation: '', - multiStepRegistrationPageStep: FIRST_STEP, }; export default ConfigurableRegistrationForm; diff --git a/src/register/components/RegistrationFailure.jsx b/src/register/components/RegistrationFailure.jsx index f2a83585..c34c2af7 100644 --- a/src/register/components/RegistrationFailure.jsx +++ b/src/register/components/RegistrationFailure.jsx @@ -13,13 +13,12 @@ import { TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../data/constants'; -import { FIRST_STEP } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; const RegistrationFailureMessage = (props) => { const { formatMessage } = useIntl(); const { - context, errorCode, failureCount, multiStepRegistrationPageStep, + context, errorCode, failureCount, } = props; useEffect(() => { @@ -50,11 +49,7 @@ const RegistrationFailureMessage = (props) => { errorMessage = formatMessage(messages['registration.tpa.session.expired'], { provider: context.provider }); break; default: - if (multiStepRegistrationPageStep !== FIRST_STEP) { - errorMessage = formatMessage(messages['multistep.registration.form.submission.error']); - } else { - errorMessage = formatMessage(messages['registration.empty.form.submission.error']); - } + errorMessage = formatMessage(messages['registration.empty.form.submission.error']); break; } @@ -70,7 +65,6 @@ RegistrationFailureMessage.defaultProps = { context: { errorMessage: null, }, - multiStepRegistrationPageStep: FIRST_STEP, }; RegistrationFailureMessage.propTypes = { @@ -80,7 +74,6 @@ RegistrationFailureMessage.propTypes = { }), errorCode: PropTypes.string.isRequired, failureCount: PropTypes.number.isRequired, - multiStepRegistrationPageStep: PropTypes.string, }; export default RegistrationFailureMessage; diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 28269072..537d11eb 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -11,9 +11,6 @@ import configureStore from 'redux-mock-store'; import { registerNewUser } from '../../data/actions'; import { FIELDS } from '../../data/constants'; -import { FIRST_STEP, NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; @@ -25,7 +22,6 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); -jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlConfigurableRegistrationForm = injectIntl(ConfigurableRegistrationForm); const IntlRegistrationPage = injectIntl(RegistrationPage); @@ -97,9 +93,6 @@ describe('ConfigurableRegistrationForm', () => { registrationError: {}, registrationFormData, usernameSuggestions: [], - multiStepRegistrationPageStep: FIRST_STEP, - multiStepRegExpVariation: '', - isValidatingMultiStepRegistrationPage: false, }, commonComponents: { thirdPartyAuthApiStatus: null, @@ -128,7 +121,6 @@ describe('ConfigurableRegistrationForm', () => { }; window.location = { search: '' }; getLocale.mockImplementationOnce(() => ('en-us')); - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/RegistrationFailure.test.jsx b/src/register/components/tests/RegistrationFailure.test.jsx index b9667c9c..003cc966 100644 --- a/src/register/components/tests/RegistrationFailure.test.jsx +++ b/src/register/components/tests/RegistrationFailure.test.jsx @@ -12,9 +12,6 @@ import configureStore from 'redux-mock-store'; import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../../data/constants'; -import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import RegistrationFailureMessage from '../RegistrationFailure'; @@ -26,7 +23,6 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); -jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage); @@ -125,7 +121,6 @@ describe('RegistrationFailure', () => { institutionLogin: false, }; window.location = { search: '' }; - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/ThirdPartyAuth.test.jsx b/src/register/components/tests/ThirdPartyAuth.test.jsx index 45672ad5..917f10f9 100644 --- a/src/register/components/tests/ThirdPartyAuth.test.jsx +++ b/src/register/components/tests/ThirdPartyAuth.test.jsx @@ -12,9 +12,6 @@ import configureStore from 'redux-mock-store'; import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, } from '../../../data/constants'; -import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -25,7 +22,6 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); -jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -124,7 +120,6 @@ describe('ThirdPartyAuth', () => { institutionLogin: false, }; window.location = { search: '' }; - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/data/actions.js b/src/register/data/actions.js index 530f3bc8..68bfe9e6 100644 --- a/src/register/data/actions.js +++ b/src/register/data/actions.js @@ -8,8 +8,6 @@ export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERRO export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE'; export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED'; export const REGISTER_SET_EMAIL_SUGGESTIONS = 'REGISTER_SET_EMAIL_SUGGESTIONS'; -export const REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA = 'REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA'; - // Backup registration form export const backupRegistrationForm = () => ({ type: BACKUP_REGISTRATION_DATA.BASE, @@ -21,19 +19,18 @@ export const backupRegistrationFormBegin = (data) => ({ }); // Validate fields from the backend -export const fetchRealtimeValidations = (formPayload, isValidatingMultiStepRegistrationPage) => ({ +export const fetchRealtimeValidations = (formPayload) => ({ type: REGISTER_FORM_VALIDATIONS.BASE, - payload: { formPayload, isValidatingMultiStepRegistrationPage }, + payload: { formPayload }, }); -export const fetchRealtimeValidationsBegin = (isValidatingMultiStepRegistrationPage) => ({ +export const fetchRealtimeValidationsBegin = () => ({ type: REGISTER_FORM_VALIDATIONS.BEGIN, - payload: { isValidatingMultiStepRegistrationPage }, }); -export const fetchRealtimeValidationsSuccess = (validations, isValidatingMultiStepRegistrationPage) => ({ +export const fetchRealtimeValidationsSuccess = (validations) => ({ type: REGISTER_FORM_VALIDATIONS.SUCCESS, - payload: { validations, isValidatingMultiStepRegistrationPage }, + payload: { validations }, }); export const fetchRealtimeValidationsFailure = () => ({ @@ -85,11 +82,3 @@ export const setUserPipelineDataLoaded = (value) => ({ type: REGISTER_SET_USER_PIPELINE_DATA_LOADED, payload: { value }, }); - -// Multi Step Registration Experiment Actions -export const setMultiStepRegistrationExpData = ( - multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage, -) => ({ - type: REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, - payload: { multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage }, -}); diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js deleted file mode 100644 index f3d21381..00000000 --- a/src/register/data/optimizelyExperiment/helper.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * This file contains data for Multi Step Registration Optimizely experiment - */ -import { getConfig } from '@edx/frontend-platform'; - -import messages from '../../messages'; - -export const NOT_INITIALIZED = 'experiment-not-initialized'; -export const CONTROL = 'control-registration-page'; -export const MULTI_STEP_REGISTRATION_EXP_VARIATION = 'multi-step-registration-page'; - -export const FIRST_STEP = 'first-step'; -export const SECOND_STEP = 'second-step'; -export const THIRD_STEP = 'third-step'; - -export const CONTROL_REGISTER_PAGE_FIRST_STEP_FIELDS = ['name', 'email', 'password', 'marketing_email_opt_in', 'ThirdPartyAuth']; -export const CONTROL_REGISTER_PAGE_SECOND_STEP_FIELDS = ['username', 'country']; -export const CONTROL_REGISTER_PAGE_COMMON_FIELDS = ['tos_and_honor_code', 'honor_code']; - -export const MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS = ['email', 'marketing_email_opt_in', 'ThirdPartyAuth']; -export const MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS = ['name', 'password']; -export const MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS = ['username', 'country']; -export const MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS = ['tos_and_honor_code', 'honor_code']; - -const MULTI_STEP_REGISTRATION_EXP_PAGE = 'authn_register_page'; - -export function getMultiStepRegistrationExperimentVariation() { - try { - if (window.optimizely - && window.optimizely.get('data').experiments[getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID]) { - const selectedVariant = window.optimizely.get('state').getVariationMap()[ - getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID - ]; - return selectedVariant?.name; - } - } catch (e) { /* empty */ } - return ''; -} - -export function activateMultiStepRegistrationExperiment() { - window.optimizely = window.optimizely || []; - window.optimizely.push({ - type: 'page', - pageName: MULTI_STEP_REGISTRATION_EXP_PAGE, - }); -} - -/** - * We want to display username and honor_code fields in second page if user is in multi-step - * registration page experiment - */ -export const shouldDisplayFieldInExperiment = (fieldName, expVariation, registerPageStep) => ( - !expVariation || expVariation === NOT_INITIALIZED - || (expVariation === CONTROL - && ( - CONTROL_REGISTER_PAGE_COMMON_FIELDS.includes(fieldName) - || (registerPageStep === FIRST_STEP && CONTROL_REGISTER_PAGE_FIRST_STEP_FIELDS.includes(fieldName)) - || (registerPageStep === SECOND_STEP && CONTROL_REGISTER_PAGE_SECOND_STEP_FIELDS.includes(fieldName)) - )) - || (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION - && ( - MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS.includes(fieldName) - || (registerPageStep === FIRST_STEP && MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS.includes(fieldName)) - || (registerPageStep === SECOND_STEP && MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS.includes(fieldName)) - || (registerPageStep === THIRD_STEP && MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS.includes(fieldName)) - )) -); - -export const getRegisterButtonLabelInExperiment = ( - existingButtonLabel, expVariation, registerPageStep, formatMessage, -) => { - if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep === FIRST_STEP) { - return formatMessage(messages['multistep.registration.exp.continue.button']); - } - return existingButtonLabel; -}; - -export const getRegisterButtonClassInExperiment = (expVariation, registerPageStep) => { - if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep === FIRST_STEP) { - return 'continue-button'; - } - return 'register-button'; -}; - -export const getRegisterButtonSubmitStateInExperiment = ( - registerSubmitState, validationsSubmitState, expVariation, registerPageStep, -) => { - if ((expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep !== THIRD_STEP) - || (expVariation === CONTROL && registerPageStep !== SECOND_STEP)) { - return validationsSubmitState; - } - return registerSubmitState; -}; - -export const getMultiStepRegistrationPreviousStep = (currentStep) => { - if (currentStep === THIRD_STEP) { - return SECOND_STEP; - } - if (currentStep === SECOND_STEP) { - return FIRST_STEP; - } - return currentStep; -}; - -export const getMultiStepRegistrationNextStep = (currentStep) => { - if (currentStep === FIRST_STEP) { - return SECOND_STEP; - } - if (currentStep === SECOND_STEP) { - return THIRD_STEP; - } - return currentStep; -}; diff --git a/src/register/data/optimizelyExperiment/track.js b/src/register/data/optimizelyExperiment/track.js deleted file mode 100644 index 68a6347a..00000000 --- a/src/register/data/optimizelyExperiment/track.js +++ /dev/null @@ -1,57 +0,0 @@ -import { sendTrackEvent } from '@edx/frontend-platform/analytics'; - -export const eventNames = { - multiStepRegistrationStep1Viewed: 'edx.bi.user.multistepregistration.step1.viewed', - multiStepRegistrationStep2Viewed: 'edx.bi.user.multistepregistration.step2.viewed', - multiStepRegistrationStep3Viewed: 'edx.bi.user.multistepregistration.step3.viewed', - multiStepRegistrationStep1SubmitBtnClicked: 'edx.bi.user.registration.step1.submit.click', - multiStepRegistrationStep2SubmitBtnClicked: 'edx.bi.user.registration.step2.submit.click', - multiStepRegistrationStep3SubmitBtnClicked: 'edx.bi.user.registration.step3.submit.click', - multiStepRegistrationFormSubmitBtnClicked: 'edx.bi.user.registration.form.submit.click', - multiStepRegistrationSSOBtnClicked: 'edx.bi.user.registration.sso.btn.click', -}; - -export const trackMultiStepRegistrationStep1Viewed = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationStep1Viewed, { - variation: expVariation, - }); -}; - -export const trackMultiStepRegistrationStep2Viewed = (expVariation, isMarketingLead) => { - sendTrackEvent(eventNames.multiStepRegistrationStep2Viewed, { - variation: expVariation, - is_marketing_lead: isMarketingLead, - }); -}; - -export const trackMultiStepRegistrationStep3Viewed = () => { - sendTrackEvent(eventNames.multiStepRegistrationStep3Viewed, {}); -}; - -export const trackMultiStepRegistrationStep1SubmitBtnClicked = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationStep1SubmitBtnClicked, { - variation: expVariation, - }); -}; - -export const trackMultiStepRegistrationStep2SubmitBtnClicked = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationStep2SubmitBtnClicked, { - variation: expVariation, - }); -}; - -export const trackMultiStepRegistrationStep3SubmitBtnClicked = () => { - sendTrackEvent(eventNames.multiStepRegistrationStep3SubmitBtnClicked, {}); -}; - -export const trackMultiStepRegistrationFormSubmitBtnClicked = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationFormSubmitBtnClicked, { - variation: expVariation, - }); -}; - -export const trackMultiStepRegistrationSSOBtnClicked = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationSSOBtnClicked, { - variation: expVariation, - }); -}; diff --git a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx deleted file mode 100644 index 97d0a233..00000000 --- a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { - activateMultiStepRegistrationExperiment, - getMultiStepRegistrationExperimentVariation, - NOT_INITIALIZED, -} from './helper'; -import { trackMultiStepRegistrationStep1Viewed } from './track'; -import { COMPLETE_STATE } from '../../../data/constants'; - -/** - * This hook returns activates multi step registration experiment and returns the experiment - * variation for the user. - */ -const useMultiStepRegistrationExperimentVariation = ( - initExpVariation, - registrationEmbedded, - tpaHint, - currentProvider, - thirdPartyAuthApiStatus, -) => { - const [variation, setVariation] = useState(initExpVariation); - - useEffect(() => { - if (initExpVariation || registrationEmbedded || !!tpaHint || !!currentProvider - || thirdPartyAuthApiStatus !== COMPLETE_STATE) { - return variation; - } - - const getVariation = () => { - const expVariation = getMultiStepRegistrationExperimentVariation(); - if (expVariation) { - setVariation(expVariation); - trackMultiStepRegistrationStep1Viewed(expVariation); - } else { - // This is to handle the case when user dont get variation for some reason, the register page - // shows unlimited spinner. - setVariation(NOT_INITIALIZED); - } - }; - - activateMultiStepRegistrationExperiment(); - - const timer = setTimeout(getVariation, 300); - - return () => { - clearTimeout(timer); - }; - }, [ // eslint-disable-line react-hooks/exhaustive-deps - currentProvider, initExpVariation, registrationEmbedded, thirdPartyAuthApiStatus, tpaHint, - ]); - - return variation; -}; - -export default useMultiStepRegistrationExperimentVariation; diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index 32438877..70c3a994 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -5,11 +5,9 @@ import { REGISTER_NEW_USER, REGISTER_SET_COUNTRY_CODE, REGISTER_SET_EMAIL_SUGGESTIONS, - REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from './actions'; -import { FIRST_STEP } from './optimizelyExperiment/helper'; import { DEFAULT_STATE, PENDING_STATE, @@ -37,14 +35,10 @@ export const defaultState = { }, validations: null, submitState: DEFAULT_STATE, - validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, - multiStepRegExpVariation: '', - multiStepRegistrationPageStep: FIRST_STEP, - isValidatingMultiStepRegistrationPage: false, }; const reducer = (state = defaultState, action = {}) => { @@ -91,22 +85,12 @@ const reducer = (state = defaultState, action = {}) => { registrationError: { ...registrationErrorTemp }, }; } - case REGISTER_FORM_VALIDATIONS.BEGIN: { - return { - ...state, - validationsSubmitState: action.payload?.isValidatingMultiStepRegistrationPage - ? PENDING_STATE - : state.validationsSubmitState, - }; - } case REGISTER_FORM_VALIDATIONS.SUCCESS: { const { usernameSuggestions, ...validationWithoutUsernameSuggestions } = action.payload.validations; return { ...state, validations: validationWithoutUsernameSuggestions, - isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, usernameSuggestions: usernameSuggestions || state.usernameSuggestions, - validationsSubmitState: DEFAULT_STATE, }; } case REGISTER_FORM_VALIDATIONS.FAILURE: @@ -114,7 +98,6 @@ const reducer = (state = defaultState, action = {}) => { ...state, validationApiRateLimited: true, validations: null, - validationsSubmitState: DEFAULT_STATE, }; case REGISTER_CLEAR_USERNAME_SUGGESTIONS: return { @@ -146,14 +129,6 @@ const reducer = (state = defaultState, action = {}) => { emailSuggestion: action.payload.emailSuggestion, }, }; - case REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA: { - return { - ...state, - multiStepRegExpVariation: action.payload.multiStepRegExpVariation, - multiStepRegistrationPageStep: action.payload.multiStepRegistrationPageStep, - isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, - }; - } default: return { ...state, diff --git a/src/register/data/sagas.js b/src/register/data/sagas.js index 3edd395e..0325cd4b 100644 --- a/src/register/data/sagas.js +++ b/src/register/data/sagas.js @@ -40,13 +40,10 @@ export function* handleNewUserRegistration(action) { export function* fetchRealtimeValidations(action) { try { - yield put(fetchRealtimeValidationsBegin(action.payload?.isValidatingMultiStepRegistrationPage)); + yield put(fetchRealtimeValidationsBegin()); const { fieldValidations } = yield call(getFieldsValidations, action.payload.formPayload); - yield put(fetchRealtimeValidationsSuccess( - camelCaseObject(fieldValidations), - action.payload?.isValidatingMultiStepRegistrationPage, - )); + yield put(fetchRealtimeValidationsSuccess(camelCaseObject(fieldValidations))); } catch (e) { if (e.response && e.response.status === 403) { yield put(fetchRealtimeValidationsFailure()); diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index 909704d2..3e2270ab 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -11,7 +11,6 @@ import { REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from '../actions'; -import { FIRST_STEP } from '../optimizelyExperiment/helper'; import reducer from '../reducers'; describe('Registration Reducer Tests', () => { @@ -35,14 +34,10 @@ describe('Registration Reducer Tests', () => { }, validations: null, submitState: DEFAULT_STATE, - validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, - multiStepRegExpVariation: '', - multiStepRegistrationPageStep: FIRST_STEP, - isValidatingMultiStepRegistrationPage: false, }; it('should return the initial state', () => { diff --git a/src/register/data/utils.js b/src/register/data/utils.js index 747f2f2c..ee08f0b6 100644 --- a/src/register/data/utils.js +++ b/src/register/data/utils.js @@ -43,39 +43,31 @@ export const isFormValid = ( Object.keys(payload).forEach(key => { switch (key) { case 'name': - if (!fieldErrors.name) { - fieldErrors.name = validateName(payload.name, formatMessage); - } + fieldErrors.name = validateName(payload.name, formatMessage); if (fieldErrors.name) { isValid = false; } break; case 'email': { - if (!fieldErrors.email) { - const { - fieldError, confirmEmailError, suggestion, - } = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage); - if (fieldError) { - fieldErrors.email = fieldError; - isValid = false; - } - if (confirmEmailError) { - fieldErrors.confirm_email = confirmEmailError; - isValid = false; - } - emailSuggestion = suggestion; + const { + fieldError, confirmEmailError, suggestion, + } = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage); + if (fieldError) { + fieldErrors.email = fieldError; + isValid = false; } + if (confirmEmailError) { + fieldErrors.confirm_email = confirmEmailError; + isValid = false; + } + emailSuggestion = suggestion; if (fieldErrors.email) { isValid = false; } break; } case 'username': - if (!fieldErrors.username) { - fieldErrors.username = validateUsername(payload.username, formatMessage); - } + fieldErrors.username = validateUsername(payload.username, formatMessage); if (fieldErrors.username) { isValid = false; } break; case 'password': - if (!fieldErrors.password) { - fieldErrors.password = validatePasswordField(payload.password, formatMessage); - } + fieldErrors.password = validatePasswordField(payload.password, formatMessage); if (fieldErrors.password) { isValid = false; } break; default: diff --git a/src/register/messages.jsx b/src/register/messages.jsx index cea5e5df..39d9e7f5 100644 --- a/src/register/messages.jsx +++ b/src/register/messages.jsx @@ -201,29 +201,6 @@ const messages = defineMessages({ defaultMessage: 'Did you mean', description: 'Did you mean alert suggestion', }, - // MultiStep Registration experiment - 'multistep.registration.exp.continue.button': { - id: 'multistep.registration.exp.continue.button', - defaultMessage: 'Continue', - description: 'Label text for multistep registration page second step', - }, - 'multistep.registration.username.second.step.guideline.content': { - id: 'multistep.registration.username.second.step.guideline.content', - defaultMessage: 'Finish Registration', - description: 'Guideline content for username field in multi-step registration experiment step 2', - }, - 'multistep.registration.username.third.step.guideline.content': { - id: 'multistep.registration.username.third.step.guideline.content', - defaultMessage: 'To finalize your registration, please confirm your country of residence ' - + 'and create a public username that will identify you in your course communication forums. ' - + 'The username cannot be changed.', - description: 'Guideline content for username field in multi-step registration experiment step 2', - }, - 'multistep.registration.form.submission.error': { - id: 'multistep.registration.form.submission.error', - defaultMessage: 'Please check your responses for this and the previous step and try again.', - description: 'Error message that appears on top of the form when invalid form is submitted', - }, }); export default messages; diff --git a/src/sass/_registration.scss b/src/sass/_registration.scss index 884af10d..75889601 100644 --- a/src/sass/_registration.scss +++ b/src/sass/_registration.scss @@ -2,10 +2,6 @@ min-width: 14.4rem; } -.continue-button { - min-width: 7rem; -} - .pgn__form-autosuggest__wrapper > .pgn__form-group { margin-bottom: 0 !important; } From 52e438652c306ea60b80308a2450d1a1381fbfb1 Mon Sep 17 00:00:00 2001 From: Blue Date: Tue, 7 May 2024 16:44:47 +0500 Subject: [PATCH 15/82] 2u-main rebase with master (#1246) Rebase 2u-main with master --- .env | 1 + src/common-components/ThirdPartyAuth.jsx | 3 +- src/config/index.js | 1 + .../CountryField/CountryField.jsx | 2 +- src/register/RegistrationPage.jsx | 26 ++++++---- src/register/RegistrationPage.test.jsx | 49 ++++++++++++++++++- .../ConfigurableRegistrationForm.jsx | 6 ++- 7 files changed, 73 insertions(+), 15 deletions(-) diff --git a/.env b/.env index 661e6802..9d2c9658 100644 --- a/.env +++ b/.env @@ -23,6 +23,7 @@ POST_REGISTRATION_REDIRECT_URL='' SEARCH_CATALOG_URL='' # ***** Features flags ***** DISABLE_ENTERPRISE_LOGIN='' +ENABLE_AUTO_GENERATED_USERNAME='' ENABLE_DYNAMIC_REGISTRATION_FIELDS='' ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN='' ENABLE_POST_REGISTRATION_RECOMMENDATIONS='' diff --git a/src/common-components/ThirdPartyAuth.jsx b/src/common-components/ThirdPartyAuth.jsx index 3b782036..7dcef3e1 100644 --- a/src/common-components/ThirdPartyAuth.jsx +++ b/src/common-components/ThirdPartyAuth.jsx @@ -37,6 +37,7 @@ const ThirdPartyAuth = (props) => { const isSocialAuthActive = !!providers.length && !currentProvider; const isEnterpriseLoginDisabled = getConfig().DISABLE_ENTERPRISE_LOGIN; const enterpriseLoginURL = getConfig().LMS_BASE_URL + ENTERPRISE_LOGIN_URL; + const isThirdPartyAuthActive = isSocialAuthActive || (isEnterpriseLoginDisabled && isInstitutionAuthActive); return ( <> @@ -61,7 +62,7 @@ const ThirdPartyAuth = (props) => { )} - {thirdPartyAuthApiStatus === PENDING_STATE ? ( + {thirdPartyAuthApiStatus === PENDING_STATE && isThirdPartyAuthActive ? (
diff --git a/src/config/index.js b/src/config/index.js index fa3613aa..badb6fe7 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -4,6 +4,7 @@ const configuration = { USER_RETENTION_COOKIE_NAME: process.env.USER_RETENTION_COOKIE_NAME || '', // Features DISABLE_ENTERPRISE_LOGIN: process.env.DISABLE_ENTERPRISE_LOGIN || '', + ENABLE_AUTO_GENERATED_USERNAME: process.env.ENABLE_AUTO_GENERATED_USERNAME || false, ENABLE_DYNAMIC_REGISTRATION_FIELDS: process.env.ENABLE_DYNAMIC_REGISTRATION_FIELDS || false, ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: process.env.ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN || false, ENABLE_POST_REGISTRATION_RECOMMENDATIONS: process.env.ENABLE_POST_REGISTRATION_RECOMMENDATIONS || false, diff --git a/src/register/RegistrationFields/CountryField/CountryField.jsx b/src/register/RegistrationFields/CountryField/CountryField.jsx index c411750b..4f9e06c6 100644 --- a/src/register/RegistrationFields/CountryField/CountryField.jsx +++ b/src/register/RegistrationFields/CountryField/CountryField.jsx @@ -97,7 +97,7 @@ const CountryField = (props) => { }; const getCountryList = () => countryList.map((country) => ( - + {country[COUNTRY_DISPLAY_KEY]} )); diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 80ac1f6b..8c5ea79f 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -60,6 +60,7 @@ const RegistrationPage = (props) => { showConfigurableEdxFields: getConfig().SHOW_CONFIGURABLE_EDX_FIELDS, showConfigurableRegistrationFields: getConfig().ENABLE_DYNAMIC_REGISTRATION_FIELDS, showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN, + autoGeneratedUsernameEnabled: getConfig().ENABLE_AUTO_GENERATED_USERNAME, }; const { handleInstitutionLogin, @@ -215,6 +216,9 @@ const RegistrationPage = (props) => { delete payload.password; payload.social_auth_provider = currentProvider; } + if (flags.autoGeneratedUsernameEnabled) { + delete payload.username; + } // Validating form data before submitting const { isValid, fieldErrors, emailSuggestion } = isFormValid( @@ -324,16 +328,18 @@ const RegistrationPage = (props) => { helpText={[formatMessage(messages['help.text.email'])]} floatingLabel={formatMessage(messages['registration.email.label'])} /> - + {!flags.autoGeneratedUsernameEnabled && ( + + )} {!currentProvider && ( { jest.clearAllMocks(); }); - const populateRequiredFields = (getByLabelText, payload, isThirdPartyAuth = false) => { + const populateRequiredFields = ( + getByLabelText, + payload, + isThirdPartyAuth = false, + autoGeneratedUsernameEnabled = false, + ) => { fireEvent.change(getByLabelText('Full name'), { target: { value: payload.name, name: 'name' } }); - fireEvent.change(getByLabelText('Public username'), { target: { value: payload.username, name: 'username' } }); + if (!autoGeneratedUsernameEnabled) { + fireEvent.change(getByLabelText('Public username'), { target: { value: payload.username, name: 'username' } }); + } fireEvent.change(getByLabelText('Email'), { target: { value: payload.email, name: 'email' } }); fireEvent.change(getByLabelText('Country/Region'), { target: { value: payload.country, name: 'country' } }); @@ -299,6 +306,44 @@ describe('RegistrationPage', () => { }); }); + it('should submit form without UsernameField when autoGeneratedUsernameEnabled is true', () => { + mergeConfig({ + ENABLE_AUTO_GENERATED_USERNAME: true, + }); + jest.spyOn(global.Date, 'now').mockImplementation(() => 0); + const payload = { + name: 'John Doe', + email: 'john.doe@gmail.com', + password: 'password1', + country: 'Pakistan', + honor_code: true, + totalRegistrationTime: 0, + }; + + store.dispatch = jest.fn(store.dispatch); + const { getByLabelText, container } = render(routerWrapper(reduxWrapper())); + populateRequiredFields(getByLabelText, payload, false, true); + const button = container.querySelector('button.btn-brand'); + fireEvent.click(button); + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + mergeConfig({ + ENABLE_AUTO_GENERATED_USERNAME: false, + }); + }); + + it('should not display UsernameField when ENABLE_AUTO_GENERATED_USERNAME is true', () => { + mergeConfig({ + ENABLE_AUTO_GENERATED_USERNAME: true, + }); + + const { queryByLabelText } = render(routerWrapper(reduxWrapper())); + expect(queryByLabelText('Username')).toBeNull(); + + mergeConfig({ + ENABLE_AUTO_GENERATED_USERNAME: false, + }); + }); + it('should not dispatch registerNewUser on empty form Submission', () => { store.dispatch = jest.fn(store.dispatch); diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index be1f9c27..8c300b7e 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -33,7 +33,11 @@ const ConfigurableRegistrationForm = (props) => { autoSubmitRegistrationForm, } = props; - const countryList = useMemo(() => getCountryList(getLocale()), []); + /** The reason for adding the entry 'United States' is that Chrome browser aut-fill the form with the 'Unites + States' instead of 'United States of America' which does not exist in country dropdown list and gets the user + confused and unable to create an account. So we added the United States entry in the dropdown list. + */ + const countryList = useMemo(() => getCountryList(getLocale()).concat([{ code: 'US', name: 'United States' }]), []); let showTermsOfServiceAndHonorCode = false; let showCountryField = false; From dcbd644a257573306d4ae64f3b034925cc51f388 Mon Sep 17 00:00:00 2001 From: Blue Date: Mon, 13 May 2024 14:11:03 +0500 Subject: [PATCH 16/82] feat: implement auto generated username experiment (#1248) * feat: implement auto generated username registration exp --- src/config/index.js | 1 + src/logistration/Logistration.test.jsx | 5 + src/register/RegistrationPage.jsx | 203 ++++++++++-------- src/register/RegistrationPage.test.jsx | 5 + .../ConfigurableRegistrationForm.test.jsx | 5 + .../tests/RegistrationFailure.test.jsx | 5 + .../components/tests/ThirdPartyAuth.test.jsx | 5 + src/register/data/actions.js | 7 + .../data/optimizelyExperiment/helper.js | 30 +++ ...utoGeneratedUsernameExperimentVariation.js | 53 +++++ src/register/data/reducers.js | 8 + src/register/data/tests/reducers.test.js | 1 + 12 files changed, 234 insertions(+), 94 deletions(-) create mode 100644 src/register/data/optimizelyExperiment/helper.js create mode 100644 src/register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation.js diff --git a/src/config/index.js b/src/config/index.js index badb6fe7..78cea882 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -35,6 +35,7 @@ const configuration = { ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL, ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '', ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '', + AUTO_GENERATED_USERNAME_EXPERIMENT_ID: process.env.AUTO_GENERATED_USERNAME_EXPERIMENT_ID || '', }; export default configuration; diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index 87bf3e70..b16ae216 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -15,12 +15,16 @@ import { } from '../data/constants'; import { backupLoginForm } from '../login/data/actions'; import { backupRegistrationForm } from '../register/data/actions'; +import { NOT_INITIALIZED } from '../register/data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from '../register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); +jest.mock('../register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const mockStore = configureStore(); const IntlLogistration = injectIntl(Logistration); @@ -84,6 +88,7 @@ describe('Logistration', () => { })), })); + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); configure({ loggingService: { logError: jest.fn() }, config: { diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 8c5ea79f..22f12e14 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -18,6 +18,7 @@ import { backupRegistrationFormBegin, clearRegistrationBackendError, registerNewUser, + setAutoGeneratedUsernameExperimentData, setEmailSuggestionInStore, setUserPipelineDataLoaded, } from './data/actions'; @@ -25,6 +26,8 @@ import { FORM_SUBMISSION_ERROR, TPA_AUTHENTICATION_FAILURE, } from './data/constants'; +import { AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION, NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import getBackendValidations from './data/selectors'; import { isFormValid, prepareRegistrationPayload, @@ -68,6 +71,7 @@ const RegistrationPage = (props) => { } = props; const backedUpFormData = useSelector(state => state.register.registrationFormData); + const initExpVariation = useSelector(state => state.register.autoGeneratedUsernameExperimentVariation); const registrationError = useSelector(state => state.register.registrationError); const registrationErrorCode = registrationError?.errorCode; const registrationResult = useSelector(state => state.register.registrationResult); @@ -103,6 +107,12 @@ const RegistrationPage = (props) => { ? formatMessage(messages['create.account.cta.button'], { label: cta }) : formatMessage(messages['create.account.for.free.button']); + const autoGeneratedUsernameExpVariation = useAutoGeneratedUsernameExperimentVariation( + initExpVariation, registrationEmbedded, tpaHint, currentProvider, thirdPartyAuthApiStatus, + ); + + const hideUsernameField = flags.autoGeneratedUsernameEnabled + || autoGeneratedUsernameExpVariation === AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION; /** * Set the userPipelineDetails data in formFields for only first time */ @@ -149,8 +159,10 @@ const RegistrationPage = (props) => { formFields: { ...formFields }, errors: { ...errors }, })); + dispatch(setAutoGeneratedUsernameExperimentData(autoGeneratedUsernameExpVariation)); } - }, [shouldBackupState, configurableFormFields, formFields, errors, dispatch, backedUpFormData]); + }, [shouldBackupState, configurableFormFields, // eslint-disable-line react-hooks/exhaustive-deps + formFields, errors, dispatch, backedUpFormData]); useEffect(() => { if (backendValidations) { @@ -216,7 +228,7 @@ const RegistrationPage = (props) => { delete payload.password; payload.social_auth_provider = currentProvider; } - if (flags.autoGeneratedUsernameEnabled) { + if (hideUsernameField) { delete payload.username; } @@ -286,106 +298,109 @@ const RegistrationPage = (props) => { getConfig().ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN && !!Object.keys(optionalFields.fields).length } /> - {autoSubmitRegForm && !errorCode.type ? ( -
- -
- ) : ( -
- - -
- + +
+ ) : ( +
+ - - {!flags.autoGeneratedUsernameEnabled && ( - + - )} - {!currentProvider && ( - - )} - - e.preventDefault()} - /> - {!registrationEmbedded && ( - + )} + {!currentProvider && ( + + )} + - )} - -
- )} - + e.preventDefault()} + /> + {!registrationEmbedded && ( + + )} + + + )} ); }; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 92c74854..9773bf03 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -17,6 +17,9 @@ import { setUserPipelineDataLoaded, } from './data/actions'; import { INTERNAL_SERVER_ERROR } from './data/constants'; +import { NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from './RegistrationPage'; import { AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, @@ -30,6 +33,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('./data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -128,6 +132,7 @@ describe('RegistrationPage', () => { institutionLogin: false, }; window.location = { search: '' }; + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 537d11eb..93978509 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -11,6 +11,9 @@ import configureStore from 'redux-mock-store'; import { registerNewUser } from '../../data/actions'; import { FIELDS } from '../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from '../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; @@ -22,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const IntlConfigurableRegistrationForm = injectIntl(ConfigurableRegistrationForm); const IntlRegistrationPage = injectIntl(RegistrationPage); @@ -121,6 +125,7 @@ describe('ConfigurableRegistrationForm', () => { }; window.location = { search: '' }; getLocale.mockImplementationOnce(() => ('en-us')); + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/RegistrationFailure.test.jsx b/src/register/components/tests/RegistrationFailure.test.jsx index 003cc966..da8867cd 100644 --- a/src/register/components/tests/RegistrationFailure.test.jsx +++ b/src/register/components/tests/RegistrationFailure.test.jsx @@ -12,6 +12,9 @@ import configureStore from 'redux-mock-store'; import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from '../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import RegistrationFailureMessage from '../RegistrationFailure'; @@ -23,6 +26,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage); @@ -121,6 +125,7 @@ describe('RegistrationFailure', () => { institutionLogin: false, }; window.location = { search: '' }; + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/ThirdPartyAuth.test.jsx b/src/register/components/tests/ThirdPartyAuth.test.jsx index 917f10f9..7706d185 100644 --- a/src/register/components/tests/ThirdPartyAuth.test.jsx +++ b/src/register/components/tests/ThirdPartyAuth.test.jsx @@ -12,6 +12,9 @@ import configureStore from 'redux-mock-store'; import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, } from '../../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from '../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -22,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -120,6 +124,7 @@ describe('ThirdPartyAuth', () => { institutionLogin: false, }; window.location = { search: '' }; + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/data/actions.js b/src/register/data/actions.js index 68bfe9e6..2c122515 100644 --- a/src/register/data/actions.js +++ b/src/register/data/actions.js @@ -8,6 +8,7 @@ export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERRO export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE'; export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED'; export const REGISTER_SET_EMAIL_SUGGESTIONS = 'REGISTER_SET_EMAIL_SUGGESTIONS'; +export const REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA = 'REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA'; // Backup registration form export const backupRegistrationForm = () => ({ type: BACKUP_REGISTRATION_DATA.BASE, @@ -82,3 +83,9 @@ export const setUserPipelineDataLoaded = (value) => ({ type: REGISTER_SET_USER_PIPELINE_DATA_LOADED, payload: { value }, }); + +// Auto Generated Username Registration Experiment Actions +export const setAutoGeneratedUsernameExperimentData = (autoGeneratedRegExpVariation) => ({ + type: REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA, + payload: { autoGeneratedRegExpVariation }, +}); diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js new file mode 100644 index 00000000..0cc54183 --- /dev/null +++ b/src/register/data/optimizelyExperiment/helper.js @@ -0,0 +1,30 @@ +/** + * This file contains data for auto generated username Optimizely experiment + */ +import { getConfig } from '@edx/frontend-platform'; + +export const NOT_INITIALIZED = 'experiment-not-initialized'; +export const CONTROL = 'control-registration-page'; +export const AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION = 'auto-generated-username-register-page'; +const AUTO_GENERATED_USERNAME_EXP_PAGE = 'targeting_for_auto_generated_username_page'; + +export function getAutoGeneratedUsernameExperimentVariation() { + try { + if (window.optimizely + && window.optimizely.get('data').experiments[getConfig().AUTO_GENERATED_USERNAME_EXPERIMENT_ID]) { + const selectedVariant = window.optimizely.get('state').getVariationMap()[ + getConfig().AUTO_GENERATED_USERNAME_EXPERIMENT_ID + ]; + return selectedVariant?.name; + } + } catch (e) { /* empty */ } + return ''; +} + +export function activateAutoGeneratedUsernameExperiment() { + window.optimizely = window.optimizely || []; + window.optimizely.push({ + type: 'page', + pageName: AUTO_GENERATED_USERNAME_EXP_PAGE, + }); +} diff --git a/src/register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation.js b/src/register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation.js new file mode 100644 index 00000000..5442a652 --- /dev/null +++ b/src/register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation.js @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; + +import { + activateAutoGeneratedUsernameExperiment, + getAutoGeneratedUsernameExperimentVariation, + NOT_INITIALIZED, +} from './helper'; +import { COMPLETE_STATE } from '../../../data/constants'; + +/** + * This hook returns activates multi step registration experiment and returns the experiment + * variation for the user. + */ +const useAutoGeneratedUsernameExperimentVariation = ( + initExpVariation, + registrationEmbedded, + tpaHint, + currentProvider, + thirdPartyAuthApiStatus, +) => { + const [variation, setVariation] = useState(initExpVariation); + useEffect(() => { + if (initExpVariation || registrationEmbedded || !!tpaHint || !!currentProvider + || thirdPartyAuthApiStatus !== COMPLETE_STATE) { + return variation; + } + + const getVariation = () => { + const expVariation = getAutoGeneratedUsernameExperimentVariation(); + if (expVariation) { + setVariation(expVariation); + } else { + // This is to handle the case when user dont get variation for some reason, the register page + // shows unlimited spinner. + setVariation(NOT_INITIALIZED); + } + }; + + activateAutoGeneratedUsernameExperiment(); + + const timer = setTimeout(getVariation, 300); + + return () => { + clearTimeout(timer); + }; + }, [ // eslint-disable-line react-hooks/exhaustive-deps + initExpVariation, currentProvider, registrationEmbedded, thirdPartyAuthApiStatus, tpaHint, + ]); + + return variation; +}; + +export default useAutoGeneratedUsernameExperimentVariation; diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index 70c3a994..d8a822df 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -3,6 +3,7 @@ import { REGISTER_CLEAR_USERNAME_SUGGESTIONS, REGISTER_FORM_VALIDATIONS, REGISTER_NEW_USER, + REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA, REGISTER_SET_COUNTRY_CODE, REGISTER_SET_EMAIL_SUGGESTIONS, REGISTER_SET_USER_PIPELINE_DATA_LOADED, @@ -39,6 +40,7 @@ export const defaultState = { usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + autoGeneratedUsernameExperimentVariation: '', }; const reducer = (state = defaultState, action = {}) => { @@ -55,6 +57,12 @@ const reducer = (state = defaultState, action = {}) => { registrationFormData: { ...action.payload }, userPipelineDataLoaded: state.userPipelineDataLoaded, }; + case REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA: { + return { + ...state, + autoGeneratedUsernameExperimentVariation: action.payload.autoGeneratedRegExpVariation, + }; + } case REGISTER_NEW_USER.BEGIN: return { ...state, diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index 3e2270ab..39fa4cee 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -38,6 +38,7 @@ describe('Registration Reducer Tests', () => { usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + autoGeneratedUsernameExperimentVariation: '', }; it('should return the initial state', () => { From 5754c2961acc535401d12a3db21b6be5609b8298 Mon Sep 17 00:00:00 2001 From: Blue Date: Tue, 4 Jun 2024 16:27:14 +0500 Subject: [PATCH 17/82] feat: add page event for reset password (#1253) Description: Add page event for reset password page VAN-1929 --- src/forgot-password/ForgotPasswordPage.jsx | 2 +- src/reset-password/ResetPasswordPage.jsx | 5 +++++ src/reset-password/tests/ResetPasswordPage.test.jsx | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/forgot-password/ForgotPasswordPage.jsx b/src/forgot-password/ForgotPasswordPage.jsx index ff381340..f4c7b314 100644 --- a/src/forgot-password/ForgotPasswordPage.jsx +++ b/src/forgot-password/ForgotPasswordPage.jsx @@ -41,7 +41,7 @@ const ForgotPasswordPage = (props) => { const navigate = useNavigate(); useEffect(() => { - sendPageEvent('login_and_registration', 'reset'); + sendPageEvent('login_and_registration', 'forgot-password'); sendTrackEvent('edx.bi.password_reset_form.viewed', { category: 'user-engagement' }); }, []); diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/reset-password/ResetPasswordPage.jsx index 9b4b6758..0393d279 100644 --- a/src/reset-password/ResetPasswordPage.jsx +++ b/src/reset-password/ResetPasswordPage.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; +import { sendPageEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, @@ -42,6 +43,10 @@ const ResetPasswordPage = (props) => { const { token } = useParams(); const navigate = useNavigate(); + useEffect(() => { + sendPageEvent('login_and_registration', 'reset-password'); + }, []); + useEffect(() => { if (props.status !== TOKEN_STATE.PENDING && props.status !== PASSWORD_RESET_ERROR) { setErrorCode(props.status); diff --git a/src/reset-password/tests/ResetPasswordPage.test.jsx b/src/reset-password/tests/ResetPasswordPage.test.jsx index 421edc9e..0c9ba1a2 100644 --- a/src/reset-password/tests/ResetPasswordPage.test.jsx +++ b/src/reset-password/tests/ResetPasswordPage.test.jsx @@ -19,6 +19,10 @@ import ResetPasswordPage from '../ResetPasswordPage'; const mockedNavigator = jest.fn(); const token = '1c-bmjdkc-5e60e084cf8113048ca7'; +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendPageEvent: jest.fn(), +})); + jest.mock('@edx/frontend-platform/auth'); jest.mock('react-router-dom', () => ({ ...(jest.requireActual('react-router-dom')), From bd63bb1f15f4f7ba8a22bb683e10839f64182077 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Fri, 7 Jun 2024 07:57:42 +0500 Subject: [PATCH 18/82] Update 2u-main with master (#1254) * feat: Hide preloaders for third party auth providers if they are disabled * feat: remove username from the registration from (#1201) (#1241) Co-authored-by: Attiya Ishaque * fix: add new entry for another US label (#1244) Add new entry for for another US label which is United States * feat: implement multi step registration experiment Rebase 2u main with master (#1228) * chore(deps): update dependency babel-plugin-formatjs to v10.5.14 * fix(deps): update dependency @edx/frontend-platform to v7.1.3 * fix(deps): update font awesome to v6.5.2 * chore(deps): update dependency @openedx/frontend-build to v13.1.4 * fix(deps): update dependency @openedx/paragon to v22.2.1 * fix(deps): update dependency algoliasearch to v4.23.3 * fix(deps): update dependency algoliasearch-helper to v3.17.0 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * feat: add multi step registration eventing (#1226) * feat: implement multi step registration experiment * feat: add multi step registration eventing * fix: fix register button width * fix: fix register button loader for control * feat: capture marketing lead in experiment events (#1243) * revert: multistep registration experiment revert multistep registration experiment changes VAN-1930 * feat: implement auto generated username experiment (#1248) * feat: implement auto generated username registration exp * feat: add page event for reset password (#1253) Description: Add page event for reset password page VAN-1929 --------- Co-authored-by: Stanislav Lunyachek Co-authored-by: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Co-authored-by: Attiya Ishaque Co-authored-by: Blue Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Syed Sajjad Hussain Shah From efaa83a1bcaeeba300b1228af520aab6f3f844a5 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Tue, 11 Jun 2024 12:01:30 +0500 Subject: [PATCH 19/82] feat: hard code fields on frontend (#1256) * feat: hard code fields hard code configurable fields on frontend which includes country field on register page & level of education & gender field on progressive profiling VAN-1971 * fix: fix secondary provider null name issue --- .../InstitutionLogistration.jsx | 2 +- src/common-components/data/constants.js | 79 +++++++++++++++++++ src/common-components/data/sagas.js | 13 ++- src/config/index.js | 1 + 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/common-components/data/constants.js diff --git a/src/common-components/InstitutionLogistration.jsx b/src/common-components/InstitutionLogistration.jsx index b773bc6e..ba26cf3c 100644 --- a/src/common-components/InstitutionLogistration.jsx +++ b/src/common-components/InstitutionLogistration.jsx @@ -60,7 +60,7 @@ const InstitutionLogistration = props => { className="btn nav-item p-0 mb-1 institutions--provider-link" destination={lmsBaseUrl + provider.loginUrl} > - {provider.name} + {provider?.name} diff --git a/src/common-components/data/constants.js b/src/common-components/data/constants.js new file mode 100644 index 00000000..4f9ec72c --- /dev/null +++ b/src/common-components/data/constants.js @@ -0,0 +1,79 @@ +export const registerFields = { + fields: { + country: { + name: 'country', + error_message: 'Select your country or region of residence', + }, + honor_code: { + name: 'honor_code', + type: 'tos_and_honor_code', + error_message: '', + }, + }, +}; + +export const progressiveProfilingFields = { + extended_profile: [], + fields: { + level_of_education: { + name: 'level_of_education', + type: 'select', + label: 'Highest level of education completed', + error_message: '', + options: [ + [ + 'p', + 'Doctorate', + ], + [ + 'm', + "Master's or professional degree", + ], + [ + 'b', + "Bachelor's degree", + ], + [ + 'a', + 'Associate degree', + ], + [ + 'hs', + 'Secondary/high school', + ], + [ + 'jhs', + 'Junior secondary/junior high/middle school', + ], + [ + 'none', + 'No formal education', + ], + [ + 'other', + 'Other education', + ], + ], + }, + gender: { + name: 'gender', + type: 'select', + label: 'Gender', + error_message: '', + options: [ + [ + 'm', + 'Male', + ], + [ + 'f', + 'Female', + ], + [ + 'o', + 'Other/Prefer Not to Say', + ], + ], + }, + }, +}; diff --git a/src/common-components/data/sagas.js b/src/common-components/data/sagas.js index ffe0be37..65105866 100644 --- a/src/common-components/data/sagas.js +++ b/src/common-components/data/sagas.js @@ -1,3 +1,4 @@ +import { getConfig } from '@edx/frontend-platform'; import { logError } from '@edx/frontend-platform/logging'; import { call, put, takeEvery } from 'redux-saga/effects'; @@ -7,6 +8,7 @@ import { getThirdPartyAuthContextSuccess, THIRD_PARTY_AUTH_CONTEXT, } from './actions'; +import { progressiveProfilingFields, registerFields } from './constants'; import { getThirdPartyAuthContext, } from './service'; @@ -20,7 +22,16 @@ export function* fetchThirdPartyAuthContext(action) { } = yield call(getThirdPartyAuthContext, action.payload.urlParams); yield put(setCountryFromThirdPartyAuthContext(thirdPartyAuthContext.countryCode)); - yield put(getThirdPartyAuthContextSuccess(fieldDescriptions, optionalFields, thirdPartyAuthContext)); + // hard code country field, level of education and gender fields + if (getConfig().ENABLE_HARD_CODE_OPTIONAL_FIELDS) { + yield put(getThirdPartyAuthContextSuccess( + registerFields, + progressiveProfilingFields, + thirdPartyAuthContext, + )); + } else { + yield put(getThirdPartyAuthContextSuccess(fieldDescriptions, optionalFields, thirdPartyAuthContext)); + } } catch (e) { yield put(getThirdPartyAuthContextFailure()); logError(e); diff --git a/src/config/index.js b/src/config/index.js index 78cea882..6399b549 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -11,6 +11,7 @@ const configuration = { MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '', SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false, SHOW_REGISTRATION_LINKS: process.env.SHOW_REGISTRATION_LINKS !== 'false', + ENABLE_HARD_CODE_OPTIONAL_FIELDS: process.env.ENABLE_HARD_CODE_OPTIONAL_FIELDS || false, ENABLE_IMAGE_LAYOUT: process.env.ENABLE_IMAGE_LAYOUT || false, // Links ACTIVATION_EMAIL_SUPPORT_LINK: process.env.ACTIVATION_EMAIL_SUPPORT_LINK || null, From 0d603b5fa102e7cdd1a0807836c5f19a64d289f9 Mon Sep 17 00:00:00 2001 From: Muhammad Abdullah Waheed <42172960+abdullahwaheed@users.noreply.github.com> Date: Wed, 3 Jul 2024 17:08:44 +0500 Subject: [PATCH 20/82] feat: added app name identifier in segment events (#1277) * feat: added app name identifier in registration call * feat: added utils for tracking events * refactor: mapped login events * refactor: mapped forgot password events * refactor: mapped reset password events * refactor: mapped register events * fix: fixed unit tests * refactor: mapped progressive prifiling events * fix: fixed unit tests * refactor: added app name in logistration events * refactor: resolved PR reviews and fixed tests --- src/data/constants.js | 1 + src/data/segment/utils.js | 37 ++++++++++++++++ src/forgot-password/ForgotPasswordPage.jsx | 9 ++-- src/login/LoginPage.jsx | 15 ++++--- src/login/tests/LoginPage.test.jsx | 8 ++-- src/logistration/Logistration.jsx | 10 ++--- src/logistration/Logistration.test.jsx | 5 ++- .../ProgressiveProfiling.jsx | 44 +++++++++---------- .../tests/ProgressiveProfiling.test.jsx | 10 +++-- src/register/RegistrationPage.jsx | 10 ++--- src/register/RegistrationPage.test.jsx | 11 +++-- .../ConfigurableRegistrationForm.test.jsx | 3 +- src/reset-password/ResetPasswordPage.jsx | 15 ++++--- .../tests/ResetPasswordPage.test.jsx | 1 + src/tracking/trackers/forgotpassword.js | 22 ++++++++++ src/tracking/trackers/login.js | 29 ++++++++++++ .../trackers/progressive-profiling.js | 37 ++++++++++++++++ src/tracking/trackers/register.js | 22 ++++++++++ src/tracking/trackers/reset-password.js | 14 ++++++ .../trackers/tests/forgot-password.test.jsx | 37 ++++++++++++++++ src/tracking/trackers/tests/login.test.jsx | 37 ++++++++++++++++ .../tests/progressive-profiling.test.jsx | 37 ++++++++++++++++ src/tracking/trackers/tests/register.test.jsx | 36 +++++++++++++++ .../trackers/tests/reset-password.test.jsx | 26 +++++++++++ 24 files changed, 419 insertions(+), 57 deletions(-) create mode 100644 src/data/segment/utils.js create mode 100644 src/tracking/trackers/forgotpassword.js create mode 100644 src/tracking/trackers/login.js create mode 100644 src/tracking/trackers/progressive-profiling.js create mode 100644 src/tracking/trackers/register.js create mode 100644 src/tracking/trackers/reset-password.js create mode 100644 src/tracking/trackers/tests/forgot-password.test.jsx create mode 100644 src/tracking/trackers/tests/login.test.jsx create mode 100644 src/tracking/trackers/tests/progressive-profiling.test.jsx create mode 100644 src/tracking/trackers/tests/register.test.jsx create mode 100644 src/tracking/trackers/tests/reset-password.test.jsx diff --git a/src/data/constants.js b/src/data/constants.js index 90fdf75f..5adf4382 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -37,3 +37,4 @@ export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+ // things like auto-enrollment upon login and registration. export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow', 'next', 'register_for_free', 'track', 'is_account_recovery', 'variant', 'host', 'cta']; export const REDIRECT = 'redirect'; +export const APP_NAME = 'authn_mfe'; diff --git a/src/data/segment/utils.js b/src/data/segment/utils.js new file mode 100644 index 00000000..fa84443d --- /dev/null +++ b/src/data/segment/utils.js @@ -0,0 +1,37 @@ +/* eslint-disable import/prefer-default-export */ +import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; + +import { APP_NAME } from '../constants'; + +export const LINK_TIMEOUT = 300; + +/** + * Creates an event tracker function that sends a tracking event with the given name and options. + * + * @param {string} name - The name of the event to be tracked. + * @param {object} [options={}] - Additional options to be included with the event. + * @returns {function} - A function that, when called, sends the tracking event. + */ +export const createEventTracker = (name, options = {}) => () => sendTrackEvent( + name, + { ...options, app_name: APP_NAME }, +); + +/** + * Creates an event tracker function that sends a tracking event with the given name and options. + * + * @param {string} name - The name of the event to be tracked. + * @param {object} [options={}] - Additional options to be included with the event. + * @returns {function} - A function that, when called, sends the tracking event. + */ +export const createPageEventTracker = (name, options = null) => () => sendPageEvent( + name, + options, + { app_name: APP_NAME }, +); + +export const createLinkTracker = (tracker, href) => (e) => { + e.preventDefault(); + tracker(); + return setTimeout(() => { window.location.href = href; }, LINK_TIMEOUT); +}; diff --git a/src/forgot-password/ForgotPasswordPage.jsx b/src/forgot-password/ForgotPasswordPage.jsx index f4c7b314..fad31300 100644 --- a/src/forgot-password/ForgotPasswordPage.jsx +++ b/src/forgot-password/ForgotPasswordPage.jsx @@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; -import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, @@ -25,6 +24,10 @@ import BaseContainer from '../base-container'; import { FormGroup } from '../common-components'; import { DEFAULT_STATE, LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants'; import { updatePathWithQueryParams, windowScrollTo } from '../data/utils'; +import { + trackForgotPasswordPageEvent, + trackForgotPasswordPageViewed, +} from '../tracking/trackers/forgotpassword'; const ForgotPasswordPage = (props) => { const platformName = getConfig().SITE_NAME; @@ -41,8 +44,8 @@ const ForgotPasswordPage = (props) => { const navigate = useNavigate(); useEffect(() => { - sendPageEvent('login_and_registration', 'forgot-password'); - sendTrackEvent('edx.bi.password_reset_form.viewed', { category: 'user-engagement' }); + trackForgotPasswordPageEvent(); + trackForgotPasswordPageViewed(); }, []); useEffect(() => { diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 62815726..0ff25258 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; -import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; import { injectIntl, useIntl } from '@edx/frontend-platform/i18n'; import { Form, StatefulButton, @@ -43,6 +42,9 @@ import { updatePathWithQueryParams, } from '../data/utils'; import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess'; +import { + trackForgotPasswordLinkClick, trackLoginPageViewed, trackLoginSuccess, +} from '../tracking/trackers/login'; const LoginPage = (props) => { const { @@ -78,9 +80,15 @@ const LoginPage = (props) => { const tpaHint = getTpaHint(); useEffect(() => { - sendPageEvent('login_and_registration', 'login'); + trackLoginPageViewed(); }, []); + useEffect(() => { + if (loginResult.success) { + trackLoginSuccess(); + } + }, [loginResult]); + useEffect(() => { const payload = { ...queryParams }; if (tpaHint) { @@ -170,9 +178,6 @@ const LoginPage = (props) => { const { name } = event.target; setErrors(prevErrors => ({ ...prevErrors, [name]: '' })); }; - const trackForgotPasswordLinkClick = () => { - sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement' }); - }; const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders); diff --git a/src/login/tests/LoginPage.test.jsx b/src/login/tests/LoginPage.test.jsx index 9c337bf2..38778b8c 100644 --- a/src/login/tests/LoginPage.test.jsx +++ b/src/login/tests/LoginPage.test.jsx @@ -11,7 +11,9 @@ import { act } from 'react-dom/test-utils'; import { MemoryRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; -import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE } from '../../data/constants'; +import { + APP_NAME, COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, +} from '../../data/constants'; import { backupLoginFormBegin, dismissPasswordResetBanner, loginRequest } from '../data/actions'; import { INTERNAL_SERVER_ERROR } from '../data/constants'; import LoginPage from '../LoginPage'; @@ -751,7 +753,7 @@ describe('LoginPage', () => { it('should send page event when login page is rendered', () => { render(reduxWrapper()); - expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'login'); + expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'login', { app_name: APP_NAME }); }); it('tests that form is in invalid state when it is submitted', () => { @@ -784,7 +786,7 @@ describe('LoginPage', () => { { selector: '#forgot-password' }, )); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.password-reset_form.toggled', { category: 'user-engagement' }); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.password-reset_form.toggled', { category: 'user-engagement', app_name: APP_NAME }); }); it('should backup the login form state when shouldBackupState is true', () => { diff --git a/src/logistration/Logistration.jsx b/src/logistration/Logistration.jsx index 9451aa27..1a65b259 100644 --- a/src/logistration/Logistration.jsx +++ b/src/logistration/Logistration.jsx @@ -20,7 +20,7 @@ import { tpaProvidersSelector, } from '../common-components/data/selectors'; import messages from '../common-components/messages'; -import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; +import { APP_NAME, LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; import { getTpaHint, getTpaProvider, updatePathWithQueryParams, } from '../data/utils'; @@ -56,11 +56,11 @@ const Logistration = (props) => { }, [navigate, disablePublicAccountCreation]); const handleInstitutionLogin = (e) => { - sendTrackEvent('edx.bi.institution_login_form.toggled', { category: 'user-engagement' }); + sendTrackEvent('edx.bi.institution_login_form.toggled', { category: 'user-engagement', app_name: APP_NAME }); if (typeof e === 'string') { - sendPageEvent('login_and_registration', e === '/login' ? 'login' : 'register'); + sendPageEvent('login_and_registration', e === '/login' ? 'login' : 'register', { app_name: APP_NAME }); } else { - sendPageEvent('login_and_registration', e.target.dataset.eventName); + sendPageEvent('login_and_registration', e.target.dataset.eventName, { app_name: APP_NAME }); } setInstitutionLogin(!institutionLogin); @@ -70,7 +70,7 @@ const Logistration = (props) => { if (tabKey === currentTab) { return; } - sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement' }); + sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement', app_name: APP_NAME }); props.clearThirdPartyAuthContextErrorMessage(); if (tabKey === LOGIN_PAGE) { props.backupRegistrationForm(); diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index b16ae216..b4b0d1e9 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -11,6 +11,7 @@ import configureStore from 'redux-mock-store'; import Logistration from './Logistration'; import { clearThirdPartyAuthContextErrorMessage } from '../common-components/data/actions'; import { + APP_NAME, COMPLETE_STATE, LOGIN_PAGE, REGISTER_PAGE, } from '../data/constants'; import { backupLoginForm } from '../login/data/actions'; @@ -229,8 +230,8 @@ describe('Logistration', () => { render(reduxWrapper()); fireEvent.click(screen.getByText('Institution/campus credentials')); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement' }); - expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login'); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement', app_name: APP_NAME }); + expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login', { app_name: APP_NAME }); mergeConfig({ DISABLE_ENTERPRISE_LOGIN: '', diff --git a/src/progressive-profiling/ProgressiveProfiling.jsx b/src/progressive-profiling/ProgressiveProfiling.jsx index d42e3d52..55b96462 100644 --- a/src/progressive-profiling/ProgressiveProfiling.jsx +++ b/src/progressive-profiling/ProgressiveProfiling.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig, snakeCaseObject } from '@edx/frontend-platform'; -import { identifyAuthenticatedUser, sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { identifyAuthenticatedUser } from '@edx/frontend-platform/analytics'; import { AxiosJwtAuthService, configure as configureAuth, @@ -39,6 +39,13 @@ import { import isOneTrustFunctionalCookieEnabled from '../data/oneTrust'; import { getAllPossibleQueryParams, isHostAvailableInQueryParams } from '../data/utils'; import { FormFieldRenderer } from '../field-renderer'; +import { + trackDisablePostRegistrationRecommendations, + trackProgressiveProfilingPageViewed, + trackProgressiveProfilingSkipLinkClick, + trackProgressiveProfilingSubmitClick, + trackProgressiveProfilingSupportLinkCLick, +} from '../tracking/trackers/progressive-profiling'; const ProgressiveProfiling = (props) => { const { formatMessage } = useIntl(); @@ -98,14 +105,13 @@ const ProgressiveProfiling = (props) => { useEffect(() => { if (authenticatedUser?.userId) { identifyAuthenticatedUser(authenticatedUser.userId); - sendPageEvent('login_and_registration', 'welcome'); + trackProgressiveProfilingPageViewed(); } }, [authenticatedUser]); useEffect(() => { if (!enablePostRegistrationRecommendations) { - sendTrackEvent( - 'edx.bi.user.recommendations.not.enabled', + trackDisablePostRegistrationRecommendations( { functionalCookiesConsent, page: 'authn_recommendations' }, ); return; @@ -149,29 +155,23 @@ const ProgressiveProfiling = (props) => { }); } props.saveUserProfile(authenticatedUser.username, snakeCaseObject(payload)); - - sendTrackEvent( - 'edx.bi.welcome.page.submit.clicked', - { - isGenderSelected: !!values.gender, - isYearOfBirthSelected: !!values.year_of_birth, - isLevelOfEducationSelected: !!values.level_of_education, - isWorkExperienceSelected: !!values.work_experience, - host: queryParams?.host || '', - }, - ); + const eventProperties = { + isGenderSelected: !!values.gender, + isYearOfBirthSelected: !!values.year_of_birth, + isLevelOfEducationSelected: !!values.level_of_education, + isWorkExperienceSelected: !!values.work_experience, + host: queryParams?.host || '', + }; + trackProgressiveProfilingSubmitClick(eventProperties); }; const handleSkip = (e) => { e.preventDefault(); window.history.replaceState(location.state, null, ''); setShowModal(true); - sendTrackEvent( - 'edx.bi.welcome.page.skip.link.clicked', - { - host: queryParams?.host || '', - }, - ); + trackProgressiveProfilingSkipLinkClick({ + host: queryParams?.host || '', + }); }; const onChangeHandler = (e) => { @@ -242,7 +242,7 @@ const ProgressiveProfiling = (props) => { destination={getConfig().AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK} target="_blank" showLaunchIcon={false} - onClick={() => (sendTrackEvent('edx.bi.welcome.page.support.link.clicked'))} + onClick={() => (trackProgressiveProfilingSupportLinkCLick())} > {formatMessage(messages['optional.fields.information.link'])} diff --git a/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx b/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx index 9bc4439c..c5786b2e 100644 --- a/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx +++ b/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx @@ -12,6 +12,7 @@ import { MemoryRouter, mockNavigate, useLocation } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import { + APP_NAME, AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, DEFAULT_REDIRECT_URL, EMBEDDED, @@ -143,8 +144,9 @@ describe('ProgressiveProfilingTests', () => { const modalContentContainer = document.getElementsByClassName('.pgn__modal-content-container'); expect(modalContentContainer).toBeTruthy(); + const payload = { host: '', app_name: APP_NAME }; - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host: '' }); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', payload); }); // ******** test event functionality ******** @@ -165,7 +167,7 @@ describe('ProgressiveProfilingTests', () => { const supportLink = screen.getByRole('link', { name: /learn more about how we use this information/i }); fireEvent.click(supportLink); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.support.link.clicked'); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.support.link.clicked', { app_name: APP_NAME }); }); it('should set empty host property value for non-embedded experience', () => { @@ -175,6 +177,7 @@ describe('ProgressiveProfilingTests', () => { isLevelOfEducationSelected: false, isWorkExperienceSelected: false, host: '', + app_name: APP_NAME, }; delete window.location; window.location = { href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING) }; @@ -316,7 +319,7 @@ describe('ProgressiveProfilingTests', () => { const skipLinkButton = screen.getByText('Skip for now'); fireEvent.click(skipLinkButton); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host }); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host, app_name: APP_NAME }); }); it('should show spinner while fetching the optional fields', () => { @@ -349,6 +352,7 @@ describe('ProgressiveProfilingTests', () => { isLevelOfEducationSelected: false, isWorkExperienceSelected: false, host: 'http://example.com', + app_name: APP_NAME, }; delete window.location; window.location = { diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 22f12e14..0a0a5b53 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -4,7 +4,6 @@ import React, { import { useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; -import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, Spinner, StatefulButton } from '@openedx/paragon'; import classNames from 'classnames'; @@ -44,11 +43,12 @@ import { getThirdPartyAuthContext as getRegistrationDataFromBackend } from '../c import EnterpriseSSO from '../common-components/EnterpriseSSO'; import ThirdPartyAuth from '../common-components/ThirdPartyAuth'; import { - COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, + APP_NAME, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, } from '../data/constants'; import { getAllPossibleQueryParams, getTpaHint, getTpaProvider, isHostAvailableInQueryParams, setCookie, } from '../data/utils'; +import { trackRegistrationPageViewed, trackRegistrationSuccess } from '../tracking/trackers/register'; /** * Main Registration Page component @@ -138,7 +138,7 @@ const RegistrationPage = (props) => { useEffect(() => { if (!formStartTime) { - sendPageEvent('login_and_registration', 'register'); + trackRegistrationPageViewed(); const payload = { ...queryParams, is_register_page: true }; if (tpaHint) { payload.tpa_hint = tpaHint; @@ -183,7 +183,7 @@ const RegistrationPage = (props) => { useEffect(() => { if (registrationResult.success) { // This event is used by GTM - sendTrackEvent('edx.bi.user.account.registered.client', {}); + trackRegistrationSuccess(); // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); @@ -222,7 +222,7 @@ const RegistrationPage = (props) => { const registerUser = () => { const totalRegistrationTime = (Date.now() - formStartTime) / 1000; - let payload = { ...formFields }; + let payload = { ...formFields, app_name: APP_NAME }; if (currentProvider) { delete payload.password; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 9773bf03..5633c753 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -22,7 +22,7 @@ import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from './RegistrationPage'; import { - AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, + APP_NAME, AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, } from '../data/constants'; jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -190,6 +190,7 @@ describe('RegistrationPage', () => { honor_code: true, totalRegistrationTime: 0, next: '/course/demo-course-url', + app_name: APP_NAME, }; store.dispatch = jest.fn(store.dispatch); @@ -212,6 +213,7 @@ describe('RegistrationPage', () => { honor_code: true, social_auth_provider: 'Apple', totalRegistrationTime: 0, + app_name: APP_NAME, }; store = mockStore({ @@ -297,6 +299,7 @@ describe('RegistrationPage', () => { honor_code: true, totalRegistrationTime: 0, marketing_emails_opt_in: true, + app_name: APP_NAME, }; store.dispatch = jest.fn(store.dispatch); @@ -323,6 +326,7 @@ describe('RegistrationPage', () => { country: 'Pakistan', honor_code: true, totalRegistrationTime: 0, + app_name: APP_NAME, }; store.dispatch = jest.fn(store.dispatch); @@ -597,7 +601,7 @@ describe('RegistrationPage', () => { it('should send page event when register page is rendered', () => { render(routerWrapper(reduxWrapper())); - expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register'); + expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register', { app_name: APP_NAME }); }); it('should send track event when user has successfully registered', () => { @@ -615,7 +619,7 @@ describe('RegistrationPage', () => { delete window.location; window.location = { href: getConfig().BASE_URL }; render(routerWrapper(reduxWrapper())); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', {}); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', { app_name: APP_NAME }); }); it('should populate form with pipeline user details', () => { @@ -888,6 +892,7 @@ describe('RegistrationPage', () => { country: 'PK', social_auth_provider: 'Apple', totalRegistrationTime: 0, + app_name: APP_NAME, })); }); }); diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 93978509..670415f7 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -9,6 +9,7 @@ import { fireEvent, render } from '@testing-library/react'; import { BrowserRouter as Router } from 'react-router-dom'; import configureStore from 'redux-mock-store'; +import { APP_NAME } from '../../../data/constants'; import { registerNewUser } from '../../data/actions'; import { FIELDS } from '../../data/constants'; import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; @@ -265,7 +266,7 @@ describe('ConfigurableRegistrationForm', () => { fireEvent.click(submitButton); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK', app_name: APP_NAME })); }); it('should show error messages for required fields on empty form submission', () => { diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/reset-password/ResetPasswordPage.jsx index 0393d279..ceb37e8c 100644 --- a/src/reset-password/ResetPasswordPage.jsx +++ b/src/reset-password/ResetPasswordPage.jsx @@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; -import { sendPageEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, @@ -19,7 +18,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { resetPassword, validateToken } from './data/actions'; import { - FORM_SUBMISSION_ERROR, PASSWORD_RESET_ERROR, PASSWORD_VALIDATION_ERROR, TOKEN_STATE, + FORM_SUBMISSION_ERROR, PASSWORD_RESET_ERROR, PASSWORD_VALIDATION_ERROR, SUCCESS, TOKEN_STATE, } from './data/constants'; import { resetPasswordResultSelector } from './data/selectors'; import { validatePassword } from './data/service'; @@ -31,6 +30,7 @@ import { LETTER_REGEX, LOGIN_PAGE, NUMBER_REGEX, RESET_PAGE, } from '../data/constants'; import { getAllPossibleQueryParams, updatePathWithQueryParams, windowScrollTo } from '../data/utils'; +import { trackPasswordResetSuccess, trackResetPasswordPageViewed } from '../tracking/trackers/reset-password'; const ResetPasswordPage = (props) => { const { formatMessage } = useIntl(); @@ -44,8 +44,13 @@ const ResetPasswordPage = (props) => { const navigate = useNavigate(); useEffect(() => { - sendPageEvent('login_and_registration', 'reset-password'); - }, []); + if (props.status === TOKEN_STATE.VALID) { + trackResetPasswordPageViewed(); + } + if (props.status === SUCCESS) { + trackPasswordResetSuccess(); + } + }, [props.status]); useEffect(() => { if (props.status !== TOKEN_STATE.PENDING && props.status !== PASSWORD_RESET_ERROR) { @@ -144,7 +149,7 @@ const ResetPasswordPage = (props) => { } } else if (props.status === PASSWORD_RESET_ERROR) { navigate(updatePathWithQueryParams(RESET_PAGE)); - } else if (props.status === 'success') { + } else if (props.status === SUCCESS) { navigate(updatePathWithQueryParams(LOGIN_PAGE)); } else { return ( diff --git a/src/reset-password/tests/ResetPasswordPage.test.jsx b/src/reset-password/tests/ResetPasswordPage.test.jsx index 0c9ba1a2..f3619be7 100644 --- a/src/reset-password/tests/ResetPasswordPage.test.jsx +++ b/src/reset-password/tests/ResetPasswordPage.test.jsx @@ -21,6 +21,7 @@ const token = '1c-bmjdkc-5e60e084cf8113048ca7'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), + sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); diff --git a/src/tracking/trackers/forgotpassword.js b/src/tracking/trackers/forgotpassword.js new file mode 100644 index 00000000..3918478d --- /dev/null +++ b/src/tracking/trackers/forgotpassword.js @@ -0,0 +1,22 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + loginAndRegistration: 'login_and_registration', + forgotPasswordPageViewed: 'edx.bi.password_reset_form.viewed', +}; + +export const categories = { + userEngagement: 'user-engagement', +}; + +// Event tracker for forgot password page viewed +export const trackForgotPasswordPageViewed = () => createEventTracker( + eventNames.forgotPasswordPageViewed, + { + category: categories.userEngagement, + }, +)(); + +export const trackForgotPasswordPageEvent = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'forgot-password')(); +}; diff --git a/src/tracking/trackers/login.js b/src/tracking/trackers/login.js new file mode 100644 index 00000000..3c6a4a2a --- /dev/null +++ b/src/tracking/trackers/login.js @@ -0,0 +1,29 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + forgotPasswordLinkClicked: 'edx.bi.password-reset_form.toggled', + loginAndRegistration: 'login_and_registration', + registerFormToggled: 'edx.bi.register_form.toggled', + loginSuccess: 'edx.bi.user.account.authenticated.client', +}; + +export const categories = { + userEngagement: 'user-engagement', +}; + +// Event tracker for Forgot Password link click +export const trackForgotPasswordLinkClick = () => createEventTracker( + eventNames.forgotPasswordLinkClicked, + { category: categories.userEngagement }, +)(); + +// Tracks the login page event. +export const trackLoginPageViewed = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'login')(); +}; + +// Tracks the login sucess event. +export const trackLoginSuccess = () => createEventTracker( + eventNames.loginSuccess, + {}, +)(); diff --git a/src/tracking/trackers/progressive-profiling.js b/src/tracking/trackers/progressive-profiling.js new file mode 100644 index 00000000..11a6f53e --- /dev/null +++ b/src/tracking/trackers/progressive-profiling.js @@ -0,0 +1,37 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + progressiveProfilingSubmitClick: 'edx.bi.welcome.page.submit.clicked', + progressiveProfilingSkipLinkClick: 'edx.bi.welcome.page.skip.link.clicked', + disablePostRegistrationRecommendations: 'edx.bi.user.recommendations.not.enabled', + progressiveProfilingSupportLinkCLick: 'edx.bi.welcome.page.support.link.clicked', + loginAndRegistration: 'login_and_registration', +}; + +// Event link tracker for Progressive profiling skip button click +export const trackProgressiveProfilingSkipLinkClick = evenProperties => createEventTracker( + eventNames.progressiveProfilingSkipLinkClick, { ...evenProperties }, +)(); + +// Event tracker for progressive profiling submit button click +export const trackProgressiveProfilingSubmitClick = (evenProperties) => createEventTracker( + eventNames.progressiveProfilingSubmitClick, + { ...evenProperties }, +)(); + +// Event tracker for progressive profiling submit button click +export const trackDisablePostRegistrationRecommendations = (evenProperties) => createEventTracker( + eventNames.disablePostRegistrationRecommendations, + { ...evenProperties }, +)(); + +// Tracks the progressive profiling page event. +export const trackProgressiveProfilingPageViewed = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'welcome')(); +}; + +// Tracks the progressive profiling spport link click. +export const trackProgressiveProfilingSupportLinkCLick = () => createEventTracker( + eventNames.progressiveProfilingSupportLinkCLick, + {}, +)(); diff --git a/src/tracking/trackers/register.js b/src/tracking/trackers/register.js new file mode 100644 index 00000000..3a860856 --- /dev/null +++ b/src/tracking/trackers/register.js @@ -0,0 +1,22 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + loginAndRegistration: 'login_and_registration', + registrationSuccess: 'edx.bi.user.account.registered.client', + loginFormToggled: 'edx.bi.login_form.toggled', +}; + +export const categories = { + userEngagement: 'user-engagement', +}; + +// Event tracker for successful registration +export const trackRegistrationSuccess = () => createEventTracker( + eventNames.registrationSuccess, + {}, +)(); + +// Tracks the progressive profiling page event. +export const trackRegistrationPageViewed = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'register')(); +}; diff --git a/src/tracking/trackers/reset-password.js b/src/tracking/trackers/reset-password.js new file mode 100644 index 00000000..cb79340f --- /dev/null +++ b/src/tracking/trackers/reset-password.js @@ -0,0 +1,14 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + loginAndRegistration: 'login_and_registration', + resetPasswordSuccess: 'edx.bi.user.password.reset.success', +}; + +export const trackResetPasswordPageViewed = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'reset-password')(); +}; + +export const trackPasswordResetSuccess = () => { + createEventTracker(eventNames.resetPasswordSuccess, {})(); +}; diff --git a/src/tracking/trackers/tests/forgot-password.test.jsx b/src/tracking/trackers/tests/forgot-password.test.jsx new file mode 100644 index 00000000..6a9b87c4 --- /dev/null +++ b/src/tracking/trackers/tests/forgot-password.test.jsx @@ -0,0 +1,37 @@ +import { createEventTracker, createPageEventTracker } from '../../../data/segment/utils'; +import { + categories, + eventNames, + trackForgotPasswordPageEvent, + trackForgotPasswordPageViewed, +} from '../forgotpassword'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fire trackForgotPasswordPageEvent', () => { + trackForgotPasswordPageEvent(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'forgot-password', + ); + }); + + it('should fire forgotPasswordPageViewedEvent', () => { + trackForgotPasswordPageViewed(); + + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.forgotPasswordPageViewed, + { category: categories.userEngagement }, + ); + }); +}); diff --git a/src/tracking/trackers/tests/login.test.jsx b/src/tracking/trackers/tests/login.test.jsx new file mode 100644 index 00000000..fac7d082 --- /dev/null +++ b/src/tracking/trackers/tests/login.test.jsx @@ -0,0 +1,37 @@ +import { createEventTracker, createPageEventTracker } from '../../../data/segment/utils'; +import { + categories, + eventNames, + trackForgotPasswordLinkClick, + trackLoginPageViewed, +} from '../login'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('trackForgotPasswordLinkClick function', () => { + trackForgotPasswordLinkClick(); + + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.forgotPasswordLinkClicked, + { category: categories.userEngagement }, + ); + }); + + it('trackLoginPageEvent function', () => { + trackLoginPageViewed(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'login', + ); + }); +}); diff --git a/src/tracking/trackers/tests/progressive-profiling.test.jsx b/src/tracking/trackers/tests/progressive-profiling.test.jsx new file mode 100644 index 00000000..4cda593d --- /dev/null +++ b/src/tracking/trackers/tests/progressive-profiling.test.jsx @@ -0,0 +1,37 @@ +import { createEventTracker, createPageEventTracker } from '../../../data/segment/utils'; +import { + eventNames, + trackProgressiveProfilingPageViewed, + trackProgressiveProfilingSkipLinkClick, +} from '../progressive-profiling'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createLinkTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fire trackProgressiveProfilingSkipLinkClickEvent', () => { + trackProgressiveProfilingSkipLinkClick(); + + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.progressiveProfilingSkipLinkClick, + {}, + ); + }); + + it('should fire trackProgressiveProfilingPageEvent', () => { + trackProgressiveProfilingPageViewed(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'welcome', + ); + }); +}); diff --git a/src/tracking/trackers/tests/register.test.jsx b/src/tracking/trackers/tests/register.test.jsx new file mode 100644 index 00000000..d058908f --- /dev/null +++ b/src/tracking/trackers/tests/register.test.jsx @@ -0,0 +1,36 @@ +import { createEventTracker, createPageEventTracker } from '../../../data/segment/utils'; +import { + eventNames, + trackRegistrationPageViewed, + trackRegistrationSuccess, +} from '../register'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fire registrationSuccessEvent', () => { + trackRegistrationSuccess(); + + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.registrationSuccess, + {}, + ); + }); + + it('should fire trackRegistrationPageEvent', () => { + trackRegistrationPageViewed(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'register', + ); + }); +}); diff --git a/src/tracking/trackers/tests/reset-password.test.jsx b/src/tracking/trackers/tests/reset-password.test.jsx new file mode 100644 index 00000000..15d6ed79 --- /dev/null +++ b/src/tracking/trackers/tests/reset-password.test.jsx @@ -0,0 +1,26 @@ +import { createPageEventTracker } from '../../../data/segment/utils'; +import { + eventNames, + trackResetPasswordPageViewed, +} from '../reset-password'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fire trackResettPasswordPageEvent', () => { + trackResetPasswordPageViewed(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'reset-password', + ); + }); +}); From 327210192caebfe5ccc9b7917301fc483885c6c9 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Fri, 12 Jul 2024 13:18:42 +0500 Subject: [PATCH 21/82] fix: set marketing opt in in cookie for sso (#1285) --- src/common-components/SocialAuthProviders.jsx | 8 +++++++- .../tests/SocialAuthProviders.test.jsx | 7 ++++--- .../components/ConfigurableRegistrationForm.jsx | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/common-components/SocialAuthProviders.jsx b/src/common-components/SocialAuthProviders.jsx index abe06da7..f76e4968 100644 --- a/src/common-components/SocialAuthProviders.jsx +++ b/src/common-components/SocialAuthProviders.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -8,15 +9,20 @@ import { Login } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import messages from './messages'; -import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import { LOGIN_PAGE, REGISTER_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import { setCookie } from '../data/utils'; const SocialAuthProviders = (props) => { const { formatMessage } = useIntl(); const { referrer, socialAuthProviders } = props; + const registrationFields = useSelector(state => state.register.registrationFormData); function handleSubmit(e) { e.preventDefault(); + if (referrer === REGISTER_PAGE) { + setCookie('marketingEmailsOptIn', registrationFields?.configurableFormFields?.marketingEmailsOptIn); + } const url = e.currentTarget.dataset.providerUrl; window.location.href = getConfig().LMS_BASE_URL + url; } diff --git a/src/common-components/tests/SocialAuthProviders.test.jsx b/src/common-components/tests/SocialAuthProviders.test.jsx index 850708ec..ccc96157 100644 --- a/src/common-components/tests/SocialAuthProviders.test.jsx +++ b/src/common-components/tests/SocialAuthProviders.test.jsx @@ -27,7 +27,8 @@ describe('SocialAuthProviders', () => { loginUrl: '/auth/login/facebook/?auth_entry=login&next=/dashboard', }; - it('should match social auth provider with iconImage snapshot', () => { + // Skipped tests will be fixed later. + it.skip('should match social auth provider with iconImage snapshot', () => { props = { socialAuthProviders: [appleProvider, facebookProvider] }; const tree = renderer.create( @@ -39,7 +40,7 @@ describe('SocialAuthProviders', () => { expect(tree).toMatchSnapshot(); }); - it('should match social auth provider with iconClass snapshot', () => { + it.skip('should match social auth provider with iconClass snapshot', () => { props = { socialAuthProviders: [{ ...appleProvider, @@ -57,7 +58,7 @@ describe('SocialAuthProviders', () => { expect(tree).toMatchSnapshot(); }); - it('should match social auth provider with default icon snapshot', () => { + it.skip('should match social auth provider with default icon snapshot', () => { props = { socialAuthProviders: [{ ...appleProvider, diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index 8c300b7e..971bdd4a 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -1,10 +1,12 @@ import React, { useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { getCountryList, getLocale, useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; import { FormFieldRenderer } from '../../field-renderer'; +import { backupRegistrationFormBegin } from '../data/actions'; import { FIELDS } from '../data/constants'; import messages from '../messages'; import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields'; @@ -32,6 +34,7 @@ const ConfigurableRegistrationForm = (props) => { setFormFields, autoSubmitRegistrationForm, } = props; + const dispatch = useDispatch(); /** The reason for adding the entry 'United States' is that Chrome browser aut-fill the form with the 'Unites States' instead of 'United States of America' which does not exist in country dropdown list and gets the user @@ -50,6 +53,8 @@ const ConfigurableRegistrationForm = (props) => { showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN, }; + const backedUpFormData = useSelector(state => state.register.registrationFormData); + /** * If auto submitting register form, we will check tos and honor code fields if they exist for feature parity. */ @@ -90,6 +95,16 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors(prevErrors => ({ ...prevErrors, [name]: '' })); } } + // setting marketingEmailsOptIn state for SSO authentication flow for register API call + if (name === 'marketingEmailsOptIn') { + dispatch(backupRegistrationFormBegin({ + ...backedUpFormData, + configurableFormFields: { + ...backedUpFormData.configurableFormFields, + [name]: value, + }, + })); + } setFormFields(prevState => ({ ...prevState, [name]: value })); }; From 6b983e18d3ee4a4c550d0f9ecee81347c3808067 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:05:50 +0500 Subject: [PATCH 22/82] fix: remove cookie (#1286) -remove marketingEmailsOptIn cookie on successful registration - fix tests --- .../RedirectLogistration.jsx | 2 +- .../tests/SocialAuthProviders.test.jsx | 38 ++++++++++++++----- src/data/utils/cookies.js | 8 ++++ src/data/utils/index.js | 2 +- src/register/RegistrationPage.jsx | 5 ++- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/common-components/RedirectLogistration.jsx b/src/common-components/RedirectLogistration.jsx index f6c30b50..24603f6a 100644 --- a/src/common-components/RedirectLogistration.jsx +++ b/src/common-components/RedirectLogistration.jsx @@ -5,7 +5,7 @@ import { Navigate } from 'react-router-dom'; import { AUTHN_PROGRESSIVE_PROFILING, RECOMMENDATIONS, REDIRECT, } from '../data/constants'; -import { setCookie } from '../data/utils'; +import setCookie from '../data/utils/cookies'; const RedirectLogistration = (props) => { const { diff --git a/src/common-components/tests/SocialAuthProviders.test.jsx b/src/common-components/tests/SocialAuthProviders.test.jsx index ccc96157..b1066a19 100644 --- a/src/common-components/tests/SocialAuthProviders.test.jsx +++ b/src/common-components/tests/SocialAuthProviders.test.jsx @@ -1,16 +1,35 @@ import React from 'react'; +import { Provider } from 'react-redux'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import renderer from 'react-test-renderer'; +import configureStore from 'redux-mock-store'; import registerIcons from '../RegisterFaIcons'; import SocialAuthProviders from '../SocialAuthProviders'; registerIcons(); +const mockStore = configureStore(); describe('SocialAuthProviders', () => { let props = {}; + const initialState = { + register: { + registrationFormData: { + configurableFormFields: { + marketingEmailsOptIn: true, + }, + }, + }, + }; + const store = mockStore(initialState); + const reduxWrapper = children => ( + + {children} + + ); + const appleProvider = { id: 'oa2-apple-id', name: 'Apple', @@ -27,20 +46,19 @@ describe('SocialAuthProviders', () => { loginUrl: '/auth/login/facebook/?auth_entry=login&next=/dashboard', }; - // Skipped tests will be fixed later. - it.skip('should match social auth provider with iconImage snapshot', () => { + it('should match social auth provider with iconImage snapshot', () => { props = { socialAuthProviders: [appleProvider, facebookProvider] }; - const tree = renderer.create( + const tree = renderer.create(reduxWrapper( , - ).toJSON(); + )).toJSON(); expect(tree).toMatchSnapshot(); }); - it.skip('should match social auth provider with iconClass snapshot', () => { + it('should match social auth provider with iconClass snapshot', () => { props = { socialAuthProviders: [{ ...appleProvider, @@ -49,16 +67,16 @@ describe('SocialAuthProviders', () => { }], }; - const tree = renderer.create( + const tree = renderer.create(reduxWrapper( , - ).toJSON(); + )).toJSON(); expect(tree).toMatchSnapshot(); }); - it.skip('should match social auth provider with default icon snapshot', () => { + it('should match social auth provider with default icon snapshot', () => { props = { socialAuthProviders: [{ ...appleProvider, @@ -67,11 +85,11 @@ describe('SocialAuthProviders', () => { }], }; - const tree = renderer.create( + const tree = renderer.create(reduxWrapper( , - ).toJSON(); + )).toJSON(); expect(tree).toMatchSnapshot(); }); diff --git a/src/data/utils/cookies.js b/src/data/utils/cookies.js index 1aad2858..cfddf5ec 100644 --- a/src/data/utils/cookies.js +++ b/src/data/utils/cookies.js @@ -11,3 +11,11 @@ export default function setCookie(cookieName, cookieValue, cookieExpiry) { cookies.set(cookieName, cookieValue, options); } } + +export function removeCookie(cookieName) { + if (cookieName) { + const cookies = new Cookies(); + const options = { domain: getConfig().SESSION_COOKIE_DOMAIN, path: '/' }; + cookies.remove(cookieName, options); + } +} diff --git a/src/data/utils/index.js b/src/data/utils/index.js index 05532111..1c9eebbb 100644 --- a/src/data/utils/index.js +++ b/src/data/utils/index.js @@ -8,4 +8,4 @@ export { windowScrollTo, } from './dataUtils'; export { default as AsyncActionType } from './reduxUtils'; -export { default as setCookie } from './cookies'; +export { default as setCookie, removeCookie } from './cookies'; diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 0a0a5b53..50f689bf 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -46,7 +46,7 @@ import { APP_NAME, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, } from '../data/constants'; import { - getAllPossibleQueryParams, getTpaHint, getTpaProvider, isHostAvailableInQueryParams, setCookie, + getAllPossibleQueryParams, getTpaHint, getTpaProvider, isHostAvailableInQueryParams, removeCookie, setCookie, } from '../data/utils'; import { trackRegistrationPageViewed, trackRegistrationSuccess } from '../tracking/trackers/register'; @@ -187,6 +187,9 @@ const RegistrationPage = (props) => { // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); + + // remove marketingEmailsOptIn cookie that was set on SSO registration flow + removeCookie('marketingEmailsOptIn'); } }, [registrationResult]); From 9a30f053c7f92f797e73c6ab41cb44ebc2e4a632 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:57:18 +0500 Subject: [PATCH 23/82] fix: fix marketingEmailsOptIn null value (#1294) Fix marketingEmailsOptIn null value issue for SSO flow on onboarding component VAN-2013 --- src/common-components/ThirdPartyAuthAlert.jsx | 6 +++++- src/login/LoginPage.jsx | 4 ++++ src/register/RegistrationPage.jsx | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/common-components/ThirdPartyAuthAlert.jsx b/src/common-components/ThirdPartyAuthAlert.jsx index fb79a6a6..fa2c6ee6 100644 --- a/src/common-components/ThirdPartyAuthAlert.jsx +++ b/src/common-components/ThirdPartyAuthAlert.jsx @@ -7,6 +7,7 @@ import PropTypes from 'prop-types'; import messages from './messages'; import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; +import setCookie from '../data/utils/cookies'; const ThirdPartyAuthAlert = (props) => { const { formatMessage } = useIntl(); @@ -20,7 +21,10 @@ const ThirdPartyAuthAlert = (props) => { message = formatMessage(messages['register.third.party.auth.account.not.linked'], { currentProvider, platformName }); } - if (!currentProvider) { + if (currentProvider) { + // Setting this cookie to capture marketingEmailsOptIn for SSO flow on the onboarding component + setCookie('ssoPipelineRedirectionDone', true); + } else { return null; } diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 0ff25258..6a935935 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -41,6 +41,7 @@ import { getTpaProvider, updatePathWithQueryParams, } from '../data/utils'; +import { removeCookie } from '../data/utils/cookies'; import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess'; import { trackForgotPasswordLinkClick, trackLoginPageViewed, trackLoginSuccess, @@ -86,6 +87,9 @@ const LoginPage = (props) => { useEffect(() => { if (loginResult.success) { trackLoginSuccess(); + + // Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component + removeCookie('ssoPipelineRedirectionDone'); } }, [loginResult]); diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 50f689bf..8b6dc9fd 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -188,8 +188,10 @@ const RegistrationPage = (props) => { // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); - // remove marketingEmailsOptIn cookie that was set on SSO registration flow + // Remove marketingEmailsOptIn cookie that was set on SSO registration flow removeCookie('marketingEmailsOptIn'); + // Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component + removeCookie('ssoPipelineRedirectionDone'); } }, [registrationResult]); From 4755540be8f345edfd6a22859860c54b820699ab Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Mon, 29 Jul 2024 11:23:48 +0500 Subject: [PATCH 24/82] fix: retain query params in authenticated user redirection (#1288) --- src/common-components/UnAuthOnlyRoute.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/common-components/UnAuthOnlyRoute.jsx b/src/common-components/UnAuthOnlyRoute.jsx index 7be62aae..3e05fe0b 100644 --- a/src/common-components/UnAuthOnlyRoute.jsx +++ b/src/common-components/UnAuthOnlyRoute.jsx @@ -4,9 +4,7 @@ import { getConfig } from '@edx/frontend-platform'; import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth'; import PropTypes from 'prop-types'; -import { - DEFAULT_REDIRECT_URL, -} from '../data/constants'; +import { updatePathWithQueryParams } from '../data/utils'; /** * This wrapper redirects the requester to our default redirect url if they are @@ -25,7 +23,8 @@ const UnAuthOnlyRoute = ({ children }) => { if (isReady) { if (authUser && authUser.username) { - global.location.href = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL); + const updatedPath = updatePathWithQueryParams(window.location.pathname); + global.location.href = getConfig().LMS_BASE_URL.concat(updatedPath); return null; } From 099fe8d717e7d24f5b7420a94d32ee4c9cab3312 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:06:20 +0500 Subject: [PATCH 25/82] fix: fix datadog js errors (#1296) --- src/recommendations/track.js | 4 ++-- src/register/RegistrationFields/NameField/validator.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/recommendations/track.js b/src/recommendations/track.js index 5d826a3e..9a18b974 100644 --- a/src/recommendations/track.js +++ b/src/recommendations/track.js @@ -15,7 +15,7 @@ const generateProductKey = (product) => ( export const getProductMapping = (recommendedProducts) => recommendedProducts.map((product) => ({ product_key: generateProductKey(product), product_line: product.cardType, - product_source: product.productSource.name, + product_source: product?.productSource?.name, })); export const trackRecommendationClick = (product, position, userId) => { @@ -25,7 +25,7 @@ export const trackRecommendationClick = (product, position, userId) => { recommendation_type: product.recommendationType, product_key: generateProductKey(product), product_line: product.cardType, - product_source: product.productSource.name, + product_source: product?.productSource?.name, user_id: userId, }); diff --git a/src/register/RegistrationFields/NameField/validator.js b/src/register/RegistrationFields/NameField/validator.js index aefaedfb..c8e1d09c 100644 --- a/src/register/RegistrationFields/NameField/validator.js +++ b/src/register/RegistrationFields/NameField/validator.js @@ -11,7 +11,7 @@ export const INVALID_NAME_REGEX = /https?:\/\/(?:[-\w.]|(?:%[\da-fA-F]{2}))*/g; const validateName = (value, formatMessage) => { let fieldError = ''; - if (!value.trim()) { + if (!value || (value && !value.trim())) { fieldError = formatMessage(messages['empty.name.field.error']); } else if (URL_REGEX.test(value) || HTML_REGEX.test(value) || INVALID_NAME_REGEX.test(value)) { fieldError = formatMessage(messages['name.validation.message']); From 2428b4c38958ee76572d07aefdfc7d122a0116f9 Mon Sep 17 00:00:00 2001 From: Blue Date: Wed, 28 Aug 2024 14:58:40 +0500 Subject: [PATCH 26/82] fix: covert totalRegistrationTime to snake case (#1302) Description: Convert totalRegistrationTime to snake case VAN-1816 Co-authored-by: Ahtesham Quraish --- src/register/RegistrationPage.test.jsx | 14 +++++++------- .../tests/ConfigurableRegistrationForm.test.jsx | 4 ++-- src/register/data/utils.js | 2 +- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 5633c753..5f23e2c2 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -188,7 +188,7 @@ describe('RegistrationPage', () => { password: 'password1', country: 'Pakistan', honor_code: true, - totalRegistrationTime: 0, + total_registration_time: 0, next: '/course/demo-course-url', app_name: APP_NAME, }; @@ -212,7 +212,7 @@ describe('RegistrationPage', () => { country: 'Pakistan', honor_code: true, social_auth_provider: 'Apple', - totalRegistrationTime: 0, + total_registration_time: 0, app_name: APP_NAME, }; @@ -246,7 +246,7 @@ describe('RegistrationPage', () => { password: 'password1', country: 'Ukraine', honor_code: true, - totalRegistrationTime: 0, + total_registration_time: 0, }; store.dispatch = jest.fn(store.dispatch); @@ -271,7 +271,7 @@ describe('RegistrationPage', () => { password: 'password1', country: 'Ukraine', honor_code: true, - totalRegistrationTime: 0, + total_registration_time: 0, }; store.dispatch = jest.fn(store.dispatch); @@ -297,7 +297,7 @@ describe('RegistrationPage', () => { password: 'password1', country: 'Pakistan', honor_code: true, - totalRegistrationTime: 0, + total_registration_time: 0, marketing_emails_opt_in: true, app_name: APP_NAME, }; @@ -325,7 +325,7 @@ describe('RegistrationPage', () => { password: 'password1', country: 'Pakistan', honor_code: true, - totalRegistrationTime: 0, + total_registration_time: 0, app_name: APP_NAME, }; @@ -891,7 +891,7 @@ describe('RegistrationPage', () => { email: 'john.doe@example.com', country: 'PK', social_auth_provider: 'Apple', - totalRegistrationTime: 0, + total_registration_time: 0, app_name: APP_NAME, })); }); diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 670415f7..61a25848 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -251,7 +251,7 @@ describe('ConfigurableRegistrationForm', () => { country: 'Pakistan', honor_code: true, profession: 'Engineer', - totalRegistrationTime: 0, + total_registration_time: 0, }; store.dispatch = jest.fn(store.dispatch); @@ -362,7 +362,7 @@ describe('ConfigurableRegistrationForm', () => { password: 'password1', country: 'Ukraine', honor_code: true, - totalRegistrationTime: 0, + total_registration_time: 0, }; store = mockStore({ diff --git a/src/register/data/utils.js b/src/register/data/utils.js index ee08f0b6..e7773e2b 100644 --- a/src/register/data/utils.js +++ b/src/register/data/utils.js @@ -126,8 +126,8 @@ export const prepareRegistrationPayload = ( delete payload.marketingEmailsOptIn; } - payload = snakeCaseObject(payload); payload.totalRegistrationTime = totalRegistrationTime; + payload = snakeCaseObject(payload); // add query params to the payload payload = { ...payload, ...queryParams }; From ecf4c3ae53e92436525aba1d0a237a5b327ee27e Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Thu, 29 Aug 2024 09:48:21 +0500 Subject: [PATCH 27/82] fix: password reset redirection (#1300) fix authenticated user redirects to 404 if token is invalide for password reset VAN-2052 --- src/common-components/UnAuthOnlyRoute.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/common-components/UnAuthOnlyRoute.jsx b/src/common-components/UnAuthOnlyRoute.jsx index 3e05fe0b..2e9df557 100644 --- a/src/common-components/UnAuthOnlyRoute.jsx +++ b/src/common-components/UnAuthOnlyRoute.jsx @@ -4,6 +4,7 @@ import { getConfig } from '@edx/frontend-platform'; import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth'; import PropTypes from 'prop-types'; +import { RESET_PAGE } from '../data/constants'; import { updatePathWithQueryParams } from '../data/utils'; /** @@ -24,6 +25,10 @@ const UnAuthOnlyRoute = ({ children }) => { if (isReady) { if (authUser && authUser.username) { const updatedPath = updatePathWithQueryParams(window.location.pathname); + if (updatedPath.startsWith(RESET_PAGE)) { + global.location.href = getConfig().LMS_BASE_URL; + return null; + } global.location.href = getConfig().LMS_BASE_URL.concat(updatedPath); return null; } From c1e63da7787a717873b65baaa4b1c238c5436eae Mon Sep 17 00:00:00 2001 From: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> Date: Tue, 10 Sep 2024 21:11:44 +0500 Subject: [PATCH 28/82] feat: removed Russian Federation from country list (#1315) --- src/register/components/ConfigurableRegistrationForm.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index 971bdd4a..3013be5f 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -40,7 +40,10 @@ const ConfigurableRegistrationForm = (props) => { States' instead of 'United States of America' which does not exist in country dropdown list and gets the user confused and unable to create an account. So we added the United States entry in the dropdown list. */ - const countryList = useMemo(() => getCountryList(getLocale()).concat([{ code: 'US', name: 'United States' }]), []); + + const countryList = useMemo(() => ( + getCountryList(getLocale()).concat([{ code: 'US', name: 'United States' }]).filter(country => country.code !== 'RU') + ), []); let showTermsOfServiceAndHonorCode = false; let showCountryField = false; From e2cdfce832bcfc04192928445620274370f2018e Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Wed, 17 Apr 2024 17:00:28 +0500 Subject: [PATCH 29/82] Rebase 2u main with master (#1228) * chore(deps): update dependency babel-plugin-formatjs to v10.5.14 * fix(deps): update dependency @edx/frontend-platform to v7.1.3 * fix(deps): update font awesome to v6.5.2 * chore(deps): update dependency @openedx/frontend-build to v13.1.4 * fix(deps): update dependency @openedx/paragon to v22.2.1 * fix(deps): update dependency algoliasearch to v4.23.3 * fix(deps): update dependency algoliasearch-helper to v3.17.0 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> From d66afe98f032952abceecf876827ce37f4241792 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Wed, 3 Apr 2024 11:28:39 +0500 Subject: [PATCH 30/82] feat: implement multi step registration experiment --- src/common-components/messages.jsx | 6 + src/config/index.js | 2 + src/logistration/Logistration.jsx | 55 +++++++-- src/logistration/Logistration.test.jsx | 7 ++ src/register/RegistrationPage.jsx | 2 +- src/register/RegistrationPage.test.jsx | 5 + .../ConfigurableRegistrationForm.jsx | 24 +++- .../components/RegistrationFailure.jsx | 11 +- .../ConfigurableRegistrationForm.test.jsx | 8 ++ .../tests/RegistrationFailure.test.jsx | 5 + .../components/tests/ThirdPartyAuth.test.jsx | 5 + src/register/data/actions.js | 20 +++- .../data/optimizelyExperiment/helper.js | 105 ++++++++++++++++++ ...ltiStepRegistrationExperimentVariation.jsx | 54 +++++++++ src/register/data/reducers.js | 25 +++++ src/register/data/sagas.js | 7 +- src/register/data/tests/reducers.test.js | 5 + src/register/messages.jsx | 23 ++++ src/sass/_registration.scss | 4 - 19 files changed, 346 insertions(+), 27 deletions(-) create mode 100644 src/register/data/optimizelyExperiment/helper.js create mode 100644 src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx diff --git a/src/common-components/messages.jsx b/src/common-components/messages.jsx index 08e88b8f..6f0e69ca 100644 --- a/src/common-components/messages.jsx +++ b/src/common-components/messages.jsx @@ -132,6 +132,12 @@ const messages = defineMessages({ defaultMessage: 'Company or school credentials', description: 'Company or school login link text.', }, + // multi step registration experiment messages + 'tab.back.btn.text': { + id: 'tab.back.btn.text', + defaultMessage: 'Back', + description: 'Tab back button text', + }, }); export default messages; diff --git a/src/config/index.js b/src/config/index.js index badb6fe7..781db13c 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -35,6 +35,8 @@ const configuration = { ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL, ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '', ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '', + // Multi Step Registration Experiment + MULTI_STEP_REGISTRATION_EXPERIMENT_ID: process.env.MULTI_STEP_REGISTRATION_EXPERIMENT_ID || '', }; export default configuration; diff --git a/src/logistration/Logistration.jsx b/src/logistration/Logistration.jsx index 9451aa27..5199e1b3 100644 --- a/src/logistration/Logistration.jsx +++ b/src/logistration/Logistration.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -7,10 +7,11 @@ import { getAuthService } from '@edx/frontend-platform/auth'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, + IconButton, Tab, Tabs, } from '@openedx/paragon'; -import { ChevronLeft } from '@openedx/paragon/icons'; +import { ArrowBackIos, ChevronLeft } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import { Navigate, useNavigate } from 'react-router-dom'; @@ -27,7 +28,11 @@ import { import { LoginPage } from '../login'; import { backupLoginForm } from '../login/data/actions'; import { RegistrationPage } from '../register'; -import { backupRegistrationForm } from '../register/data/actions'; +import { backupRegistrationForm, setMultiStepRegistrationExpData } from '../register/data/actions'; +import { + FIRST_STEP, + getMultiStepRegistrationPreviousStep, +} from '../register/data/optimizelyExperiment/helper'; const Logistration = (props) => { const { selectedPage, tpaProviders } = props; @@ -42,6 +47,10 @@ const Logistration = (props) => { const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false; const hideRegistrationLink = getConfig().SHOW_REGISTRATION_LINKS === false; + const dispatch = useDispatch(); + const multiStepRegExpVariation = useSelector(state => state.register.multiStepRegExpVariation); + const multiStepRegistrationPageStep = useSelector(state => state.register.multiStepRegistrationPageStep); + useEffect(() => { const authService = getAuthService(); if (authService) { @@ -91,6 +100,39 @@ const Logistration = (props) => { ); + /** + * Temporary function created to resolve the complexity in tabs conditioning for multi-step + * registration experiment + */ + const getTabs = () => { + if (multiStepRegistrationPageStep !== FIRST_STEP) { + const prevStep = getMultiStepRegistrationPreviousStep(multiStepRegistrationPageStep); + return ( +
+ { + dispatch(setMultiStepRegistrationExpData(multiStepRegExpVariation, prevStep)); + }} + variant="primary" + size="inline" + className="mr-1" + /> + {formatMessage(messages['tab.back.btn.text'])} +
+ ); + } + return ( + handleOnSelect(tabKey, selectedPage)}> + + + + ); + }; + const isValidTpaHint = () => { const { provider } = getTpaProvider(tpaHint, providers, secondaryProviders); return !!provider; @@ -123,12 +165,7 @@ const Logistration = (props) => { ) - : (!isValidTpaHint() && !hideRegistrationLink && ( - handleOnSelect(tabKey, selectedPage)}> - - - - ))} + : (!isValidTpaHint() && !hideRegistrationLink && getTabs())} { key && ( )} diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index 87bf3e70..ce7af88f 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -15,12 +15,16 @@ import { } from '../data/constants'; import { backupLoginForm } from '../login/data/actions'; import { backupRegistrationForm } from '../register/data/actions'; +import { FIRST_STEP, NOT_INITIALIZED } from '../register/data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); +jest.mock('../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const mockStore = configureStore(); const IntlLogistration = injectIntl(Logistration); @@ -63,6 +67,8 @@ describe('Logistration', () => { registrationError: {}, usernameSuggestions: [], validationApiRateLimited: false, + multiStepRegExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, }, commonComponents: { thirdPartyAuthContext: { @@ -83,6 +89,7 @@ describe('Logistration', () => { username: 'test-user', })), })); + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); configure({ loggingService: { logError: jest.fn() }, diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 8c5ea79f..145c5a38 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -176,7 +176,7 @@ const RegistrationPage = (props) => { // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); } - }, [registrationResult]); + }, [registrationResult]); // eslint-disable-line react-hooks/exhaustive-deps const handleOnChange = (event) => { const { name } = event.target; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 18e4e513..59a4d8aa 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -17,6 +17,9 @@ import { setUserPipelineDataLoaded, } from './data/actions'; import { INTERNAL_SERVER_ERROR } from './data/constants'; +import { NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from './data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from './RegistrationPage'; import { AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, @@ -30,6 +33,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('./data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -128,6 +132,7 @@ describe('RegistrationPage', () => { institutionLogin: false, }; window.location = { search: '' }; + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index 8c300b7e..ce0fbea5 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -6,6 +6,7 @@ import PropTypes from 'prop-types'; import { FormFieldRenderer } from '../../field-renderer'; import { FIELDS } from '../data/constants'; +import { FIRST_STEP, shouldDisplayFieldInExperiment } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields'; @@ -31,6 +32,8 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors, setFormFields, autoSubmitRegistrationForm, + multiStepRegistrationExpVariation, + multiStepRegistrationPageStep, } = props; /** The reason for adding the entry 'United States' is that Chrome browser aut-fill the form with the 'Unites @@ -109,7 +112,9 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors(prevErrors => ({ ...prevErrors, [name]: '' })); }; - if (flags.showConfigurableRegistrationFields) { + if (flags.showConfigurableRegistrationFields && shouldDisplayFieldInExperiment( + 'other_fields', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { Object.keys(fieldDescriptions).forEach(fieldName => { const fieldData = fieldDescriptions[fieldName]; switch (fieldData.name) { @@ -161,7 +166,9 @@ const ConfigurableRegistrationForm = (props) => { }); } - if (flags.showConfigurableEdxFields || showCountryField) { + if ((flags.showConfigurableEdxFields || showCountryField) && shouldDisplayFieldInExperiment( + 'country', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { formFieldDescriptions.push( { ); } - if (flags.showMarketingEmailOptInCheckbox) { + if (flags.showMarketingEmailOptInCheckbox && shouldDisplayFieldInExperiment( + 'marketing_email_opt_in', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { formFieldDescriptions.push( { ); } - if (flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) { + if ((flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) + && shouldDisplayFieldInExperiment( + 'honor_code', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, + )) { formFieldDescriptions.push( @@ -231,11 +243,15 @@ ConfigurableRegistrationForm.propTypes = { setFieldErrors: PropTypes.func.isRequired, setFormFields: PropTypes.func.isRequired, autoSubmitRegistrationForm: PropTypes.bool, + multiStepRegistrationExpVariation: PropTypes.string, + multiStepRegistrationPageStep: PropTypes.string, }; ConfigurableRegistrationForm.defaultProps = { fieldDescriptions: {}, autoSubmitRegistrationForm: false, + multiStepRegistrationExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, }; export default ConfigurableRegistrationForm; diff --git a/src/register/components/RegistrationFailure.jsx b/src/register/components/RegistrationFailure.jsx index c34c2af7..f2a83585 100644 --- a/src/register/components/RegistrationFailure.jsx +++ b/src/register/components/RegistrationFailure.jsx @@ -13,12 +13,13 @@ import { TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../data/constants'; +import { FIRST_STEP } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; const RegistrationFailureMessage = (props) => { const { formatMessage } = useIntl(); const { - context, errorCode, failureCount, + context, errorCode, failureCount, multiStepRegistrationPageStep, } = props; useEffect(() => { @@ -49,7 +50,11 @@ const RegistrationFailureMessage = (props) => { errorMessage = formatMessage(messages['registration.tpa.session.expired'], { provider: context.provider }); break; default: - errorMessage = formatMessage(messages['registration.empty.form.submission.error']); + if (multiStepRegistrationPageStep !== FIRST_STEP) { + errorMessage = formatMessage(messages['multistep.registration.form.submission.error']); + } else { + errorMessage = formatMessage(messages['registration.empty.form.submission.error']); + } break; } @@ -65,6 +70,7 @@ RegistrationFailureMessage.defaultProps = { context: { errorMessage: null, }, + multiStepRegistrationPageStep: FIRST_STEP, }; RegistrationFailureMessage.propTypes = { @@ -74,6 +80,7 @@ RegistrationFailureMessage.propTypes = { }), errorCode: PropTypes.string.isRequired, failureCount: PropTypes.number.isRequired, + multiStepRegistrationPageStep: PropTypes.string, }; export default RegistrationFailureMessage; diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 3d664260..00fe5595 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -11,6 +11,9 @@ import configureStore from 'redux-mock-store'; import { registerNewUser } from '../../data/actions'; import { FIELDS } from '../../data/constants'; +import { FIRST_STEP, NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; @@ -22,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlConfigurableRegistrationForm = injectIntl(ConfigurableRegistrationForm); const IntlRegistrationPage = injectIntl(RegistrationPage); @@ -93,6 +97,9 @@ describe('ConfigurableRegistrationForm', () => { registrationError: {}, registrationFormData, usernameSuggestions: [], + multiStepRegistrationPageStep: FIRST_STEP, + multiStepRegExpVariation: '', + isValidatingMultiStepRegistrationPage: false, }, commonComponents: { thirdPartyAuthApiStatus: null, @@ -121,6 +128,7 @@ describe('ConfigurableRegistrationForm', () => { }; window.location = { search: '' }; getLocale.mockImplementationOnce(() => ('en-us')); + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/RegistrationFailure.test.jsx b/src/register/components/tests/RegistrationFailure.test.jsx index 003cc966..b9667c9c 100644 --- a/src/register/components/tests/RegistrationFailure.test.jsx +++ b/src/register/components/tests/RegistrationFailure.test.jsx @@ -12,6 +12,9 @@ import configureStore from 'redux-mock-store'; import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import RegistrationFailureMessage from '../RegistrationFailure'; @@ -23,6 +26,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage); @@ -121,6 +125,7 @@ describe('RegistrationFailure', () => { institutionLogin: false, }; window.location = { search: '' }; + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/ThirdPartyAuth.test.jsx b/src/register/components/tests/ThirdPartyAuth.test.jsx index 917f10f9..45672ad5 100644 --- a/src/register/components/tests/ThirdPartyAuth.test.jsx +++ b/src/register/components/tests/ThirdPartyAuth.test.jsx @@ -12,6 +12,9 @@ import configureStore from 'redux-mock-store'; import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, } from '../../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useMultiStepRegistrationExperimentVariation + from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -22,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -120,6 +124,7 @@ describe('ThirdPartyAuth', () => { institutionLogin: false, }; window.location = { search: '' }; + useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/data/actions.js b/src/register/data/actions.js index 9fa5aed5..530f3bc8 100644 --- a/src/register/data/actions.js +++ b/src/register/data/actions.js @@ -8,6 +8,7 @@ export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERRO export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE'; export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED'; export const REGISTER_SET_EMAIL_SUGGESTIONS = 'REGISTER_SET_EMAIL_SUGGESTIONS'; +export const REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA = 'REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA'; // Backup registration form export const backupRegistrationForm = () => ({ @@ -20,18 +21,19 @@ export const backupRegistrationFormBegin = (data) => ({ }); // Validate fields from the backend -export const fetchRealtimeValidations = (formPayload) => ({ +export const fetchRealtimeValidations = (formPayload, isValidatingMultiStepRegistrationPage) => ({ type: REGISTER_FORM_VALIDATIONS.BASE, - payload: { formPayload }, + payload: { formPayload, isValidatingMultiStepRegistrationPage }, }); -export const fetchRealtimeValidationsBegin = () => ({ +export const fetchRealtimeValidationsBegin = (isValidatingMultiStepRegistrationPage) => ({ type: REGISTER_FORM_VALIDATIONS.BEGIN, + payload: { isValidatingMultiStepRegistrationPage }, }); -export const fetchRealtimeValidationsSuccess = (validations) => ({ +export const fetchRealtimeValidationsSuccess = (validations, isValidatingMultiStepRegistrationPage) => ({ type: REGISTER_FORM_VALIDATIONS.SUCCESS, - payload: { validations }, + payload: { validations, isValidatingMultiStepRegistrationPage }, }); export const fetchRealtimeValidationsFailure = () => ({ @@ -83,3 +85,11 @@ export const setUserPipelineDataLoaded = (value) => ({ type: REGISTER_SET_USER_PIPELINE_DATA_LOADED, payload: { value }, }); + +// Multi Step Registration Experiment Actions +export const setMultiStepRegistrationExpData = ( + multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage, +) => ({ + type: REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, + payload: { multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage }, +}); diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js new file mode 100644 index 00000000..e59ec718 --- /dev/null +++ b/src/register/data/optimizelyExperiment/helper.js @@ -0,0 +1,105 @@ +/** + * This file contains data for Multi Step Registration Optimizely experiment + */ +import { getConfig } from '@edx/frontend-platform'; + +import messages from '../../messages'; + +export const NOT_INITIALIZED = 'experiment-not-initialized'; +export const CONTROL = 'control-registration-page'; +export const MULTI_STEP_REGISTRATION_EXP_VARIATION = 'multi-step-registration-page'; + +export const FIRST_STEP = 'first-step'; +export const SECOND_STEP = 'second-step'; +export const THIRD_STEP = 'third-step'; + +export const CONTROL_REGISTER_PAGE_FIRST_STEP_FIELDS = ['name', 'email', 'password', 'marketing_email_opt_in', 'ThirdPartyAuth']; +export const CONTROL_REGISTER_PAGE_SECOND_STEP_FIELDS = ['username', 'country']; +export const CONTROL_REGISTER_PAGE_COMMON_FIELDS = ['tos_and_honor_code', 'honor_code']; + +export const MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS = ['email', 'marketing_email_opt_in', 'ThirdPartyAuth']; +export const MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS = ['name', 'password']; +export const MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS = ['username', 'country']; +export const MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS = ['tos_and_honor_code', 'honor_code']; + +const MULTI_STEP_REGISTRATION_EXP_PAGE = 'authn_register_page'; + +export function getMultiStepRegistrationExperimentVariation() { + try { + if (window.optimizely + && window.optimizely.get('data').experiments[getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID]) { + const selectedVariant = window.optimizely.get('state').getVariationMap()[ + getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID + ]; + return selectedVariant?.name; + } + } catch (e) { /* empty */ } + return ''; +} + +export function activateMultiStepRegistrationExperiment() { + window.optimizely = window.optimizely || []; + window.optimizely.push({ + type: 'page', + pageName: MULTI_STEP_REGISTRATION_EXP_PAGE, + }); +} + +/** + * We want to display username and honor_code fields in second page if user is in multi-step + * registration page experiment + */ +export const shouldDisplayFieldInExperiment = (fieldName, expVariation, registerPageStep) => ( + !expVariation || expVariation === NOT_INITIALIZED + || (expVariation === CONTROL + && ( + CONTROL_REGISTER_PAGE_COMMON_FIELDS.includes(fieldName) + || (registerPageStep === FIRST_STEP && CONTROL_REGISTER_PAGE_FIRST_STEP_FIELDS.includes(fieldName)) + || (registerPageStep === SECOND_STEP && CONTROL_REGISTER_PAGE_SECOND_STEP_FIELDS.includes(fieldName)) + )) + || (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION + && ( + MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS.includes(fieldName) + || (registerPageStep === FIRST_STEP && MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS.includes(fieldName)) + || (registerPageStep === SECOND_STEP && MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS.includes(fieldName)) + || (registerPageStep === THIRD_STEP && MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS.includes(fieldName)) + )) +); + +export const getRegisterButtonLabelInExperiment = ( + existingButtonLabel, expVariation, registerPageStep, formatMessage, +) => { + if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep === FIRST_STEP) { + return formatMessage(messages['multistep.registration.exp.continue.button']); + } + return existingButtonLabel; +}; + +export const getRegisterButtonSubmitStateInExperiment = ( + registerSubmitState, validationsSubmitState, expVariation, registerPageStep, +) => { + if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep !== THIRD_STEP) { + return validationsSubmitState; + } + return registerSubmitState; +}; + +export const getMultiStepRegistrationPreviousStep = (currentStep) => { + if (currentStep === THIRD_STEP) { + return SECOND_STEP; + } + if (currentStep === SECOND_STEP) { + return FIRST_STEP; + } + return currentStep; +}; + +export const getMultiStepRegistrationNextStep = (currentStep) => { + if (currentStep === FIRST_STEP) { + return SECOND_STEP; + } + if (currentStep === SECOND_STEP) { + return THIRD_STEP; + } + return currentStep; +}; diff --git a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx new file mode 100644 index 00000000..692101b7 --- /dev/null +++ b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; + +import { + activateMultiStepRegistrationExperiment, + getMultiStepRegistrationExperimentVariation, + NOT_INITIALIZED, +} from './helper'; +import { COMPLETE_STATE } from '../../../data/constants'; + +/** + * This hook returns activates multi step registration experiment and returns the experiment + * variation for the user. + */ +const useMultiStepRegistrationExperimentVariation = ( + initExpVariation, + registrationEmbedded, + tpaHint, + currentProvider, + thirdPartyAuthApiStatus, +) => { + const [variation, setVariation] = useState(initExpVariation); + + useEffect(() => { + if (initExpVariation || registrationEmbedded || !!tpaHint || !!currentProvider + || thirdPartyAuthApiStatus !== COMPLETE_STATE) { + return variation; + } + + const getVariation = () => { + const expVariation = getMultiStepRegistrationExperimentVariation(); + if (expVariation) { + setVariation(expVariation); + } else { + // This is to handle the case when user dont get variation for some reason, the register page + // shows unlimited spinner. + setVariation(NOT_INITIALIZED); + } + }; + + activateMultiStepRegistrationExperiment(); + + const timer = setTimeout(getVariation, 300); + + return () => { + clearTimeout(timer); + }; + }, [ // eslint-disable-line react-hooks/exhaustive-deps + currentProvider, initExpVariation, registrationEmbedded, thirdPartyAuthApiStatus, tpaHint, + ]); + + return variation; +}; + +export default useMultiStepRegistrationExperimentVariation; diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index 70c3a994..32438877 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -5,9 +5,11 @@ import { REGISTER_NEW_USER, REGISTER_SET_COUNTRY_CODE, REGISTER_SET_EMAIL_SUGGESTIONS, + REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from './actions'; +import { FIRST_STEP } from './optimizelyExperiment/helper'; import { DEFAULT_STATE, PENDING_STATE, @@ -35,10 +37,14 @@ export const defaultState = { }, validations: null, submitState: DEFAULT_STATE, + validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + multiStepRegExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, + isValidatingMultiStepRegistrationPage: false, }; const reducer = (state = defaultState, action = {}) => { @@ -85,12 +91,22 @@ const reducer = (state = defaultState, action = {}) => { registrationError: { ...registrationErrorTemp }, }; } + case REGISTER_FORM_VALIDATIONS.BEGIN: { + return { + ...state, + validationsSubmitState: action.payload?.isValidatingMultiStepRegistrationPage + ? PENDING_STATE + : state.validationsSubmitState, + }; + } case REGISTER_FORM_VALIDATIONS.SUCCESS: { const { usernameSuggestions, ...validationWithoutUsernameSuggestions } = action.payload.validations; return { ...state, validations: validationWithoutUsernameSuggestions, + isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, usernameSuggestions: usernameSuggestions || state.usernameSuggestions, + validationsSubmitState: DEFAULT_STATE, }; } case REGISTER_FORM_VALIDATIONS.FAILURE: @@ -98,6 +114,7 @@ const reducer = (state = defaultState, action = {}) => { ...state, validationApiRateLimited: true, validations: null, + validationsSubmitState: DEFAULT_STATE, }; case REGISTER_CLEAR_USERNAME_SUGGESTIONS: return { @@ -129,6 +146,14 @@ const reducer = (state = defaultState, action = {}) => { emailSuggestion: action.payload.emailSuggestion, }, }; + case REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA: { + return { + ...state, + multiStepRegExpVariation: action.payload.multiStepRegExpVariation, + multiStepRegistrationPageStep: action.payload.multiStepRegistrationPageStep, + isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, + }; + } default: return { ...state, diff --git a/src/register/data/sagas.js b/src/register/data/sagas.js index 0325cd4b..3edd395e 100644 --- a/src/register/data/sagas.js +++ b/src/register/data/sagas.js @@ -40,10 +40,13 @@ export function* handleNewUserRegistration(action) { export function* fetchRealtimeValidations(action) { try { - yield put(fetchRealtimeValidationsBegin()); + yield put(fetchRealtimeValidationsBegin(action.payload?.isValidatingMultiStepRegistrationPage)); const { fieldValidations } = yield call(getFieldsValidations, action.payload.formPayload); - yield put(fetchRealtimeValidationsSuccess(camelCaseObject(fieldValidations))); + yield put(fetchRealtimeValidationsSuccess( + camelCaseObject(fieldValidations), + action.payload?.isValidatingMultiStepRegistrationPage, + )); } catch (e) { if (e.response && e.response.status === 403) { yield put(fetchRealtimeValidationsFailure()); diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index 3e2270ab..909704d2 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -11,6 +11,7 @@ import { REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from '../actions'; +import { FIRST_STEP } from '../optimizelyExperiment/helper'; import reducer from '../reducers'; describe('Registration Reducer Tests', () => { @@ -34,10 +35,14 @@ describe('Registration Reducer Tests', () => { }, validations: null, submitState: DEFAULT_STATE, + validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + multiStepRegExpVariation: '', + multiStepRegistrationPageStep: FIRST_STEP, + isValidatingMultiStepRegistrationPage: false, }; it('should return the initial state', () => { diff --git a/src/register/messages.jsx b/src/register/messages.jsx index 39d9e7f5..cea5e5df 100644 --- a/src/register/messages.jsx +++ b/src/register/messages.jsx @@ -201,6 +201,29 @@ const messages = defineMessages({ defaultMessage: 'Did you mean', description: 'Did you mean alert suggestion', }, + // MultiStep Registration experiment + 'multistep.registration.exp.continue.button': { + id: 'multistep.registration.exp.continue.button', + defaultMessage: 'Continue', + description: 'Label text for multistep registration page second step', + }, + 'multistep.registration.username.second.step.guideline.content': { + id: 'multistep.registration.username.second.step.guideline.content', + defaultMessage: 'Finish Registration', + description: 'Guideline content for username field in multi-step registration experiment step 2', + }, + 'multistep.registration.username.third.step.guideline.content': { + id: 'multistep.registration.username.third.step.guideline.content', + defaultMessage: 'To finalize your registration, please confirm your country of residence ' + + 'and create a public username that will identify you in your course communication forums. ' + + 'The username cannot be changed.', + description: 'Guideline content for username field in multi-step registration experiment step 2', + }, + 'multistep.registration.form.submission.error': { + id: 'multistep.registration.form.submission.error', + defaultMessage: 'Please check your responses for this and the previous step and try again.', + description: 'Error message that appears on top of the form when invalid form is submitted', + }, }); export default messages; diff --git a/src/sass/_registration.scss b/src/sass/_registration.scss index 75889601..4126da9a 100644 --- a/src/sass/_registration.scss +++ b/src/sass/_registration.scss @@ -1,7 +1,3 @@ -.register-button { - min-width: 14.4rem; -} - .pgn__form-autosuggest__wrapper > .pgn__form-group { margin-bottom: 0 !important; } From 99850574fbdabd1dfcd920f1dc53d1022f6916b4 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Thu, 18 Apr 2024 11:04:28 +0500 Subject: [PATCH 31/82] feat: add multi step registration eventing (#1226) * feat: implement multi step registration experiment * feat: add multi step registration eventing --- src/common-components/SocialAuthProviders.jsx | 14 ++++- src/common-components/ThirdPartyAuth.jsx | 4 ++ .../data/optimizelyExperiment/track.js | 56 +++++++++++++++++++ ...ltiStepRegistrationExperimentVariation.jsx | 2 + 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 src/register/data/optimizelyExperiment/track.js diff --git a/src/common-components/SocialAuthProviders.jsx b/src/common-components/SocialAuthProviders.jsx index abe06da7..5cdb04e0 100644 --- a/src/common-components/SocialAuthProviders.jsx +++ b/src/common-components/SocialAuthProviders.jsx @@ -9,14 +9,24 @@ import PropTypes from 'prop-types'; import messages from './messages'; import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import { CONTROL, MULTI_STEP_REGISTRATION_EXP_VARIATION } from '../register/data/optimizelyExperiment/helper'; +import { trackMultiStepRegistrationSSOBtnClicked } from '../register/data/optimizelyExperiment/track'; const SocialAuthProviders = (props) => { const { formatMessage } = useIntl(); - const { referrer, socialAuthProviders } = props; + const { + referrer, + socialAuthProviders, + multiStepRegistrationExpVariation, + } = props; function handleSubmit(e) { e.preventDefault(); + if (multiStepRegistrationExpVariation === CONTROL + || multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION) { + trackMultiStepRegistrationSSOBtnClicked(multiStepRegistrationExpVariation); + } const url = e.currentTarget.dataset.providerUrl; window.location.href = getConfig().LMS_BASE_URL + url; } @@ -60,6 +70,7 @@ const SocialAuthProviders = (props) => { SocialAuthProviders.defaultProps = { referrer: LOGIN_PAGE, socialAuthProviders: [], + multiStepRegistrationExpVariation: '', }; SocialAuthProviders.propTypes = { @@ -73,6 +84,7 @@ SocialAuthProviders.propTypes = { registerUrl: PropTypes.string, skipRegistrationForm: PropTypes.bool, })), + multiStepRegistrationExpVariation: PropTypes.string, }; export default SocialAuthProviders; diff --git a/src/common-components/ThirdPartyAuth.jsx b/src/common-components/ThirdPartyAuth.jsx index 7dcef3e1..406c8cf8 100644 --- a/src/common-components/ThirdPartyAuth.jsx +++ b/src/common-components/ThirdPartyAuth.jsx @@ -32,6 +32,7 @@ const ThirdPartyAuth = (props) => { handleInstitutionLogin, thirdPartyAuthApiStatus, isLoginPage, + multiStepRegistrationExpVariation, } = props; const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider; const isSocialAuthActive = !!providers.length && !currentProvider; @@ -79,6 +80,7 @@ const ThirdPartyAuth = (props) => { )} @@ -94,6 +96,7 @@ ThirdPartyAuth.defaultProps = { secondaryProviders: [], thirdPartyAuthApiStatus: PENDING_STATE, isLoginPage: false, + multiStepRegistrationExpVariation: '', }; ThirdPartyAuth.propTypes = { @@ -121,6 +124,7 @@ ThirdPartyAuth.propTypes = { ), thirdPartyAuthApiStatus: PropTypes.string, isLoginPage: PropTypes.bool, + multiStepRegistrationExpVariation: PropTypes.string, }; export default ThirdPartyAuth; diff --git a/src/register/data/optimizelyExperiment/track.js b/src/register/data/optimizelyExperiment/track.js new file mode 100644 index 00000000..8db71809 --- /dev/null +++ b/src/register/data/optimizelyExperiment/track.js @@ -0,0 +1,56 @@ +import { sendTrackEvent } from '@edx/frontend-platform/analytics'; + +export const eventNames = { + multiStepRegistrationStep1Viewed: 'edx.bi.user.multistepregistration.step1.viewed', + multiStepRegistrationStep2Viewed: 'edx.bi.user.multistepregistration.step2.viewed', + multiStepRegistrationStep3Viewed: 'edx.bi.user.multistepregistration.step3.viewed', + multiStepRegistrationStep1SubmitBtnClicked: 'edx.bi.user.registration.step1.submit.click', + multiStepRegistrationStep2SubmitBtnClicked: 'edx.bi.user.registration.step2.submit.click', + multiStepRegistrationStep3SubmitBtnClicked: 'edx.bi.user.registration.step3.submit.click', + multiStepRegistrationFormSubmitBtnClicked: 'edx.bi.user.registration.form.submit.click', + multiStepRegistrationSSOBtnClicked: 'edx.bi.user.registration.sso.btn.click', +}; + +export const trackMultiStepRegistrationStep1Viewed = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationStep1Viewed, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationStep2Viewed = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationStep2Viewed, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationStep3Viewed = () => { + sendTrackEvent(eventNames.multiStepRegistrationStep3Viewed, {}); +}; + +export const trackMultiStepRegistrationStep1SubmitBtnClicked = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationStep1SubmitBtnClicked, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationStep2SubmitBtnClicked = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationStep2SubmitBtnClicked, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationStep3SubmitBtnClicked = () => { + sendTrackEvent(eventNames.multiStepRegistrationStep3SubmitBtnClicked, {}); +}; + +export const trackMultiStepRegistrationFormSubmitBtnClicked = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationFormSubmitBtnClicked, { + variation: expVariation, + }); +}; + +export const trackMultiStepRegistrationSSOBtnClicked = (expVariation) => { + sendTrackEvent(eventNames.multiStepRegistrationSSOBtnClicked, { + variation: expVariation, + }); +}; diff --git a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx index 692101b7..97d0a233 100644 --- a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx +++ b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx @@ -5,6 +5,7 @@ import { getMultiStepRegistrationExperimentVariation, NOT_INITIALIZED, } from './helper'; +import { trackMultiStepRegistrationStep1Viewed } from './track'; import { COMPLETE_STATE } from '../../../data/constants'; /** @@ -30,6 +31,7 @@ const useMultiStepRegistrationExperimentVariation = ( const expVariation = getMultiStepRegistrationExperimentVariation(); if (expVariation) { setVariation(expVariation); + trackMultiStepRegistrationStep1Viewed(expVariation); } else { // This is to handle the case when user dont get variation for some reason, the register page // shows unlimited spinner. From 4f48e82959186d145b6b67ddcbd545d9008441a7 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah Date: Mon, 22 Apr 2024 11:34:47 +0500 Subject: [PATCH 32/82] fix: fix register button width --- src/register/data/optimizelyExperiment/helper.js | 7 +++++++ src/sass/_registration.scss | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js index e59ec718..7140d5ec 100644 --- a/src/register/data/optimizelyExperiment/helper.js +++ b/src/register/data/optimizelyExperiment/helper.js @@ -75,6 +75,13 @@ export const getRegisterButtonLabelInExperiment = ( return existingButtonLabel; }; +export const getRegisterButtonClassInExperiment = (expVariation, registerPageStep) => { + if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep === FIRST_STEP) { + return 'continue-button'; + } + return 'register-button'; +}; + export const getRegisterButtonSubmitStateInExperiment = ( registerSubmitState, validationsSubmitState, expVariation, registerPageStep, ) => { diff --git a/src/sass/_registration.scss b/src/sass/_registration.scss index 4126da9a..884af10d 100644 --- a/src/sass/_registration.scss +++ b/src/sass/_registration.scss @@ -1,3 +1,11 @@ +.register-button { + min-width: 14.4rem; +} + +.continue-button { + min-width: 7rem; +} + .pgn__form-autosuggest__wrapper > .pgn__form-group { margin-bottom: 0 !important; } From 788a42b341f9a80c2fc0c3f51b6c01e6b3921170 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah Date: Mon, 22 Apr 2024 15:17:08 +0500 Subject: [PATCH 33/82] fix: fix register button loader for control --- src/register/data/optimizelyExperiment/helper.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js index 7140d5ec..f3d21381 100644 --- a/src/register/data/optimizelyExperiment/helper.js +++ b/src/register/data/optimizelyExperiment/helper.js @@ -85,7 +85,8 @@ export const getRegisterButtonClassInExperiment = (expVariation, registerPageSte export const getRegisterButtonSubmitStateInExperiment = ( registerSubmitState, validationsSubmitState, expVariation, registerPageStep, ) => { - if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep !== THIRD_STEP) { + if ((expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep !== THIRD_STEP) + || (expVariation === CONTROL && registerPageStep !== SECOND_STEP)) { return validationsSubmitState; } return registerSubmitState; From f8290adab516f0883d9691c8a12752fadde328e6 Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Thu, 25 Apr 2024 15:28:05 +0500 Subject: [PATCH 34/82] feat: capture marketing lead in experiment events (#1243) --- src/register/data/optimizelyExperiment/track.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/register/data/optimizelyExperiment/track.js b/src/register/data/optimizelyExperiment/track.js index 8db71809..68a6347a 100644 --- a/src/register/data/optimizelyExperiment/track.js +++ b/src/register/data/optimizelyExperiment/track.js @@ -17,9 +17,10 @@ export const trackMultiStepRegistrationStep1Viewed = (expVariation) => { }); }; -export const trackMultiStepRegistrationStep2Viewed = (expVariation) => { +export const trackMultiStepRegistrationStep2Viewed = (expVariation, isMarketingLead) => { sendTrackEvent(eventNames.multiStepRegistrationStep2Viewed, { variation: expVariation, + is_marketing_lead: isMarketingLead, }); }; From a90ebb7d4d7ce6dd886ce6f97db1cba31ea6c626 Mon Sep 17 00:00:00 2001 From: mubbsharanwar Date: Mon, 6 May 2024 15:57:54 +0500 Subject: [PATCH 35/82] revert: multistep registration experiment revert multistep registration experiment changes VAN-1930 --- src/common-components/SocialAuthProviders.jsx | 14 +-- src/common-components/ThirdPartyAuth.jsx | 4 - src/common-components/messages.jsx | 6 - src/config/index.js | 2 - src/logistration/Logistration.jsx | 55 ++------- src/logistration/Logistration.test.jsx | 7 -- src/register/RegistrationPage.jsx | 2 +- src/register/RegistrationPage.test.jsx | 5 - .../ConfigurableRegistrationForm.jsx | 24 +--- .../components/RegistrationFailure.jsx | 11 +- .../ConfigurableRegistrationForm.test.jsx | 8 -- .../tests/RegistrationFailure.test.jsx | 5 - .../components/tests/ThirdPartyAuth.test.jsx | 5 - src/register/data/actions.js | 21 +--- .../data/optimizelyExperiment/helper.js | 113 ------------------ .../data/optimizelyExperiment/track.js | 57 --------- ...ltiStepRegistrationExperimentVariation.jsx | 56 --------- src/register/data/reducers.js | 25 ---- src/register/data/sagas.js | 7 +- src/register/data/tests/reducers.test.js | 5 - src/register/data/utils.js | 36 +++--- src/register/messages.jsx | 23 ---- src/sass/_registration.scss | 4 - 23 files changed, 38 insertions(+), 457 deletions(-) delete mode 100644 src/register/data/optimizelyExperiment/helper.js delete mode 100644 src/register/data/optimizelyExperiment/track.js delete mode 100644 src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx diff --git a/src/common-components/SocialAuthProviders.jsx b/src/common-components/SocialAuthProviders.jsx index 5cdb04e0..abe06da7 100644 --- a/src/common-components/SocialAuthProviders.jsx +++ b/src/common-components/SocialAuthProviders.jsx @@ -9,24 +9,14 @@ import PropTypes from 'prop-types'; import messages from './messages'; import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; -import { CONTROL, MULTI_STEP_REGISTRATION_EXP_VARIATION } from '../register/data/optimizelyExperiment/helper'; -import { trackMultiStepRegistrationSSOBtnClicked } from '../register/data/optimizelyExperiment/track'; const SocialAuthProviders = (props) => { const { formatMessage } = useIntl(); - const { - referrer, - socialAuthProviders, - multiStepRegistrationExpVariation, - } = props; + const { referrer, socialAuthProviders } = props; function handleSubmit(e) { e.preventDefault(); - if (multiStepRegistrationExpVariation === CONTROL - || multiStepRegistrationExpVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION) { - trackMultiStepRegistrationSSOBtnClicked(multiStepRegistrationExpVariation); - } const url = e.currentTarget.dataset.providerUrl; window.location.href = getConfig().LMS_BASE_URL + url; } @@ -70,7 +60,6 @@ const SocialAuthProviders = (props) => { SocialAuthProviders.defaultProps = { referrer: LOGIN_PAGE, socialAuthProviders: [], - multiStepRegistrationExpVariation: '', }; SocialAuthProviders.propTypes = { @@ -84,7 +73,6 @@ SocialAuthProviders.propTypes = { registerUrl: PropTypes.string, skipRegistrationForm: PropTypes.bool, })), - multiStepRegistrationExpVariation: PropTypes.string, }; export default SocialAuthProviders; diff --git a/src/common-components/ThirdPartyAuth.jsx b/src/common-components/ThirdPartyAuth.jsx index 406c8cf8..7dcef3e1 100644 --- a/src/common-components/ThirdPartyAuth.jsx +++ b/src/common-components/ThirdPartyAuth.jsx @@ -32,7 +32,6 @@ const ThirdPartyAuth = (props) => { handleInstitutionLogin, thirdPartyAuthApiStatus, isLoginPage, - multiStepRegistrationExpVariation, } = props; const isInstitutionAuthActive = !!secondaryProviders.length && !currentProvider; const isSocialAuthActive = !!providers.length && !currentProvider; @@ -80,7 +79,6 @@ const ThirdPartyAuth = (props) => { )} @@ -96,7 +94,6 @@ ThirdPartyAuth.defaultProps = { secondaryProviders: [], thirdPartyAuthApiStatus: PENDING_STATE, isLoginPage: false, - multiStepRegistrationExpVariation: '', }; ThirdPartyAuth.propTypes = { @@ -124,7 +121,6 @@ ThirdPartyAuth.propTypes = { ), thirdPartyAuthApiStatus: PropTypes.string, isLoginPage: PropTypes.bool, - multiStepRegistrationExpVariation: PropTypes.string, }; export default ThirdPartyAuth; diff --git a/src/common-components/messages.jsx b/src/common-components/messages.jsx index 6f0e69ca..08e88b8f 100644 --- a/src/common-components/messages.jsx +++ b/src/common-components/messages.jsx @@ -132,12 +132,6 @@ const messages = defineMessages({ defaultMessage: 'Company or school credentials', description: 'Company or school login link text.', }, - // multi step registration experiment messages - 'tab.back.btn.text': { - id: 'tab.back.btn.text', - defaultMessage: 'Back', - description: 'Tab back button text', - }, }); export default messages; diff --git a/src/config/index.js b/src/config/index.js index 781db13c..badb6fe7 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -35,8 +35,6 @@ const configuration = { ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL, ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '', ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '', - // Multi Step Registration Experiment - MULTI_STEP_REGISTRATION_EXPERIMENT_ID: process.env.MULTI_STEP_REGISTRATION_EXPERIMENT_ID || '', }; export default configuration; diff --git a/src/logistration/Logistration.jsx b/src/logistration/Logistration.jsx index 5199e1b3..9451aa27 100644 --- a/src/logistration/Logistration.jsx +++ b/src/logistration/Logistration.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { connect, useDispatch, useSelector } from 'react-redux'; +import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; @@ -7,11 +7,10 @@ import { getAuthService } from '@edx/frontend-platform/auth'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Icon, - IconButton, Tab, Tabs, } from '@openedx/paragon'; -import { ArrowBackIos, ChevronLeft } from '@openedx/paragon/icons'; +import { ChevronLeft } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import { Navigate, useNavigate } from 'react-router-dom'; @@ -28,11 +27,7 @@ import { import { LoginPage } from '../login'; import { backupLoginForm } from '../login/data/actions'; import { RegistrationPage } from '../register'; -import { backupRegistrationForm, setMultiStepRegistrationExpData } from '../register/data/actions'; -import { - FIRST_STEP, - getMultiStepRegistrationPreviousStep, -} from '../register/data/optimizelyExperiment/helper'; +import { backupRegistrationForm } from '../register/data/actions'; const Logistration = (props) => { const { selectedPage, tpaProviders } = props; @@ -47,10 +42,6 @@ const Logistration = (props) => { const disablePublicAccountCreation = getConfig().ALLOW_PUBLIC_ACCOUNT_CREATION === false; const hideRegistrationLink = getConfig().SHOW_REGISTRATION_LINKS === false; - const dispatch = useDispatch(); - const multiStepRegExpVariation = useSelector(state => state.register.multiStepRegExpVariation); - const multiStepRegistrationPageStep = useSelector(state => state.register.multiStepRegistrationPageStep); - useEffect(() => { const authService = getAuthService(); if (authService) { @@ -100,39 +91,6 @@ const Logistration = (props) => { ); - /** - * Temporary function created to resolve the complexity in tabs conditioning for multi-step - * registration experiment - */ - const getTabs = () => { - if (multiStepRegistrationPageStep !== FIRST_STEP) { - const prevStep = getMultiStepRegistrationPreviousStep(multiStepRegistrationPageStep); - return ( -
- { - dispatch(setMultiStepRegistrationExpData(multiStepRegExpVariation, prevStep)); - }} - variant="primary" - size="inline" - className="mr-1" - /> - {formatMessage(messages['tab.back.btn.text'])} -
- ); - } - return ( - handleOnSelect(tabKey, selectedPage)}> - - - - ); - }; - const isValidTpaHint = () => { const { provider } = getTpaProvider(tpaHint, providers, secondaryProviders); return !!provider; @@ -165,7 +123,12 @@ const Logistration = (props) => { ) - : (!isValidTpaHint() && !hideRegistrationLink && getTabs())} + : (!isValidTpaHint() && !hideRegistrationLink && ( + handleOnSelect(tabKey, selectedPage)}> + + + + ))} { key && ( )} diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index ce7af88f..87bf3e70 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -15,16 +15,12 @@ import { } from '../data/constants'; import { backupLoginForm } from '../login/data/actions'; import { backupRegistrationForm } from '../register/data/actions'; -import { FIRST_STEP, NOT_INITIALIZED } from '../register/data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from '../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); -jest.mock('../register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const mockStore = configureStore(); const IntlLogistration = injectIntl(Logistration); @@ -67,8 +63,6 @@ describe('Logistration', () => { registrationError: {}, usernameSuggestions: [], validationApiRateLimited: false, - multiStepRegExpVariation: '', - multiStepRegistrationPageStep: FIRST_STEP, }, commonComponents: { thirdPartyAuthContext: { @@ -89,7 +83,6 @@ describe('Logistration', () => { username: 'test-user', })), })); - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); configure({ loggingService: { logError: jest.fn() }, diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 145c5a38..8c5ea79f 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -176,7 +176,7 @@ const RegistrationPage = (props) => { // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); } - }, [registrationResult]); // eslint-disable-line react-hooks/exhaustive-deps + }, [registrationResult]); const handleOnChange = (event) => { const { name } = event.target; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 59a4d8aa..18e4e513 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -17,9 +17,6 @@ import { setUserPipelineDataLoaded, } from './data/actions'; import { INTERNAL_SERVER_ERROR } from './data/constants'; -import { NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from './data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from './RegistrationPage'; import { AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, @@ -33,7 +30,6 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); -jest.mock('./data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -132,7 +128,6 @@ describe('RegistrationPage', () => { institutionLogin: false, }; window.location = { search: '' }; - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index ce0fbea5..8c300b7e 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -6,7 +6,6 @@ import PropTypes from 'prop-types'; import { FormFieldRenderer } from '../../field-renderer'; import { FIELDS } from '../data/constants'; -import { FIRST_STEP, shouldDisplayFieldInExperiment } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields'; @@ -32,8 +31,6 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors, setFormFields, autoSubmitRegistrationForm, - multiStepRegistrationExpVariation, - multiStepRegistrationPageStep, } = props; /** The reason for adding the entry 'United States' is that Chrome browser aut-fill the form with the 'Unites @@ -112,9 +109,7 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors(prevErrors => ({ ...prevErrors, [name]: '' })); }; - if (flags.showConfigurableRegistrationFields && shouldDisplayFieldInExperiment( - 'other_fields', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - )) { + if (flags.showConfigurableRegistrationFields) { Object.keys(fieldDescriptions).forEach(fieldName => { const fieldData = fieldDescriptions[fieldName]; switch (fieldData.name) { @@ -166,9 +161,7 @@ const ConfigurableRegistrationForm = (props) => { }); } - if ((flags.showConfigurableEdxFields || showCountryField) && shouldDisplayFieldInExperiment( - 'country', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - )) { + if (flags.showConfigurableEdxFields || showCountryField) { formFieldDescriptions.push( { ); } - if (flags.showMarketingEmailOptInCheckbox && shouldDisplayFieldInExperiment( - 'marketing_email_opt_in', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - )) { + if (flags.showMarketingEmailOptInCheckbox) { formFieldDescriptions.push( { ); } - if ((flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) - && shouldDisplayFieldInExperiment( - 'honor_code', multiStepRegistrationExpVariation, multiStepRegistrationPageStep, - )) { + if (flags.showConfigurableEdxFields || showTermsOfServiceAndHonorCode) { formFieldDescriptions.push( @@ -243,15 +231,11 @@ ConfigurableRegistrationForm.propTypes = { setFieldErrors: PropTypes.func.isRequired, setFormFields: PropTypes.func.isRequired, autoSubmitRegistrationForm: PropTypes.bool, - multiStepRegistrationExpVariation: PropTypes.string, - multiStepRegistrationPageStep: PropTypes.string, }; ConfigurableRegistrationForm.defaultProps = { fieldDescriptions: {}, autoSubmitRegistrationForm: false, - multiStepRegistrationExpVariation: '', - multiStepRegistrationPageStep: FIRST_STEP, }; export default ConfigurableRegistrationForm; diff --git a/src/register/components/RegistrationFailure.jsx b/src/register/components/RegistrationFailure.jsx index f2a83585..c34c2af7 100644 --- a/src/register/components/RegistrationFailure.jsx +++ b/src/register/components/RegistrationFailure.jsx @@ -13,13 +13,12 @@ import { TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../data/constants'; -import { FIRST_STEP } from '../data/optimizelyExperiment/helper'; import messages from '../messages'; const RegistrationFailureMessage = (props) => { const { formatMessage } = useIntl(); const { - context, errorCode, failureCount, multiStepRegistrationPageStep, + context, errorCode, failureCount, } = props; useEffect(() => { @@ -50,11 +49,7 @@ const RegistrationFailureMessage = (props) => { errorMessage = formatMessage(messages['registration.tpa.session.expired'], { provider: context.provider }); break; default: - if (multiStepRegistrationPageStep !== FIRST_STEP) { - errorMessage = formatMessage(messages['multistep.registration.form.submission.error']); - } else { - errorMessage = formatMessage(messages['registration.empty.form.submission.error']); - } + errorMessage = formatMessage(messages['registration.empty.form.submission.error']); break; } @@ -70,7 +65,6 @@ RegistrationFailureMessage.defaultProps = { context: { errorMessage: null, }, - multiStepRegistrationPageStep: FIRST_STEP, }; RegistrationFailureMessage.propTypes = { @@ -80,7 +74,6 @@ RegistrationFailureMessage.propTypes = { }), errorCode: PropTypes.string.isRequired, failureCount: PropTypes.number.isRequired, - multiStepRegistrationPageStep: PropTypes.string, }; export default RegistrationFailureMessage; diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 00fe5595..3d664260 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -11,9 +11,6 @@ import configureStore from 'redux-mock-store'; import { registerNewUser } from '../../data/actions'; import { FIELDS } from '../../data/constants'; -import { FIRST_STEP, NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; @@ -25,7 +22,6 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); -jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlConfigurableRegistrationForm = injectIntl(ConfigurableRegistrationForm); const IntlRegistrationPage = injectIntl(RegistrationPage); @@ -97,9 +93,6 @@ describe('ConfigurableRegistrationForm', () => { registrationError: {}, registrationFormData, usernameSuggestions: [], - multiStepRegistrationPageStep: FIRST_STEP, - multiStepRegExpVariation: '', - isValidatingMultiStepRegistrationPage: false, }, commonComponents: { thirdPartyAuthApiStatus: null, @@ -128,7 +121,6 @@ describe('ConfigurableRegistrationForm', () => { }; window.location = { search: '' }; getLocale.mockImplementationOnce(() => ('en-us')); - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/RegistrationFailure.test.jsx b/src/register/components/tests/RegistrationFailure.test.jsx index b9667c9c..003cc966 100644 --- a/src/register/components/tests/RegistrationFailure.test.jsx +++ b/src/register/components/tests/RegistrationFailure.test.jsx @@ -12,9 +12,6 @@ import configureStore from 'redux-mock-store'; import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../../data/constants'; -import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import RegistrationFailureMessage from '../RegistrationFailure'; @@ -26,7 +23,6 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); -jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage); @@ -125,7 +121,6 @@ describe('RegistrationFailure', () => { institutionLogin: false, }; window.location = { search: '' }; - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/ThirdPartyAuth.test.jsx b/src/register/components/tests/ThirdPartyAuth.test.jsx index 45672ad5..917f10f9 100644 --- a/src/register/components/tests/ThirdPartyAuth.test.jsx +++ b/src/register/components/tests/ThirdPartyAuth.test.jsx @@ -12,9 +12,6 @@ import configureStore from 'redux-mock-store'; import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, } from '../../../data/constants'; -import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; -import useMultiStepRegistrationExperimentVariation - from '../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -25,7 +22,6 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); -jest.mock('../../data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -124,7 +120,6 @@ describe('ThirdPartyAuth', () => { institutionLogin: false, }; window.location = { search: '' }; - useMultiStepRegistrationExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/data/actions.js b/src/register/data/actions.js index 530f3bc8..68bfe9e6 100644 --- a/src/register/data/actions.js +++ b/src/register/data/actions.js @@ -8,8 +8,6 @@ export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERRO export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE'; export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED'; export const REGISTER_SET_EMAIL_SUGGESTIONS = 'REGISTER_SET_EMAIL_SUGGESTIONS'; -export const REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA = 'REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA'; - // Backup registration form export const backupRegistrationForm = () => ({ type: BACKUP_REGISTRATION_DATA.BASE, @@ -21,19 +19,18 @@ export const backupRegistrationFormBegin = (data) => ({ }); // Validate fields from the backend -export const fetchRealtimeValidations = (formPayload, isValidatingMultiStepRegistrationPage) => ({ +export const fetchRealtimeValidations = (formPayload) => ({ type: REGISTER_FORM_VALIDATIONS.BASE, - payload: { formPayload, isValidatingMultiStepRegistrationPage }, + payload: { formPayload }, }); -export const fetchRealtimeValidationsBegin = (isValidatingMultiStepRegistrationPage) => ({ +export const fetchRealtimeValidationsBegin = () => ({ type: REGISTER_FORM_VALIDATIONS.BEGIN, - payload: { isValidatingMultiStepRegistrationPage }, }); -export const fetchRealtimeValidationsSuccess = (validations, isValidatingMultiStepRegistrationPage) => ({ +export const fetchRealtimeValidationsSuccess = (validations) => ({ type: REGISTER_FORM_VALIDATIONS.SUCCESS, - payload: { validations, isValidatingMultiStepRegistrationPage }, + payload: { validations }, }); export const fetchRealtimeValidationsFailure = () => ({ @@ -85,11 +82,3 @@ export const setUserPipelineDataLoaded = (value) => ({ type: REGISTER_SET_USER_PIPELINE_DATA_LOADED, payload: { value }, }); - -// Multi Step Registration Experiment Actions -export const setMultiStepRegistrationExpData = ( - multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage, -) => ({ - type: REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, - payload: { multiStepRegExpVariation, multiStepRegistrationPageStep, isValidatingMultiStepRegistrationPage }, -}); diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js deleted file mode 100644 index f3d21381..00000000 --- a/src/register/data/optimizelyExperiment/helper.js +++ /dev/null @@ -1,113 +0,0 @@ -/** - * This file contains data for Multi Step Registration Optimizely experiment - */ -import { getConfig } from '@edx/frontend-platform'; - -import messages from '../../messages'; - -export const NOT_INITIALIZED = 'experiment-not-initialized'; -export const CONTROL = 'control-registration-page'; -export const MULTI_STEP_REGISTRATION_EXP_VARIATION = 'multi-step-registration-page'; - -export const FIRST_STEP = 'first-step'; -export const SECOND_STEP = 'second-step'; -export const THIRD_STEP = 'third-step'; - -export const CONTROL_REGISTER_PAGE_FIRST_STEP_FIELDS = ['name', 'email', 'password', 'marketing_email_opt_in', 'ThirdPartyAuth']; -export const CONTROL_REGISTER_PAGE_SECOND_STEP_FIELDS = ['username', 'country']; -export const CONTROL_REGISTER_PAGE_COMMON_FIELDS = ['tos_and_honor_code', 'honor_code']; - -export const MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS = ['email', 'marketing_email_opt_in', 'ThirdPartyAuth']; -export const MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS = ['name', 'password']; -export const MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS = ['username', 'country']; -export const MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS = ['tos_and_honor_code', 'honor_code']; - -const MULTI_STEP_REGISTRATION_EXP_PAGE = 'authn_register_page'; - -export function getMultiStepRegistrationExperimentVariation() { - try { - if (window.optimizely - && window.optimizely.get('data').experiments[getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID]) { - const selectedVariant = window.optimizely.get('state').getVariationMap()[ - getConfig().MULTI_STEP_REGISTRATION_EXPERIMENT_ID - ]; - return selectedVariant?.name; - } - } catch (e) { /* empty */ } - return ''; -} - -export function activateMultiStepRegistrationExperiment() { - window.optimizely = window.optimizely || []; - window.optimizely.push({ - type: 'page', - pageName: MULTI_STEP_REGISTRATION_EXP_PAGE, - }); -} - -/** - * We want to display username and honor_code fields in second page if user is in multi-step - * registration page experiment - */ -export const shouldDisplayFieldInExperiment = (fieldName, expVariation, registerPageStep) => ( - !expVariation || expVariation === NOT_INITIALIZED - || (expVariation === CONTROL - && ( - CONTROL_REGISTER_PAGE_COMMON_FIELDS.includes(fieldName) - || (registerPageStep === FIRST_STEP && CONTROL_REGISTER_PAGE_FIRST_STEP_FIELDS.includes(fieldName)) - || (registerPageStep === SECOND_STEP && CONTROL_REGISTER_PAGE_SECOND_STEP_FIELDS.includes(fieldName)) - )) - || (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION - && ( - MULTI_STEP_REGISTER_PAGE_COMMON_FIELDS.includes(fieldName) - || (registerPageStep === FIRST_STEP && MULTI_STEP_REGISTER_PAGE_FIRST_STEP_FIELDS.includes(fieldName)) - || (registerPageStep === SECOND_STEP && MULTI_STEP_REGISTER_PAGE_SECOND_STEP_FIELDS.includes(fieldName)) - || (registerPageStep === THIRD_STEP && MULTI_STEP_REGISTER_PAGE_THIRD_STEP_FIELDS.includes(fieldName)) - )) -); - -export const getRegisterButtonLabelInExperiment = ( - existingButtonLabel, expVariation, registerPageStep, formatMessage, -) => { - if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep === FIRST_STEP) { - return formatMessage(messages['multistep.registration.exp.continue.button']); - } - return existingButtonLabel; -}; - -export const getRegisterButtonClassInExperiment = (expVariation, registerPageStep) => { - if (expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep === FIRST_STEP) { - return 'continue-button'; - } - return 'register-button'; -}; - -export const getRegisterButtonSubmitStateInExperiment = ( - registerSubmitState, validationsSubmitState, expVariation, registerPageStep, -) => { - if ((expVariation === MULTI_STEP_REGISTRATION_EXP_VARIATION && registerPageStep !== THIRD_STEP) - || (expVariation === CONTROL && registerPageStep !== SECOND_STEP)) { - return validationsSubmitState; - } - return registerSubmitState; -}; - -export const getMultiStepRegistrationPreviousStep = (currentStep) => { - if (currentStep === THIRD_STEP) { - return SECOND_STEP; - } - if (currentStep === SECOND_STEP) { - return FIRST_STEP; - } - return currentStep; -}; - -export const getMultiStepRegistrationNextStep = (currentStep) => { - if (currentStep === FIRST_STEP) { - return SECOND_STEP; - } - if (currentStep === SECOND_STEP) { - return THIRD_STEP; - } - return currentStep; -}; diff --git a/src/register/data/optimizelyExperiment/track.js b/src/register/data/optimizelyExperiment/track.js deleted file mode 100644 index 68a6347a..00000000 --- a/src/register/data/optimizelyExperiment/track.js +++ /dev/null @@ -1,57 +0,0 @@ -import { sendTrackEvent } from '@edx/frontend-platform/analytics'; - -export const eventNames = { - multiStepRegistrationStep1Viewed: 'edx.bi.user.multistepregistration.step1.viewed', - multiStepRegistrationStep2Viewed: 'edx.bi.user.multistepregistration.step2.viewed', - multiStepRegistrationStep3Viewed: 'edx.bi.user.multistepregistration.step3.viewed', - multiStepRegistrationStep1SubmitBtnClicked: 'edx.bi.user.registration.step1.submit.click', - multiStepRegistrationStep2SubmitBtnClicked: 'edx.bi.user.registration.step2.submit.click', - multiStepRegistrationStep3SubmitBtnClicked: 'edx.bi.user.registration.step3.submit.click', - multiStepRegistrationFormSubmitBtnClicked: 'edx.bi.user.registration.form.submit.click', - multiStepRegistrationSSOBtnClicked: 'edx.bi.user.registration.sso.btn.click', -}; - -export const trackMultiStepRegistrationStep1Viewed = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationStep1Viewed, { - variation: expVariation, - }); -}; - -export const trackMultiStepRegistrationStep2Viewed = (expVariation, isMarketingLead) => { - sendTrackEvent(eventNames.multiStepRegistrationStep2Viewed, { - variation: expVariation, - is_marketing_lead: isMarketingLead, - }); -}; - -export const trackMultiStepRegistrationStep3Viewed = () => { - sendTrackEvent(eventNames.multiStepRegistrationStep3Viewed, {}); -}; - -export const trackMultiStepRegistrationStep1SubmitBtnClicked = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationStep1SubmitBtnClicked, { - variation: expVariation, - }); -}; - -export const trackMultiStepRegistrationStep2SubmitBtnClicked = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationStep2SubmitBtnClicked, { - variation: expVariation, - }); -}; - -export const trackMultiStepRegistrationStep3SubmitBtnClicked = () => { - sendTrackEvent(eventNames.multiStepRegistrationStep3SubmitBtnClicked, {}); -}; - -export const trackMultiStepRegistrationFormSubmitBtnClicked = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationFormSubmitBtnClicked, { - variation: expVariation, - }); -}; - -export const trackMultiStepRegistrationSSOBtnClicked = (expVariation) => { - sendTrackEvent(eventNames.multiStepRegistrationSSOBtnClicked, { - variation: expVariation, - }); -}; diff --git a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx b/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx deleted file mode 100644 index 97d0a233..00000000 --- a/src/register/data/optimizelyExperiment/useMultiStepRegistrationExperimentVariation.jsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { - activateMultiStepRegistrationExperiment, - getMultiStepRegistrationExperimentVariation, - NOT_INITIALIZED, -} from './helper'; -import { trackMultiStepRegistrationStep1Viewed } from './track'; -import { COMPLETE_STATE } from '../../../data/constants'; - -/** - * This hook returns activates multi step registration experiment and returns the experiment - * variation for the user. - */ -const useMultiStepRegistrationExperimentVariation = ( - initExpVariation, - registrationEmbedded, - tpaHint, - currentProvider, - thirdPartyAuthApiStatus, -) => { - const [variation, setVariation] = useState(initExpVariation); - - useEffect(() => { - if (initExpVariation || registrationEmbedded || !!tpaHint || !!currentProvider - || thirdPartyAuthApiStatus !== COMPLETE_STATE) { - return variation; - } - - const getVariation = () => { - const expVariation = getMultiStepRegistrationExperimentVariation(); - if (expVariation) { - setVariation(expVariation); - trackMultiStepRegistrationStep1Viewed(expVariation); - } else { - // This is to handle the case when user dont get variation for some reason, the register page - // shows unlimited spinner. - setVariation(NOT_INITIALIZED); - } - }; - - activateMultiStepRegistrationExperiment(); - - const timer = setTimeout(getVariation, 300); - - return () => { - clearTimeout(timer); - }; - }, [ // eslint-disable-line react-hooks/exhaustive-deps - currentProvider, initExpVariation, registrationEmbedded, thirdPartyAuthApiStatus, tpaHint, - ]); - - return variation; -}; - -export default useMultiStepRegistrationExperimentVariation; diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index 32438877..70c3a994 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -5,11 +5,9 @@ import { REGISTER_NEW_USER, REGISTER_SET_COUNTRY_CODE, REGISTER_SET_EMAIL_SUGGESTIONS, - REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA, REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from './actions'; -import { FIRST_STEP } from './optimizelyExperiment/helper'; import { DEFAULT_STATE, PENDING_STATE, @@ -37,14 +35,10 @@ export const defaultState = { }, validations: null, submitState: DEFAULT_STATE, - validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, - multiStepRegExpVariation: '', - multiStepRegistrationPageStep: FIRST_STEP, - isValidatingMultiStepRegistrationPage: false, }; const reducer = (state = defaultState, action = {}) => { @@ -91,22 +85,12 @@ const reducer = (state = defaultState, action = {}) => { registrationError: { ...registrationErrorTemp }, }; } - case REGISTER_FORM_VALIDATIONS.BEGIN: { - return { - ...state, - validationsSubmitState: action.payload?.isValidatingMultiStepRegistrationPage - ? PENDING_STATE - : state.validationsSubmitState, - }; - } case REGISTER_FORM_VALIDATIONS.SUCCESS: { const { usernameSuggestions, ...validationWithoutUsernameSuggestions } = action.payload.validations; return { ...state, validations: validationWithoutUsernameSuggestions, - isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, usernameSuggestions: usernameSuggestions || state.usernameSuggestions, - validationsSubmitState: DEFAULT_STATE, }; } case REGISTER_FORM_VALIDATIONS.FAILURE: @@ -114,7 +98,6 @@ const reducer = (state = defaultState, action = {}) => { ...state, validationApiRateLimited: true, validations: null, - validationsSubmitState: DEFAULT_STATE, }; case REGISTER_CLEAR_USERNAME_SUGGESTIONS: return { @@ -146,14 +129,6 @@ const reducer = (state = defaultState, action = {}) => { emailSuggestion: action.payload.emailSuggestion, }, }; - case REGISTER_SET_MULTI_STEP_REGISTRATION_EXP_DATA: { - return { - ...state, - multiStepRegExpVariation: action.payload.multiStepRegExpVariation, - multiStepRegistrationPageStep: action.payload.multiStepRegistrationPageStep, - isValidatingMultiStepRegistrationPage: !!action.payload?.isValidatingMultiStepRegistrationPage, - }; - } default: return { ...state, diff --git a/src/register/data/sagas.js b/src/register/data/sagas.js index 3edd395e..0325cd4b 100644 --- a/src/register/data/sagas.js +++ b/src/register/data/sagas.js @@ -40,13 +40,10 @@ export function* handleNewUserRegistration(action) { export function* fetchRealtimeValidations(action) { try { - yield put(fetchRealtimeValidationsBegin(action.payload?.isValidatingMultiStepRegistrationPage)); + yield put(fetchRealtimeValidationsBegin()); const { fieldValidations } = yield call(getFieldsValidations, action.payload.formPayload); - yield put(fetchRealtimeValidationsSuccess( - camelCaseObject(fieldValidations), - action.payload?.isValidatingMultiStepRegistrationPage, - )); + yield put(fetchRealtimeValidationsSuccess(camelCaseObject(fieldValidations))); } catch (e) { if (e.response && e.response.status === 403) { yield put(fetchRealtimeValidationsFailure()); diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index 909704d2..3e2270ab 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -11,7 +11,6 @@ import { REGISTER_SET_USER_PIPELINE_DATA_LOADED, REGISTRATION_CLEAR_BACKEND_ERROR, } from '../actions'; -import { FIRST_STEP } from '../optimizelyExperiment/helper'; import reducer from '../reducers'; describe('Registration Reducer Tests', () => { @@ -35,14 +34,10 @@ describe('Registration Reducer Tests', () => { }, validations: null, submitState: DEFAULT_STATE, - validationsSubmitState: DEFAULT_STATE, userPipelineDataLoaded: false, usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, - multiStepRegExpVariation: '', - multiStepRegistrationPageStep: FIRST_STEP, - isValidatingMultiStepRegistrationPage: false, }; it('should return the initial state', () => { diff --git a/src/register/data/utils.js b/src/register/data/utils.js index 25694527..e7773e2b 100644 --- a/src/register/data/utils.js +++ b/src/register/data/utils.js @@ -43,39 +43,31 @@ export const isFormValid = ( Object.keys(payload).forEach(key => { switch (key) { case 'name': - if (!fieldErrors.name) { - fieldErrors.name = validateName(payload.name, formatMessage); - } + fieldErrors.name = validateName(payload.name, formatMessage); if (fieldErrors.name) { isValid = false; } break; case 'email': { - if (!fieldErrors.email) { - const { - fieldError, confirmEmailError, suggestion, - } = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage); - if (fieldError) { - fieldErrors.email = fieldError; - isValid = false; - } - if (confirmEmailError) { - fieldErrors.confirm_email = confirmEmailError; - isValid = false; - } - emailSuggestion = suggestion; + const { + fieldError, confirmEmailError, suggestion, + } = validateEmail(payload.email, configurableFormFields?.confirm_email, formatMessage); + if (fieldError) { + fieldErrors.email = fieldError; + isValid = false; } + if (confirmEmailError) { + fieldErrors.confirm_email = confirmEmailError; + isValid = false; + } + emailSuggestion = suggestion; if (fieldErrors.email) { isValid = false; } break; } case 'username': - if (!fieldErrors.username) { - fieldErrors.username = validateUsername(payload.username, formatMessage); - } + fieldErrors.username = validateUsername(payload.username, formatMessage); if (fieldErrors.username) { isValid = false; } break; case 'password': - if (!fieldErrors.password) { - fieldErrors.password = validatePasswordField(payload.password, formatMessage); - } + fieldErrors.password = validatePasswordField(payload.password, formatMessage); if (fieldErrors.password) { isValid = false; } break; default: diff --git a/src/register/messages.jsx b/src/register/messages.jsx index cea5e5df..39d9e7f5 100644 --- a/src/register/messages.jsx +++ b/src/register/messages.jsx @@ -201,29 +201,6 @@ const messages = defineMessages({ defaultMessage: 'Did you mean', description: 'Did you mean alert suggestion', }, - // MultiStep Registration experiment - 'multistep.registration.exp.continue.button': { - id: 'multistep.registration.exp.continue.button', - defaultMessage: 'Continue', - description: 'Label text for multistep registration page second step', - }, - 'multistep.registration.username.second.step.guideline.content': { - id: 'multistep.registration.username.second.step.guideline.content', - defaultMessage: 'Finish Registration', - description: 'Guideline content for username field in multi-step registration experiment step 2', - }, - 'multistep.registration.username.third.step.guideline.content': { - id: 'multistep.registration.username.third.step.guideline.content', - defaultMessage: 'To finalize your registration, please confirm your country of residence ' - + 'and create a public username that will identify you in your course communication forums. ' - + 'The username cannot be changed.', - description: 'Guideline content for username field in multi-step registration experiment step 2', - }, - 'multistep.registration.form.submission.error': { - id: 'multistep.registration.form.submission.error', - defaultMessage: 'Please check your responses for this and the previous step and try again.', - description: 'Error message that appears on top of the form when invalid form is submitted', - }, }); export default messages; diff --git a/src/sass/_registration.scss b/src/sass/_registration.scss index 884af10d..75889601 100644 --- a/src/sass/_registration.scss +++ b/src/sass/_registration.scss @@ -2,10 +2,6 @@ min-width: 14.4rem; } -.continue-button { - min-width: 7rem; -} - .pgn__form-autosuggest__wrapper > .pgn__form-group { margin-bottom: 0 !important; } From ad0d75ab0df747e11973a72246e8808c2d85b916 Mon Sep 17 00:00:00 2001 From: Blue Date: Mon, 13 May 2024 14:11:03 +0500 Subject: [PATCH 36/82] feat: implement auto generated username experiment (#1248) * feat: implement auto generated username registration exp --- src/config/index.js | 1 + src/logistration/Logistration.test.jsx | 5 + src/register/RegistrationPage.jsx | 203 ++++++++++-------- src/register/RegistrationPage.test.jsx | 5 + .../ConfigurableRegistrationForm.test.jsx | 5 + .../tests/RegistrationFailure.test.jsx | 5 + .../components/tests/ThirdPartyAuth.test.jsx | 5 + src/register/data/actions.js | 7 + .../data/optimizelyExperiment/helper.js | 30 +++ ...utoGeneratedUsernameExperimentVariation.js | 53 +++++ src/register/data/reducers.js | 8 + src/register/data/tests/reducers.test.js | 1 + 12 files changed, 234 insertions(+), 94 deletions(-) create mode 100644 src/register/data/optimizelyExperiment/helper.js create mode 100644 src/register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation.js diff --git a/src/config/index.js b/src/config/index.js index badb6fe7..78cea882 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -35,6 +35,7 @@ const configuration = { ZENDESK_LOGO_URL: process.env.ZENDESK_LOGO_URL, ALGOLIA_APP_ID: process.env.ALGOLIA_APP_ID || '', ALGOLIA_SEARCH_API_KEY: process.env.ALGOLIA_SEARCH_API_KEY || '', + AUTO_GENERATED_USERNAME_EXPERIMENT_ID: process.env.AUTO_GENERATED_USERNAME_EXPERIMENT_ID || '', }; export default configuration; diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index 87bf3e70..b16ae216 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -15,12 +15,16 @@ import { } from '../data/constants'; import { backupLoginForm } from '../login/data/actions'; import { backupRegistrationForm } from '../register/data/actions'; +import { NOT_INITIALIZED } from '../register/data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from '../register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); +jest.mock('../register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const mockStore = configureStore(); const IntlLogistration = injectIntl(Logistration); @@ -84,6 +88,7 @@ describe('Logistration', () => { })), })); + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); configure({ loggingService: { logError: jest.fn() }, config: { diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 8c5ea79f..22f12e14 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -18,6 +18,7 @@ import { backupRegistrationFormBegin, clearRegistrationBackendError, registerNewUser, + setAutoGeneratedUsernameExperimentData, setEmailSuggestionInStore, setUserPipelineDataLoaded, } from './data/actions'; @@ -25,6 +26,8 @@ import { FORM_SUBMISSION_ERROR, TPA_AUTHENTICATION_FAILURE, } from './data/constants'; +import { AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION, NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import getBackendValidations from './data/selectors'; import { isFormValid, prepareRegistrationPayload, @@ -68,6 +71,7 @@ const RegistrationPage = (props) => { } = props; const backedUpFormData = useSelector(state => state.register.registrationFormData); + const initExpVariation = useSelector(state => state.register.autoGeneratedUsernameExperimentVariation); const registrationError = useSelector(state => state.register.registrationError); const registrationErrorCode = registrationError?.errorCode; const registrationResult = useSelector(state => state.register.registrationResult); @@ -103,6 +107,12 @@ const RegistrationPage = (props) => { ? formatMessage(messages['create.account.cta.button'], { label: cta }) : formatMessage(messages['create.account.for.free.button']); + const autoGeneratedUsernameExpVariation = useAutoGeneratedUsernameExperimentVariation( + initExpVariation, registrationEmbedded, tpaHint, currentProvider, thirdPartyAuthApiStatus, + ); + + const hideUsernameField = flags.autoGeneratedUsernameEnabled + || autoGeneratedUsernameExpVariation === AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION; /** * Set the userPipelineDetails data in formFields for only first time */ @@ -149,8 +159,10 @@ const RegistrationPage = (props) => { formFields: { ...formFields }, errors: { ...errors }, })); + dispatch(setAutoGeneratedUsernameExperimentData(autoGeneratedUsernameExpVariation)); } - }, [shouldBackupState, configurableFormFields, formFields, errors, dispatch, backedUpFormData]); + }, [shouldBackupState, configurableFormFields, // eslint-disable-line react-hooks/exhaustive-deps + formFields, errors, dispatch, backedUpFormData]); useEffect(() => { if (backendValidations) { @@ -216,7 +228,7 @@ const RegistrationPage = (props) => { delete payload.password; payload.social_auth_provider = currentProvider; } - if (flags.autoGeneratedUsernameEnabled) { + if (hideUsernameField) { delete payload.username; } @@ -286,106 +298,109 @@ const RegistrationPage = (props) => { getConfig().ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN && !!Object.keys(optionalFields.fields).length } /> - {autoSubmitRegForm && !errorCode.type ? ( -
- -
- ) : ( -
- - -
- + +
+ ) : ( +
+ - - {!flags.autoGeneratedUsernameEnabled && ( - + - )} - {!currentProvider && ( - - )} - - e.preventDefault()} - /> - {!registrationEmbedded && ( - + )} + {!currentProvider && ( + + )} + - )} - -
- )} - + e.preventDefault()} + /> + {!registrationEmbedded && ( + + )} + + + )} ); }; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 18e4e513..bec3ec60 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -17,6 +17,9 @@ import { setUserPipelineDataLoaded, } from './data/actions'; import { INTERNAL_SERVER_ERROR } from './data/constants'; +import { NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from './RegistrationPage'; import { AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, @@ -30,6 +33,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('./data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -128,6 +132,7 @@ describe('RegistrationPage', () => { institutionLogin: false, }; window.location = { search: '' }; + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 3d664260..88d64253 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -11,6 +11,9 @@ import configureStore from 'redux-mock-store'; import { registerNewUser } from '../../data/actions'; import { FIELDS } from '../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from '../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import ConfigurableRegistrationForm from '../ConfigurableRegistrationForm'; @@ -22,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const IntlConfigurableRegistrationForm = injectIntl(ConfigurableRegistrationForm); const IntlRegistrationPage = injectIntl(RegistrationPage); @@ -121,6 +125,7 @@ describe('ConfigurableRegistrationForm', () => { }; window.location = { search: '' }; getLocale.mockImplementationOnce(() => ('en-us')); + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/RegistrationFailure.test.jsx b/src/register/components/tests/RegistrationFailure.test.jsx index 003cc966..da8867cd 100644 --- a/src/register/components/tests/RegistrationFailure.test.jsx +++ b/src/register/components/tests/RegistrationFailure.test.jsx @@ -12,6 +12,9 @@ import configureStore from 'redux-mock-store'; import { FORBIDDEN_REQUEST, INTERNAL_SERVER_ERROR, TPA_AUTHENTICATION_FAILURE, TPA_SESSION_EXPIRED, } from '../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from '../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; import RegistrationFailureMessage from '../RegistrationFailure'; @@ -23,6 +26,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const IntlRegistrationFailure = injectIntl(RegistrationFailureMessage); @@ -121,6 +125,7 @@ describe('RegistrationFailure', () => { institutionLogin: false, }; window.location = { search: '' }; + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/components/tests/ThirdPartyAuth.test.jsx b/src/register/components/tests/ThirdPartyAuth.test.jsx index 917f10f9..7706d185 100644 --- a/src/register/components/tests/ThirdPartyAuth.test.jsx +++ b/src/register/components/tests/ThirdPartyAuth.test.jsx @@ -12,6 +12,9 @@ import configureStore from 'redux-mock-store'; import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, } from '../../../data/constants'; +import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; +import useAutoGeneratedUsernameExperimentVariation + from '../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from '../../RegistrationPage'; jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -22,6 +25,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ ...jest.requireActual('@edx/frontend-platform/i18n'), getLocale: jest.fn(), })); +jest.mock('../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -120,6 +124,7 @@ describe('ThirdPartyAuth', () => { institutionLogin: false, }; window.location = { search: '' }; + useAutoGeneratedUsernameExperimentVariation.mockReturnValue(NOT_INITIALIZED); }); afterEach(() => { diff --git a/src/register/data/actions.js b/src/register/data/actions.js index 68bfe9e6..2c122515 100644 --- a/src/register/data/actions.js +++ b/src/register/data/actions.js @@ -8,6 +8,7 @@ export const REGISTRATION_CLEAR_BACKEND_ERROR = 'REGISTRATION_CLEAR_BACKEND_ERRO export const REGISTER_SET_COUNTRY_CODE = 'REGISTER_SET_COUNTRY_CODE'; export const REGISTER_SET_USER_PIPELINE_DATA_LOADED = 'REGISTER_SET_USER_PIPELINE_DATA_LOADED'; export const REGISTER_SET_EMAIL_SUGGESTIONS = 'REGISTER_SET_EMAIL_SUGGESTIONS'; +export const REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA = 'REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA'; // Backup registration form export const backupRegistrationForm = () => ({ type: BACKUP_REGISTRATION_DATA.BASE, @@ -82,3 +83,9 @@ export const setUserPipelineDataLoaded = (value) => ({ type: REGISTER_SET_USER_PIPELINE_DATA_LOADED, payload: { value }, }); + +// Auto Generated Username Registration Experiment Actions +export const setAutoGeneratedUsernameExperimentData = (autoGeneratedRegExpVariation) => ({ + type: REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA, + payload: { autoGeneratedRegExpVariation }, +}); diff --git a/src/register/data/optimizelyExperiment/helper.js b/src/register/data/optimizelyExperiment/helper.js new file mode 100644 index 00000000..0cc54183 --- /dev/null +++ b/src/register/data/optimizelyExperiment/helper.js @@ -0,0 +1,30 @@ +/** + * This file contains data for auto generated username Optimizely experiment + */ +import { getConfig } from '@edx/frontend-platform'; + +export const NOT_INITIALIZED = 'experiment-not-initialized'; +export const CONTROL = 'control-registration-page'; +export const AUTO_GENERATED_USERNAME_REGISTRATION_EXP_VARIATION = 'auto-generated-username-register-page'; +const AUTO_GENERATED_USERNAME_EXP_PAGE = 'targeting_for_auto_generated_username_page'; + +export function getAutoGeneratedUsernameExperimentVariation() { + try { + if (window.optimizely + && window.optimizely.get('data').experiments[getConfig().AUTO_GENERATED_USERNAME_EXPERIMENT_ID]) { + const selectedVariant = window.optimizely.get('state').getVariationMap()[ + getConfig().AUTO_GENERATED_USERNAME_EXPERIMENT_ID + ]; + return selectedVariant?.name; + } + } catch (e) { /* empty */ } + return ''; +} + +export function activateAutoGeneratedUsernameExperiment() { + window.optimizely = window.optimizely || []; + window.optimizely.push({ + type: 'page', + pageName: AUTO_GENERATED_USERNAME_EXP_PAGE, + }); +} diff --git a/src/register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation.js b/src/register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation.js new file mode 100644 index 00000000..5442a652 --- /dev/null +++ b/src/register/data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation.js @@ -0,0 +1,53 @@ +import { useEffect, useState } from 'react'; + +import { + activateAutoGeneratedUsernameExperiment, + getAutoGeneratedUsernameExperimentVariation, + NOT_INITIALIZED, +} from './helper'; +import { COMPLETE_STATE } from '../../../data/constants'; + +/** + * This hook returns activates multi step registration experiment and returns the experiment + * variation for the user. + */ +const useAutoGeneratedUsernameExperimentVariation = ( + initExpVariation, + registrationEmbedded, + tpaHint, + currentProvider, + thirdPartyAuthApiStatus, +) => { + const [variation, setVariation] = useState(initExpVariation); + useEffect(() => { + if (initExpVariation || registrationEmbedded || !!tpaHint || !!currentProvider + || thirdPartyAuthApiStatus !== COMPLETE_STATE) { + return variation; + } + + const getVariation = () => { + const expVariation = getAutoGeneratedUsernameExperimentVariation(); + if (expVariation) { + setVariation(expVariation); + } else { + // This is to handle the case when user dont get variation for some reason, the register page + // shows unlimited spinner. + setVariation(NOT_INITIALIZED); + } + }; + + activateAutoGeneratedUsernameExperiment(); + + const timer = setTimeout(getVariation, 300); + + return () => { + clearTimeout(timer); + }; + }, [ // eslint-disable-line react-hooks/exhaustive-deps + initExpVariation, currentProvider, registrationEmbedded, thirdPartyAuthApiStatus, tpaHint, + ]); + + return variation; +}; + +export default useAutoGeneratedUsernameExperimentVariation; diff --git a/src/register/data/reducers.js b/src/register/data/reducers.js index 70c3a994..d8a822df 100644 --- a/src/register/data/reducers.js +++ b/src/register/data/reducers.js @@ -3,6 +3,7 @@ import { REGISTER_CLEAR_USERNAME_SUGGESTIONS, REGISTER_FORM_VALIDATIONS, REGISTER_NEW_USER, + REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA, REGISTER_SET_COUNTRY_CODE, REGISTER_SET_EMAIL_SUGGESTIONS, REGISTER_SET_USER_PIPELINE_DATA_LOADED, @@ -39,6 +40,7 @@ export const defaultState = { usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + autoGeneratedUsernameExperimentVariation: '', }; const reducer = (state = defaultState, action = {}) => { @@ -55,6 +57,12 @@ const reducer = (state = defaultState, action = {}) => { registrationFormData: { ...action.payload }, userPipelineDataLoaded: state.userPipelineDataLoaded, }; + case REGISTER_SET_AUTO_GENERATED_USERNAME_REGISTRATION_EXP_DATA: { + return { + ...state, + autoGeneratedUsernameExperimentVariation: action.payload.autoGeneratedRegExpVariation, + }; + } case REGISTER_NEW_USER.BEGIN: return { ...state, diff --git a/src/register/data/tests/reducers.test.js b/src/register/data/tests/reducers.test.js index 3e2270ab..39fa4cee 100644 --- a/src/register/data/tests/reducers.test.js +++ b/src/register/data/tests/reducers.test.js @@ -38,6 +38,7 @@ describe('Registration Reducer Tests', () => { usernameSuggestions: [], validationApiRateLimited: false, shouldBackupState: false, + autoGeneratedUsernameExperimentVariation: '', }; it('should return the initial state', () => { From 1819edc9b793fa8725d1a154d27c33faf25a7bd1 Mon Sep 17 00:00:00 2001 From: Blue Date: Tue, 4 Jun 2024 16:27:14 +0500 Subject: [PATCH 37/82] feat: add page event for reset password (#1253) Description: Add page event for reset password page VAN-1929 --- src/forgot-password/ForgotPasswordPage.jsx | 2 +- src/reset-password/ResetPasswordPage.jsx | 5 +++++ src/reset-password/tests/ResetPasswordPage.test.jsx | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/forgot-password/ForgotPasswordPage.jsx b/src/forgot-password/ForgotPasswordPage.jsx index ff381340..f4c7b314 100644 --- a/src/forgot-password/ForgotPasswordPage.jsx +++ b/src/forgot-password/ForgotPasswordPage.jsx @@ -41,7 +41,7 @@ const ForgotPasswordPage = (props) => { const navigate = useNavigate(); useEffect(() => { - sendPageEvent('login_and_registration', 'reset'); + sendPageEvent('login_and_registration', 'forgot-password'); sendTrackEvent('edx.bi.password_reset_form.viewed', { category: 'user-engagement' }); }, []); diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/reset-password/ResetPasswordPage.jsx index 9b4b6758..0393d279 100644 --- a/src/reset-password/ResetPasswordPage.jsx +++ b/src/reset-password/ResetPasswordPage.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; +import { sendPageEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, @@ -42,6 +43,10 @@ const ResetPasswordPage = (props) => { const { token } = useParams(); const navigate = useNavigate(); + useEffect(() => { + sendPageEvent('login_and_registration', 'reset-password'); + }, []); + useEffect(() => { if (props.status !== TOKEN_STATE.PENDING && props.status !== PASSWORD_RESET_ERROR) { setErrorCode(props.status); diff --git a/src/reset-password/tests/ResetPasswordPage.test.jsx b/src/reset-password/tests/ResetPasswordPage.test.jsx index 421edc9e..0c9ba1a2 100644 --- a/src/reset-password/tests/ResetPasswordPage.test.jsx +++ b/src/reset-password/tests/ResetPasswordPage.test.jsx @@ -19,6 +19,10 @@ import ResetPasswordPage from '../ResetPasswordPage'; const mockedNavigator = jest.fn(); const token = '1c-bmjdkc-5e60e084cf8113048ca7'; +jest.mock('@edx/frontend-platform/analytics', () => ({ + sendPageEvent: jest.fn(), +})); + jest.mock('@edx/frontend-platform/auth'); jest.mock('react-router-dom', () => ({ ...(jest.requireActual('react-router-dom')), From 739f94d624e22c359453f2b79500a84c1df320d1 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Fri, 7 Jun 2024 07:57:42 +0500 Subject: [PATCH 38/82] Update 2u-main with master (#1254) * feat: Hide preloaders for third party auth providers if they are disabled * feat: remove username from the registration from (#1201) (#1241) Co-authored-by: Attiya Ishaque * fix: add new entry for another US label (#1244) Add new entry for for another US label which is United States * feat: implement multi step registration experiment Rebase 2u main with master (#1228) * chore(deps): update dependency babel-plugin-formatjs to v10.5.14 * fix(deps): update dependency @edx/frontend-platform to v7.1.3 * fix(deps): update font awesome to v6.5.2 * chore(deps): update dependency @openedx/frontend-build to v13.1.4 * fix(deps): update dependency @openedx/paragon to v22.2.1 * fix(deps): update dependency algoliasearch to v4.23.3 * fix(deps): update dependency algoliasearch-helper to v3.17.0 --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> * feat: add multi step registration eventing (#1226) * feat: implement multi step registration experiment * feat: add multi step registration eventing * fix: fix register button width * fix: fix register button loader for control * feat: capture marketing lead in experiment events (#1243) * revert: multistep registration experiment revert multistep registration experiment changes VAN-1930 * feat: implement auto generated username experiment (#1248) * feat: implement auto generated username registration exp * feat: add page event for reset password (#1253) Description: Add page event for reset password page VAN-1929 --------- Co-authored-by: Stanislav Lunyachek Co-authored-by: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Co-authored-by: Attiya Ishaque Co-authored-by: Blue Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Syed Sajjad Hussain Shah From 4898864416b0fdeb97f79a9907f1b76ab862e89b Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Tue, 11 Jun 2024 12:01:30 +0500 Subject: [PATCH 39/82] feat: hard code fields on frontend (#1256) * feat: hard code fields hard code configurable fields on frontend which includes country field on register page & level of education & gender field on progressive profiling VAN-1971 * fix: fix secondary provider null name issue --- .../InstitutionLogistration.jsx | 2 +- src/common-components/data/constants.js | 79 +++++++++++++++++++ src/common-components/data/sagas.js | 13 ++- src/config/index.js | 1 + 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/common-components/data/constants.js diff --git a/src/common-components/InstitutionLogistration.jsx b/src/common-components/InstitutionLogistration.jsx index b773bc6e..ba26cf3c 100644 --- a/src/common-components/InstitutionLogistration.jsx +++ b/src/common-components/InstitutionLogistration.jsx @@ -60,7 +60,7 @@ const InstitutionLogistration = props => { className="btn nav-item p-0 mb-1 institutions--provider-link" destination={lmsBaseUrl + provider.loginUrl} > - {provider.name} + {provider?.name} diff --git a/src/common-components/data/constants.js b/src/common-components/data/constants.js new file mode 100644 index 00000000..4f9ec72c --- /dev/null +++ b/src/common-components/data/constants.js @@ -0,0 +1,79 @@ +export const registerFields = { + fields: { + country: { + name: 'country', + error_message: 'Select your country or region of residence', + }, + honor_code: { + name: 'honor_code', + type: 'tos_and_honor_code', + error_message: '', + }, + }, +}; + +export const progressiveProfilingFields = { + extended_profile: [], + fields: { + level_of_education: { + name: 'level_of_education', + type: 'select', + label: 'Highest level of education completed', + error_message: '', + options: [ + [ + 'p', + 'Doctorate', + ], + [ + 'm', + "Master's or professional degree", + ], + [ + 'b', + "Bachelor's degree", + ], + [ + 'a', + 'Associate degree', + ], + [ + 'hs', + 'Secondary/high school', + ], + [ + 'jhs', + 'Junior secondary/junior high/middle school', + ], + [ + 'none', + 'No formal education', + ], + [ + 'other', + 'Other education', + ], + ], + }, + gender: { + name: 'gender', + type: 'select', + label: 'Gender', + error_message: '', + options: [ + [ + 'm', + 'Male', + ], + [ + 'f', + 'Female', + ], + [ + 'o', + 'Other/Prefer Not to Say', + ], + ], + }, + }, +}; diff --git a/src/common-components/data/sagas.js b/src/common-components/data/sagas.js index ffe0be37..65105866 100644 --- a/src/common-components/data/sagas.js +++ b/src/common-components/data/sagas.js @@ -1,3 +1,4 @@ +import { getConfig } from '@edx/frontend-platform'; import { logError } from '@edx/frontend-platform/logging'; import { call, put, takeEvery } from 'redux-saga/effects'; @@ -7,6 +8,7 @@ import { getThirdPartyAuthContextSuccess, THIRD_PARTY_AUTH_CONTEXT, } from './actions'; +import { progressiveProfilingFields, registerFields } from './constants'; import { getThirdPartyAuthContext, } from './service'; @@ -20,7 +22,16 @@ export function* fetchThirdPartyAuthContext(action) { } = yield call(getThirdPartyAuthContext, action.payload.urlParams); yield put(setCountryFromThirdPartyAuthContext(thirdPartyAuthContext.countryCode)); - yield put(getThirdPartyAuthContextSuccess(fieldDescriptions, optionalFields, thirdPartyAuthContext)); + // hard code country field, level of education and gender fields + if (getConfig().ENABLE_HARD_CODE_OPTIONAL_FIELDS) { + yield put(getThirdPartyAuthContextSuccess( + registerFields, + progressiveProfilingFields, + thirdPartyAuthContext, + )); + } else { + yield put(getThirdPartyAuthContextSuccess(fieldDescriptions, optionalFields, thirdPartyAuthContext)); + } } catch (e) { yield put(getThirdPartyAuthContextFailure()); logError(e); diff --git a/src/config/index.js b/src/config/index.js index 78cea882..6399b549 100644 --- a/src/config/index.js +++ b/src/config/index.js @@ -11,6 +11,7 @@ const configuration = { MARKETING_EMAILS_OPT_IN: process.env.MARKETING_EMAILS_OPT_IN || '', SHOW_CONFIGURABLE_EDX_FIELDS: process.env.SHOW_CONFIGURABLE_EDX_FIELDS || false, SHOW_REGISTRATION_LINKS: process.env.SHOW_REGISTRATION_LINKS !== 'false', + ENABLE_HARD_CODE_OPTIONAL_FIELDS: process.env.ENABLE_HARD_CODE_OPTIONAL_FIELDS || false, ENABLE_IMAGE_LAYOUT: process.env.ENABLE_IMAGE_LAYOUT || false, // Links ACTIVATION_EMAIL_SUPPORT_LINK: process.env.ACTIVATION_EMAIL_SUPPORT_LINK || null, From afd4d243604b20676c7e2935ffe69973e1bcbb70 Mon Sep 17 00:00:00 2001 From: Muhammad Abdullah Waheed <42172960+abdullahwaheed@users.noreply.github.com> Date: Wed, 3 Jul 2024 17:08:44 +0500 Subject: [PATCH 40/82] feat: added app name identifier in segment events (#1277) * feat: added app name identifier in registration call * feat: added utils for tracking events * refactor: mapped login events * refactor: mapped forgot password events * refactor: mapped reset password events * refactor: mapped register events * fix: fixed unit tests * refactor: mapped progressive prifiling events * fix: fixed unit tests * refactor: added app name in logistration events * refactor: resolved PR reviews and fixed tests --- src/data/constants.js | 1 + src/data/segment/utils.js | 37 ++++++++++++++++ src/forgot-password/ForgotPasswordPage.jsx | 9 ++-- src/login/LoginPage.jsx | 15 ++++--- src/login/tests/LoginPage.test.jsx | 8 ++-- src/logistration/Logistration.jsx | 10 ++--- src/logistration/Logistration.test.jsx | 5 ++- .../ProgressiveProfiling.jsx | 44 +++++++++---------- .../tests/ProgressiveProfiling.test.jsx | 10 +++-- src/register/RegistrationPage.jsx | 10 ++--- src/register/RegistrationPage.test.jsx | 10 +++-- .../ConfigurableRegistrationForm.test.jsx | 3 +- src/reset-password/ResetPasswordPage.jsx | 15 ++++--- .../tests/ResetPasswordPage.test.jsx | 1 + src/tracking/trackers/forgotpassword.js | 22 ++++++++++ src/tracking/trackers/login.js | 29 ++++++++++++ .../trackers/progressive-profiling.js | 37 ++++++++++++++++ src/tracking/trackers/register.js | 22 ++++++++++ src/tracking/trackers/reset-password.js | 14 ++++++ .../trackers/tests/forgot-password.test.jsx | 37 ++++++++++++++++ src/tracking/trackers/tests/login.test.jsx | 37 ++++++++++++++++ .../tests/progressive-profiling.test.jsx | 37 ++++++++++++++++ src/tracking/trackers/tests/register.test.jsx | 36 +++++++++++++++ .../trackers/tests/reset-password.test.jsx | 26 +++++++++++ 24 files changed, 418 insertions(+), 57 deletions(-) create mode 100644 src/data/segment/utils.js create mode 100644 src/tracking/trackers/forgotpassword.js create mode 100644 src/tracking/trackers/login.js create mode 100644 src/tracking/trackers/progressive-profiling.js create mode 100644 src/tracking/trackers/register.js create mode 100644 src/tracking/trackers/reset-password.js create mode 100644 src/tracking/trackers/tests/forgot-password.test.jsx create mode 100644 src/tracking/trackers/tests/login.test.jsx create mode 100644 src/tracking/trackers/tests/progressive-profiling.test.jsx create mode 100644 src/tracking/trackers/tests/register.test.jsx create mode 100644 src/tracking/trackers/tests/reset-password.test.jsx diff --git a/src/data/constants.js b/src/data/constants.js index 90fdf75f..5adf4382 100644 --- a/src/data/constants.js +++ b/src/data/constants.js @@ -37,3 +37,4 @@ export const VALID_EMAIL_REGEX = '(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+ // things like auto-enrollment upon login and registration. export const AUTH_PARAMS = ['course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow', 'next', 'register_for_free', 'track', 'is_account_recovery', 'variant', 'host', 'cta']; export const REDIRECT = 'redirect'; +export const APP_NAME = 'authn_mfe'; diff --git a/src/data/segment/utils.js b/src/data/segment/utils.js new file mode 100644 index 00000000..fa84443d --- /dev/null +++ b/src/data/segment/utils.js @@ -0,0 +1,37 @@ +/* eslint-disable import/prefer-default-export */ +import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; + +import { APP_NAME } from '../constants'; + +export const LINK_TIMEOUT = 300; + +/** + * Creates an event tracker function that sends a tracking event with the given name and options. + * + * @param {string} name - The name of the event to be tracked. + * @param {object} [options={}] - Additional options to be included with the event. + * @returns {function} - A function that, when called, sends the tracking event. + */ +export const createEventTracker = (name, options = {}) => () => sendTrackEvent( + name, + { ...options, app_name: APP_NAME }, +); + +/** + * Creates an event tracker function that sends a tracking event with the given name and options. + * + * @param {string} name - The name of the event to be tracked. + * @param {object} [options={}] - Additional options to be included with the event. + * @returns {function} - A function that, when called, sends the tracking event. + */ +export const createPageEventTracker = (name, options = null) => () => sendPageEvent( + name, + options, + { app_name: APP_NAME }, +); + +export const createLinkTracker = (tracker, href) => (e) => { + e.preventDefault(); + tracker(); + return setTimeout(() => { window.location.href = href; }, LINK_TIMEOUT); +}; diff --git a/src/forgot-password/ForgotPasswordPage.jsx b/src/forgot-password/ForgotPasswordPage.jsx index f4c7b314..fad31300 100644 --- a/src/forgot-password/ForgotPasswordPage.jsx +++ b/src/forgot-password/ForgotPasswordPage.jsx @@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; -import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, @@ -25,6 +24,10 @@ import BaseContainer from '../base-container'; import { FormGroup } from '../common-components'; import { DEFAULT_STATE, LOGIN_PAGE, VALID_EMAIL_REGEX } from '../data/constants'; import { updatePathWithQueryParams, windowScrollTo } from '../data/utils'; +import { + trackForgotPasswordPageEvent, + trackForgotPasswordPageViewed, +} from '../tracking/trackers/forgotpassword'; const ForgotPasswordPage = (props) => { const platformName = getConfig().SITE_NAME; @@ -41,8 +44,8 @@ const ForgotPasswordPage = (props) => { const navigate = useNavigate(); useEffect(() => { - sendPageEvent('login_and_registration', 'forgot-password'); - sendTrackEvent('edx.bi.password_reset_form.viewed', { category: 'user-engagement' }); + trackForgotPasswordPageEvent(); + trackForgotPasswordPageViewed(); }, []); useEffect(() => { diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 62815726..0ff25258 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -2,7 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; -import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; import { injectIntl, useIntl } from '@edx/frontend-platform/i18n'; import { Form, StatefulButton, @@ -43,6 +42,9 @@ import { updatePathWithQueryParams, } from '../data/utils'; import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess'; +import { + trackForgotPasswordLinkClick, trackLoginPageViewed, trackLoginSuccess, +} from '../tracking/trackers/login'; const LoginPage = (props) => { const { @@ -78,9 +80,15 @@ const LoginPage = (props) => { const tpaHint = getTpaHint(); useEffect(() => { - sendPageEvent('login_and_registration', 'login'); + trackLoginPageViewed(); }, []); + useEffect(() => { + if (loginResult.success) { + trackLoginSuccess(); + } + }, [loginResult]); + useEffect(() => { const payload = { ...queryParams }; if (tpaHint) { @@ -170,9 +178,6 @@ const LoginPage = (props) => { const { name } = event.target; setErrors(prevErrors => ({ ...prevErrors, [name]: '' })); }; - const trackForgotPasswordLinkClick = () => { - sendTrackEvent('edx.bi.password-reset_form.toggled', { category: 'user-engagement' }); - }; const { provider, skipHintedLogin } = getTpaProvider(tpaHint, providers, secondaryProviders); diff --git a/src/login/tests/LoginPage.test.jsx b/src/login/tests/LoginPage.test.jsx index 9c337bf2..38778b8c 100644 --- a/src/login/tests/LoginPage.test.jsx +++ b/src/login/tests/LoginPage.test.jsx @@ -11,7 +11,9 @@ import { act } from 'react-dom/test-utils'; import { MemoryRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; -import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE } from '../../data/constants'; +import { + APP_NAME, COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, +} from '../../data/constants'; import { backupLoginFormBegin, dismissPasswordResetBanner, loginRequest } from '../data/actions'; import { INTERNAL_SERVER_ERROR } from '../data/constants'; import LoginPage from '../LoginPage'; @@ -751,7 +753,7 @@ describe('LoginPage', () => { it('should send page event when login page is rendered', () => { render(reduxWrapper()); - expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'login'); + expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'login', { app_name: APP_NAME }); }); it('tests that form is in invalid state when it is submitted', () => { @@ -784,7 +786,7 @@ describe('LoginPage', () => { { selector: '#forgot-password' }, )); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.password-reset_form.toggled', { category: 'user-engagement' }); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.password-reset_form.toggled', { category: 'user-engagement', app_name: APP_NAME }); }); it('should backup the login form state when shouldBackupState is true', () => { diff --git a/src/logistration/Logistration.jsx b/src/logistration/Logistration.jsx index 9451aa27..1a65b259 100644 --- a/src/logistration/Logistration.jsx +++ b/src/logistration/Logistration.jsx @@ -20,7 +20,7 @@ import { tpaProvidersSelector, } from '../common-components/data/selectors'; import messages from '../common-components/messages'; -import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; +import { APP_NAME, LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; import { getTpaHint, getTpaProvider, updatePathWithQueryParams, } from '../data/utils'; @@ -56,11 +56,11 @@ const Logistration = (props) => { }, [navigate, disablePublicAccountCreation]); const handleInstitutionLogin = (e) => { - sendTrackEvent('edx.bi.institution_login_form.toggled', { category: 'user-engagement' }); + sendTrackEvent('edx.bi.institution_login_form.toggled', { category: 'user-engagement', app_name: APP_NAME }); if (typeof e === 'string') { - sendPageEvent('login_and_registration', e === '/login' ? 'login' : 'register'); + sendPageEvent('login_and_registration', e === '/login' ? 'login' : 'register', { app_name: APP_NAME }); } else { - sendPageEvent('login_and_registration', e.target.dataset.eventName); + sendPageEvent('login_and_registration', e.target.dataset.eventName, { app_name: APP_NAME }); } setInstitutionLogin(!institutionLogin); @@ -70,7 +70,7 @@ const Logistration = (props) => { if (tabKey === currentTab) { return; } - sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement' }); + sendTrackEvent(`edx.bi.${tabKey.replace('/', '')}_form.toggled`, { category: 'user-engagement', app_name: APP_NAME }); props.clearThirdPartyAuthContextErrorMessage(); if (tabKey === LOGIN_PAGE) { props.backupRegistrationForm(); diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index b16ae216..b4b0d1e9 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -11,6 +11,7 @@ import configureStore from 'redux-mock-store'; import Logistration from './Logistration'; import { clearThirdPartyAuthContextErrorMessage } from '../common-components/data/actions'; import { + APP_NAME, COMPLETE_STATE, LOGIN_PAGE, REGISTER_PAGE, } from '../data/constants'; import { backupLoginForm } from '../login/data/actions'; @@ -229,8 +230,8 @@ describe('Logistration', () => { render(reduxWrapper()); fireEvent.click(screen.getByText('Institution/campus credentials')); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement' }); - expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login'); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.institution_login_form.toggled', { category: 'user-engagement', app_name: APP_NAME }); + expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'institution_login', { app_name: APP_NAME }); mergeConfig({ DISABLE_ENTERPRISE_LOGIN: '', diff --git a/src/progressive-profiling/ProgressiveProfiling.jsx b/src/progressive-profiling/ProgressiveProfiling.jsx index d42e3d52..55b96462 100644 --- a/src/progressive-profiling/ProgressiveProfiling.jsx +++ b/src/progressive-profiling/ProgressiveProfiling.jsx @@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig, snakeCaseObject } from '@edx/frontend-platform'; -import { identifyAuthenticatedUser, sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; +import { identifyAuthenticatedUser } from '@edx/frontend-platform/analytics'; import { AxiosJwtAuthService, configure as configureAuth, @@ -39,6 +39,13 @@ import { import isOneTrustFunctionalCookieEnabled from '../data/oneTrust'; import { getAllPossibleQueryParams, isHostAvailableInQueryParams } from '../data/utils'; import { FormFieldRenderer } from '../field-renderer'; +import { + trackDisablePostRegistrationRecommendations, + trackProgressiveProfilingPageViewed, + trackProgressiveProfilingSkipLinkClick, + trackProgressiveProfilingSubmitClick, + trackProgressiveProfilingSupportLinkCLick, +} from '../tracking/trackers/progressive-profiling'; const ProgressiveProfiling = (props) => { const { formatMessage } = useIntl(); @@ -98,14 +105,13 @@ const ProgressiveProfiling = (props) => { useEffect(() => { if (authenticatedUser?.userId) { identifyAuthenticatedUser(authenticatedUser.userId); - sendPageEvent('login_and_registration', 'welcome'); + trackProgressiveProfilingPageViewed(); } }, [authenticatedUser]); useEffect(() => { if (!enablePostRegistrationRecommendations) { - sendTrackEvent( - 'edx.bi.user.recommendations.not.enabled', + trackDisablePostRegistrationRecommendations( { functionalCookiesConsent, page: 'authn_recommendations' }, ); return; @@ -149,29 +155,23 @@ const ProgressiveProfiling = (props) => { }); } props.saveUserProfile(authenticatedUser.username, snakeCaseObject(payload)); - - sendTrackEvent( - 'edx.bi.welcome.page.submit.clicked', - { - isGenderSelected: !!values.gender, - isYearOfBirthSelected: !!values.year_of_birth, - isLevelOfEducationSelected: !!values.level_of_education, - isWorkExperienceSelected: !!values.work_experience, - host: queryParams?.host || '', - }, - ); + const eventProperties = { + isGenderSelected: !!values.gender, + isYearOfBirthSelected: !!values.year_of_birth, + isLevelOfEducationSelected: !!values.level_of_education, + isWorkExperienceSelected: !!values.work_experience, + host: queryParams?.host || '', + }; + trackProgressiveProfilingSubmitClick(eventProperties); }; const handleSkip = (e) => { e.preventDefault(); window.history.replaceState(location.state, null, ''); setShowModal(true); - sendTrackEvent( - 'edx.bi.welcome.page.skip.link.clicked', - { - host: queryParams?.host || '', - }, - ); + trackProgressiveProfilingSkipLinkClick({ + host: queryParams?.host || '', + }); }; const onChangeHandler = (e) => { @@ -242,7 +242,7 @@ const ProgressiveProfiling = (props) => { destination={getConfig().AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK} target="_blank" showLaunchIcon={false} - onClick={() => (sendTrackEvent('edx.bi.welcome.page.support.link.clicked'))} + onClick={() => (trackProgressiveProfilingSupportLinkCLick())} > {formatMessage(messages['optional.fields.information.link'])} diff --git a/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx b/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx index 9bc4439c..c5786b2e 100644 --- a/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx +++ b/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx @@ -12,6 +12,7 @@ import { MemoryRouter, mockNavigate, useLocation } from 'react-router-dom'; import configureStore from 'redux-mock-store'; import { + APP_NAME, AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, DEFAULT_REDIRECT_URL, EMBEDDED, @@ -143,8 +144,9 @@ describe('ProgressiveProfilingTests', () => { const modalContentContainer = document.getElementsByClassName('.pgn__modal-content-container'); expect(modalContentContainer).toBeTruthy(); + const payload = { host: '', app_name: APP_NAME }; - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host: '' }); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', payload); }); // ******** test event functionality ******** @@ -165,7 +167,7 @@ describe('ProgressiveProfilingTests', () => { const supportLink = screen.getByRole('link', { name: /learn more about how we use this information/i }); fireEvent.click(supportLink); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.support.link.clicked'); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.support.link.clicked', { app_name: APP_NAME }); }); it('should set empty host property value for non-embedded experience', () => { @@ -175,6 +177,7 @@ describe('ProgressiveProfilingTests', () => { isLevelOfEducationSelected: false, isWorkExperienceSelected: false, host: '', + app_name: APP_NAME, }; delete window.location; window.location = { href: getConfig().BASE_URL.concat(AUTHN_PROGRESSIVE_PROFILING) }; @@ -316,7 +319,7 @@ describe('ProgressiveProfilingTests', () => { const skipLinkButton = screen.getByText('Skip for now'); fireEvent.click(skipLinkButton); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host }); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.welcome.page.skip.link.clicked', { host, app_name: APP_NAME }); }); it('should show spinner while fetching the optional fields', () => { @@ -349,6 +352,7 @@ describe('ProgressiveProfilingTests', () => { isLevelOfEducationSelected: false, isWorkExperienceSelected: false, host: 'http://example.com', + app_name: APP_NAME, }; delete window.location; window.location = { diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 22f12e14..0a0a5b53 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -4,7 +4,6 @@ import React, { import { useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; -import { sendPageEvent, sendTrackEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, Spinner, StatefulButton } from '@openedx/paragon'; import classNames from 'classnames'; @@ -44,11 +43,12 @@ import { getThirdPartyAuthContext as getRegistrationDataFromBackend } from '../c import EnterpriseSSO from '../common-components/EnterpriseSSO'; import ThirdPartyAuth from '../common-components/ThirdPartyAuth'; import { - COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, + APP_NAME, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, } from '../data/constants'; import { getAllPossibleQueryParams, getTpaHint, getTpaProvider, isHostAvailableInQueryParams, setCookie, } from '../data/utils'; +import { trackRegistrationPageViewed, trackRegistrationSuccess } from '../tracking/trackers/register'; /** * Main Registration Page component @@ -138,7 +138,7 @@ const RegistrationPage = (props) => { useEffect(() => { if (!formStartTime) { - sendPageEvent('login_and_registration', 'register'); + trackRegistrationPageViewed(); const payload = { ...queryParams, is_register_page: true }; if (tpaHint) { payload.tpa_hint = tpaHint; @@ -183,7 +183,7 @@ const RegistrationPage = (props) => { useEffect(() => { if (registrationResult.success) { // This event is used by GTM - sendTrackEvent('edx.bi.user.account.registered.client', {}); + trackRegistrationSuccess(); // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); @@ -222,7 +222,7 @@ const RegistrationPage = (props) => { const registerUser = () => { const totalRegistrationTime = (Date.now() - formStartTime) / 1000; - let payload = { ...formFields }; + let payload = { ...formFields, app_name: APP_NAME }; if (currentProvider) { delete payload.password; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index bec3ec60..a98d55d5 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -22,7 +22,7 @@ import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; import RegistrationPage from './RegistrationPage'; import { - AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, + APP_NAME, AUTHN_PROGRESSIVE_PROFILING, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, } from '../data/constants'; jest.mock('@edx/frontend-platform/analytics', () => ({ @@ -190,6 +190,7 @@ describe('RegistrationPage', () => { honor_code: true, total_registration_time: 0, next: '/course/demo-course-url', + app_name: APP_NAME, }; store.dispatch = jest.fn(store.dispatch); @@ -212,6 +213,7 @@ describe('RegistrationPage', () => { honor_code: true, social_auth_provider: 'Apple', total_registration_time: 0, + app_name: APP_NAME, }; store = mockStore({ @@ -297,6 +299,7 @@ describe('RegistrationPage', () => { honor_code: true, total_registration_time: 0, marketing_emails_opt_in: true, + app_name: APP_NAME, }; store.dispatch = jest.fn(store.dispatch); @@ -323,6 +326,7 @@ describe('RegistrationPage', () => { country: 'Pakistan', honor_code: true, total_registration_time: 0, + app_name: APP_NAME, }; store.dispatch = jest.fn(store.dispatch); @@ -597,7 +601,7 @@ describe('RegistrationPage', () => { it('should send page event when register page is rendered', () => { render(routerWrapper(reduxWrapper())); - expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register'); + expect(sendPageEvent).toHaveBeenCalledWith('login_and_registration', 'register', { app_name: APP_NAME }); }); it('should send track event when user has successfully registered', () => { @@ -615,7 +619,7 @@ describe('RegistrationPage', () => { delete window.location; window.location = { href: getConfig().BASE_URL }; render(routerWrapper(reduxWrapper())); - expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', {}); + expect(sendTrackEvent).toHaveBeenCalledWith('edx.bi.user.account.registered.client', { app_name: APP_NAME }); }); it('should populate form with pipeline user details', () => { diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 88d64253..61a25848 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -9,6 +9,7 @@ import { fireEvent, render } from '@testing-library/react'; import { BrowserRouter as Router } from 'react-router-dom'; import configureStore from 'redux-mock-store'; +import { APP_NAME } from '../../../data/constants'; import { registerNewUser } from '../../data/actions'; import { FIELDS } from '../../data/constants'; import { NOT_INITIALIZED } from '../../data/optimizelyExperiment/helper'; @@ -265,7 +266,7 @@ describe('ConfigurableRegistrationForm', () => { fireEvent.click(submitButton); - expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK' })); + expect(store.dispatch).toHaveBeenCalledWith(registerNewUser({ ...payload, country: 'PK', app_name: APP_NAME })); }); it('should show error messages for required fields on empty form submission', () => { diff --git a/src/reset-password/ResetPasswordPage.jsx b/src/reset-password/ResetPasswordPage.jsx index 0393d279..ceb37e8c 100644 --- a/src/reset-password/ResetPasswordPage.jsx +++ b/src/reset-password/ResetPasswordPage.jsx @@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; -import { sendPageEvent } from '@edx/frontend-platform/analytics'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, @@ -19,7 +18,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { resetPassword, validateToken } from './data/actions'; import { - FORM_SUBMISSION_ERROR, PASSWORD_RESET_ERROR, PASSWORD_VALIDATION_ERROR, TOKEN_STATE, + FORM_SUBMISSION_ERROR, PASSWORD_RESET_ERROR, PASSWORD_VALIDATION_ERROR, SUCCESS, TOKEN_STATE, } from './data/constants'; import { resetPasswordResultSelector } from './data/selectors'; import { validatePassword } from './data/service'; @@ -31,6 +30,7 @@ import { LETTER_REGEX, LOGIN_PAGE, NUMBER_REGEX, RESET_PAGE, } from '../data/constants'; import { getAllPossibleQueryParams, updatePathWithQueryParams, windowScrollTo } from '../data/utils'; +import { trackPasswordResetSuccess, trackResetPasswordPageViewed } from '../tracking/trackers/reset-password'; const ResetPasswordPage = (props) => { const { formatMessage } = useIntl(); @@ -44,8 +44,13 @@ const ResetPasswordPage = (props) => { const navigate = useNavigate(); useEffect(() => { - sendPageEvent('login_and_registration', 'reset-password'); - }, []); + if (props.status === TOKEN_STATE.VALID) { + trackResetPasswordPageViewed(); + } + if (props.status === SUCCESS) { + trackPasswordResetSuccess(); + } + }, [props.status]); useEffect(() => { if (props.status !== TOKEN_STATE.PENDING && props.status !== PASSWORD_RESET_ERROR) { @@ -144,7 +149,7 @@ const ResetPasswordPage = (props) => { } } else if (props.status === PASSWORD_RESET_ERROR) { navigate(updatePathWithQueryParams(RESET_PAGE)); - } else if (props.status === 'success') { + } else if (props.status === SUCCESS) { navigate(updatePathWithQueryParams(LOGIN_PAGE)); } else { return ( diff --git a/src/reset-password/tests/ResetPasswordPage.test.jsx b/src/reset-password/tests/ResetPasswordPage.test.jsx index 0c9ba1a2..f3619be7 100644 --- a/src/reset-password/tests/ResetPasswordPage.test.jsx +++ b/src/reset-password/tests/ResetPasswordPage.test.jsx @@ -21,6 +21,7 @@ const token = '1c-bmjdkc-5e60e084cf8113048ca7'; jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), + sendTrackEvent: jest.fn(), })); jest.mock('@edx/frontend-platform/auth'); diff --git a/src/tracking/trackers/forgotpassword.js b/src/tracking/trackers/forgotpassword.js new file mode 100644 index 00000000..3918478d --- /dev/null +++ b/src/tracking/trackers/forgotpassword.js @@ -0,0 +1,22 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + loginAndRegistration: 'login_and_registration', + forgotPasswordPageViewed: 'edx.bi.password_reset_form.viewed', +}; + +export const categories = { + userEngagement: 'user-engagement', +}; + +// Event tracker for forgot password page viewed +export const trackForgotPasswordPageViewed = () => createEventTracker( + eventNames.forgotPasswordPageViewed, + { + category: categories.userEngagement, + }, +)(); + +export const trackForgotPasswordPageEvent = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'forgot-password')(); +}; diff --git a/src/tracking/trackers/login.js b/src/tracking/trackers/login.js new file mode 100644 index 00000000..3c6a4a2a --- /dev/null +++ b/src/tracking/trackers/login.js @@ -0,0 +1,29 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + forgotPasswordLinkClicked: 'edx.bi.password-reset_form.toggled', + loginAndRegistration: 'login_and_registration', + registerFormToggled: 'edx.bi.register_form.toggled', + loginSuccess: 'edx.bi.user.account.authenticated.client', +}; + +export const categories = { + userEngagement: 'user-engagement', +}; + +// Event tracker for Forgot Password link click +export const trackForgotPasswordLinkClick = () => createEventTracker( + eventNames.forgotPasswordLinkClicked, + { category: categories.userEngagement }, +)(); + +// Tracks the login page event. +export const trackLoginPageViewed = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'login')(); +}; + +// Tracks the login sucess event. +export const trackLoginSuccess = () => createEventTracker( + eventNames.loginSuccess, + {}, +)(); diff --git a/src/tracking/trackers/progressive-profiling.js b/src/tracking/trackers/progressive-profiling.js new file mode 100644 index 00000000..11a6f53e --- /dev/null +++ b/src/tracking/trackers/progressive-profiling.js @@ -0,0 +1,37 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + progressiveProfilingSubmitClick: 'edx.bi.welcome.page.submit.clicked', + progressiveProfilingSkipLinkClick: 'edx.bi.welcome.page.skip.link.clicked', + disablePostRegistrationRecommendations: 'edx.bi.user.recommendations.not.enabled', + progressiveProfilingSupportLinkCLick: 'edx.bi.welcome.page.support.link.clicked', + loginAndRegistration: 'login_and_registration', +}; + +// Event link tracker for Progressive profiling skip button click +export const trackProgressiveProfilingSkipLinkClick = evenProperties => createEventTracker( + eventNames.progressiveProfilingSkipLinkClick, { ...evenProperties }, +)(); + +// Event tracker for progressive profiling submit button click +export const trackProgressiveProfilingSubmitClick = (evenProperties) => createEventTracker( + eventNames.progressiveProfilingSubmitClick, + { ...evenProperties }, +)(); + +// Event tracker for progressive profiling submit button click +export const trackDisablePostRegistrationRecommendations = (evenProperties) => createEventTracker( + eventNames.disablePostRegistrationRecommendations, + { ...evenProperties }, +)(); + +// Tracks the progressive profiling page event. +export const trackProgressiveProfilingPageViewed = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'welcome')(); +}; + +// Tracks the progressive profiling spport link click. +export const trackProgressiveProfilingSupportLinkCLick = () => createEventTracker( + eventNames.progressiveProfilingSupportLinkCLick, + {}, +)(); diff --git a/src/tracking/trackers/register.js b/src/tracking/trackers/register.js new file mode 100644 index 00000000..3a860856 --- /dev/null +++ b/src/tracking/trackers/register.js @@ -0,0 +1,22 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + loginAndRegistration: 'login_and_registration', + registrationSuccess: 'edx.bi.user.account.registered.client', + loginFormToggled: 'edx.bi.login_form.toggled', +}; + +export const categories = { + userEngagement: 'user-engagement', +}; + +// Event tracker for successful registration +export const trackRegistrationSuccess = () => createEventTracker( + eventNames.registrationSuccess, + {}, +)(); + +// Tracks the progressive profiling page event. +export const trackRegistrationPageViewed = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'register')(); +}; diff --git a/src/tracking/trackers/reset-password.js b/src/tracking/trackers/reset-password.js new file mode 100644 index 00000000..cb79340f --- /dev/null +++ b/src/tracking/trackers/reset-password.js @@ -0,0 +1,14 @@ +import { createEventTracker, createPageEventTracker } from '../../data/segment/utils'; + +export const eventNames = { + loginAndRegistration: 'login_and_registration', + resetPasswordSuccess: 'edx.bi.user.password.reset.success', +}; + +export const trackResetPasswordPageViewed = () => { + createPageEventTracker(eventNames.loginAndRegistration, 'reset-password')(); +}; + +export const trackPasswordResetSuccess = () => { + createEventTracker(eventNames.resetPasswordSuccess, {})(); +}; diff --git a/src/tracking/trackers/tests/forgot-password.test.jsx b/src/tracking/trackers/tests/forgot-password.test.jsx new file mode 100644 index 00000000..6a9b87c4 --- /dev/null +++ b/src/tracking/trackers/tests/forgot-password.test.jsx @@ -0,0 +1,37 @@ +import { createEventTracker, createPageEventTracker } from '../../../data/segment/utils'; +import { + categories, + eventNames, + trackForgotPasswordPageEvent, + trackForgotPasswordPageViewed, +} from '../forgotpassword'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fire trackForgotPasswordPageEvent', () => { + trackForgotPasswordPageEvent(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'forgot-password', + ); + }); + + it('should fire forgotPasswordPageViewedEvent', () => { + trackForgotPasswordPageViewed(); + + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.forgotPasswordPageViewed, + { category: categories.userEngagement }, + ); + }); +}); diff --git a/src/tracking/trackers/tests/login.test.jsx b/src/tracking/trackers/tests/login.test.jsx new file mode 100644 index 00000000..fac7d082 --- /dev/null +++ b/src/tracking/trackers/tests/login.test.jsx @@ -0,0 +1,37 @@ +import { createEventTracker, createPageEventTracker } from '../../../data/segment/utils'; +import { + categories, + eventNames, + trackForgotPasswordLinkClick, + trackLoginPageViewed, +} from '../login'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('trackForgotPasswordLinkClick function', () => { + trackForgotPasswordLinkClick(); + + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.forgotPasswordLinkClicked, + { category: categories.userEngagement }, + ); + }); + + it('trackLoginPageEvent function', () => { + trackLoginPageViewed(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'login', + ); + }); +}); diff --git a/src/tracking/trackers/tests/progressive-profiling.test.jsx b/src/tracking/trackers/tests/progressive-profiling.test.jsx new file mode 100644 index 00000000..4cda593d --- /dev/null +++ b/src/tracking/trackers/tests/progressive-profiling.test.jsx @@ -0,0 +1,37 @@ +import { createEventTracker, createPageEventTracker } from '../../../data/segment/utils'; +import { + eventNames, + trackProgressiveProfilingPageViewed, + trackProgressiveProfilingSkipLinkClick, +} from '../progressive-profiling'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createLinkTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fire trackProgressiveProfilingSkipLinkClickEvent', () => { + trackProgressiveProfilingSkipLinkClick(); + + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.progressiveProfilingSkipLinkClick, + {}, + ); + }); + + it('should fire trackProgressiveProfilingPageEvent', () => { + trackProgressiveProfilingPageViewed(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'welcome', + ); + }); +}); diff --git a/src/tracking/trackers/tests/register.test.jsx b/src/tracking/trackers/tests/register.test.jsx new file mode 100644 index 00000000..d058908f --- /dev/null +++ b/src/tracking/trackers/tests/register.test.jsx @@ -0,0 +1,36 @@ +import { createEventTracker, createPageEventTracker } from '../../../data/segment/utils'; +import { + eventNames, + trackRegistrationPageViewed, + trackRegistrationSuccess, +} from '../register'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fire registrationSuccessEvent', () => { + trackRegistrationSuccess(); + + expect(createEventTracker).toHaveBeenCalledWith( + eventNames.registrationSuccess, + {}, + ); + }); + + it('should fire trackRegistrationPageEvent', () => { + trackRegistrationPageViewed(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'register', + ); + }); +}); diff --git a/src/tracking/trackers/tests/reset-password.test.jsx b/src/tracking/trackers/tests/reset-password.test.jsx new file mode 100644 index 00000000..15d6ed79 --- /dev/null +++ b/src/tracking/trackers/tests/reset-password.test.jsx @@ -0,0 +1,26 @@ +import { createPageEventTracker } from '../../../data/segment/utils'; +import { + eventNames, + trackResetPasswordPageViewed, +} from '../reset-password'; + +// Mock createEventTracker function +jest.mock('../../../data/segment/utils', () => ({ + createEventTracker: jest.fn().mockImplementation(() => jest.fn()), + createPageEventTracker: jest.fn().mockImplementation(() => jest.fn()), +})); + +describe('Tracking Functions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fire trackResettPasswordPageEvent', () => { + trackResetPasswordPageViewed(); + + expect(createPageEventTracker).toHaveBeenCalledWith( + eventNames.loginAndRegistration, + 'reset-password', + ); + }); +}); From 56bd6d835e910e3f330ce29d78b48092bcf24b1b Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Fri, 12 Jul 2024 13:18:42 +0500 Subject: [PATCH 41/82] fix: set marketing opt in in cookie for sso (#1285) --- src/common-components/SocialAuthProviders.jsx | 8 +++++++- .../tests/SocialAuthProviders.test.jsx | 7 ++++--- .../components/ConfigurableRegistrationForm.jsx | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/common-components/SocialAuthProviders.jsx b/src/common-components/SocialAuthProviders.jsx index abe06da7..f76e4968 100644 --- a/src/common-components/SocialAuthProviders.jsx +++ b/src/common-components/SocialAuthProviders.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -8,15 +9,20 @@ import { Login } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import messages from './messages'; -import { LOGIN_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import { LOGIN_PAGE, REGISTER_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import { setCookie } from '../data/utils'; const SocialAuthProviders = (props) => { const { formatMessage } = useIntl(); const { referrer, socialAuthProviders } = props; + const registrationFields = useSelector(state => state.register.registrationFormData); function handleSubmit(e) { e.preventDefault(); + if (referrer === REGISTER_PAGE) { + setCookie('marketingEmailsOptIn', registrationFields?.configurableFormFields?.marketingEmailsOptIn); + } const url = e.currentTarget.dataset.providerUrl; window.location.href = getConfig().LMS_BASE_URL + url; } diff --git a/src/common-components/tests/SocialAuthProviders.test.jsx b/src/common-components/tests/SocialAuthProviders.test.jsx index 850708ec..ccc96157 100644 --- a/src/common-components/tests/SocialAuthProviders.test.jsx +++ b/src/common-components/tests/SocialAuthProviders.test.jsx @@ -27,7 +27,8 @@ describe('SocialAuthProviders', () => { loginUrl: '/auth/login/facebook/?auth_entry=login&next=/dashboard', }; - it('should match social auth provider with iconImage snapshot', () => { + // Skipped tests will be fixed later. + it.skip('should match social auth provider with iconImage snapshot', () => { props = { socialAuthProviders: [appleProvider, facebookProvider] }; const tree = renderer.create( @@ -39,7 +40,7 @@ describe('SocialAuthProviders', () => { expect(tree).toMatchSnapshot(); }); - it('should match social auth provider with iconClass snapshot', () => { + it.skip('should match social auth provider with iconClass snapshot', () => { props = { socialAuthProviders: [{ ...appleProvider, @@ -57,7 +58,7 @@ describe('SocialAuthProviders', () => { expect(tree).toMatchSnapshot(); }); - it('should match social auth provider with default icon snapshot', () => { + it.skip('should match social auth provider with default icon snapshot', () => { props = { socialAuthProviders: [{ ...appleProvider, diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index 8c300b7e..971bdd4a 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -1,10 +1,12 @@ import React, { useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { getCountryList, getLocale, useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; import { FormFieldRenderer } from '../../field-renderer'; +import { backupRegistrationFormBegin } from '../data/actions'; import { FIELDS } from '../data/constants'; import messages from '../messages'; import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields'; @@ -32,6 +34,7 @@ const ConfigurableRegistrationForm = (props) => { setFormFields, autoSubmitRegistrationForm, } = props; + const dispatch = useDispatch(); /** The reason for adding the entry 'United States' is that Chrome browser aut-fill the form with the 'Unites States' instead of 'United States of America' which does not exist in country dropdown list and gets the user @@ -50,6 +53,8 @@ const ConfigurableRegistrationForm = (props) => { showMarketingEmailOptInCheckbox: getConfig().MARKETING_EMAILS_OPT_IN, }; + const backedUpFormData = useSelector(state => state.register.registrationFormData); + /** * If auto submitting register form, we will check tos and honor code fields if they exist for feature parity. */ @@ -90,6 +95,16 @@ const ConfigurableRegistrationForm = (props) => { setFieldErrors(prevErrors => ({ ...prevErrors, [name]: '' })); } } + // setting marketingEmailsOptIn state for SSO authentication flow for register API call + if (name === 'marketingEmailsOptIn') { + dispatch(backupRegistrationFormBegin({ + ...backedUpFormData, + configurableFormFields: { + ...backedUpFormData.configurableFormFields, + [name]: value, + }, + })); + } setFormFields(prevState => ({ ...prevState, [name]: value })); }; From 05aa85a5fbad144b4ed9af615a37354308790b19 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:05:50 +0500 Subject: [PATCH 42/82] fix: remove cookie (#1286) -remove marketingEmailsOptIn cookie on successful registration - fix tests --- .../RedirectLogistration.jsx | 2 +- .../tests/SocialAuthProviders.test.jsx | 38 ++++++++++++++----- src/data/utils/cookies.js | 8 ++++ src/data/utils/index.js | 2 +- src/register/RegistrationPage.jsx | 5 ++- 5 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/common-components/RedirectLogistration.jsx b/src/common-components/RedirectLogistration.jsx index f6c30b50..24603f6a 100644 --- a/src/common-components/RedirectLogistration.jsx +++ b/src/common-components/RedirectLogistration.jsx @@ -5,7 +5,7 @@ import { Navigate } from 'react-router-dom'; import { AUTHN_PROGRESSIVE_PROFILING, RECOMMENDATIONS, REDIRECT, } from '../data/constants'; -import { setCookie } from '../data/utils'; +import setCookie from '../data/utils/cookies'; const RedirectLogistration = (props) => { const { diff --git a/src/common-components/tests/SocialAuthProviders.test.jsx b/src/common-components/tests/SocialAuthProviders.test.jsx index ccc96157..b1066a19 100644 --- a/src/common-components/tests/SocialAuthProviders.test.jsx +++ b/src/common-components/tests/SocialAuthProviders.test.jsx @@ -1,16 +1,35 @@ import React from 'react'; +import { Provider } from 'react-redux'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import renderer from 'react-test-renderer'; +import configureStore from 'redux-mock-store'; import registerIcons from '../RegisterFaIcons'; import SocialAuthProviders from '../SocialAuthProviders'; registerIcons(); +const mockStore = configureStore(); describe('SocialAuthProviders', () => { let props = {}; + const initialState = { + register: { + registrationFormData: { + configurableFormFields: { + marketingEmailsOptIn: true, + }, + }, + }, + }; + const store = mockStore(initialState); + const reduxWrapper = children => ( + + {children} + + ); + const appleProvider = { id: 'oa2-apple-id', name: 'Apple', @@ -27,20 +46,19 @@ describe('SocialAuthProviders', () => { loginUrl: '/auth/login/facebook/?auth_entry=login&next=/dashboard', }; - // Skipped tests will be fixed later. - it.skip('should match social auth provider with iconImage snapshot', () => { + it('should match social auth provider with iconImage snapshot', () => { props = { socialAuthProviders: [appleProvider, facebookProvider] }; - const tree = renderer.create( + const tree = renderer.create(reduxWrapper( , - ).toJSON(); + )).toJSON(); expect(tree).toMatchSnapshot(); }); - it.skip('should match social auth provider with iconClass snapshot', () => { + it('should match social auth provider with iconClass snapshot', () => { props = { socialAuthProviders: [{ ...appleProvider, @@ -49,16 +67,16 @@ describe('SocialAuthProviders', () => { }], }; - const tree = renderer.create( + const tree = renderer.create(reduxWrapper( , - ).toJSON(); + )).toJSON(); expect(tree).toMatchSnapshot(); }); - it.skip('should match social auth provider with default icon snapshot', () => { + it('should match social auth provider with default icon snapshot', () => { props = { socialAuthProviders: [{ ...appleProvider, @@ -67,11 +85,11 @@ describe('SocialAuthProviders', () => { }], }; - const tree = renderer.create( + const tree = renderer.create(reduxWrapper( , - ).toJSON(); + )).toJSON(); expect(tree).toMatchSnapshot(); }); diff --git a/src/data/utils/cookies.js b/src/data/utils/cookies.js index 1aad2858..cfddf5ec 100644 --- a/src/data/utils/cookies.js +++ b/src/data/utils/cookies.js @@ -11,3 +11,11 @@ export default function setCookie(cookieName, cookieValue, cookieExpiry) { cookies.set(cookieName, cookieValue, options); } } + +export function removeCookie(cookieName) { + if (cookieName) { + const cookies = new Cookies(); + const options = { domain: getConfig().SESSION_COOKIE_DOMAIN, path: '/' }; + cookies.remove(cookieName, options); + } +} diff --git a/src/data/utils/index.js b/src/data/utils/index.js index 05532111..1c9eebbb 100644 --- a/src/data/utils/index.js +++ b/src/data/utils/index.js @@ -8,4 +8,4 @@ export { windowScrollTo, } from './dataUtils'; export { default as AsyncActionType } from './reduxUtils'; -export { default as setCookie } from './cookies'; +export { default as setCookie, removeCookie } from './cookies'; diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 0a0a5b53..50f689bf 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -46,7 +46,7 @@ import { APP_NAME, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, } from '../data/constants'; import { - getAllPossibleQueryParams, getTpaHint, getTpaProvider, isHostAvailableInQueryParams, setCookie, + getAllPossibleQueryParams, getTpaHint, getTpaProvider, isHostAvailableInQueryParams, removeCookie, setCookie, } from '../data/utils'; import { trackRegistrationPageViewed, trackRegistrationSuccess } from '../tracking/trackers/register'; @@ -187,6 +187,9 @@ const RegistrationPage = (props) => { // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); + + // remove marketingEmailsOptIn cookie that was set on SSO registration flow + removeCookie('marketingEmailsOptIn'); } }, [registrationResult]); From d10f9b932bd87b7e2ffc54393bf7e7a3719daff6 Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:57:18 +0500 Subject: [PATCH 43/82] fix: fix marketingEmailsOptIn null value (#1294) Fix marketingEmailsOptIn null value issue for SSO flow on onboarding component VAN-2013 --- src/common-components/ThirdPartyAuthAlert.jsx | 6 +++++- src/login/LoginPage.jsx | 4 ++++ src/register/RegistrationPage.jsx | 4 +++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/common-components/ThirdPartyAuthAlert.jsx b/src/common-components/ThirdPartyAuthAlert.jsx index fb79a6a6..fa2c6ee6 100644 --- a/src/common-components/ThirdPartyAuthAlert.jsx +++ b/src/common-components/ThirdPartyAuthAlert.jsx @@ -7,6 +7,7 @@ import PropTypes from 'prop-types'; import messages from './messages'; import { LOGIN_PAGE, REGISTER_PAGE } from '../data/constants'; +import setCookie from '../data/utils/cookies'; const ThirdPartyAuthAlert = (props) => { const { formatMessage } = useIntl(); @@ -20,7 +21,10 @@ const ThirdPartyAuthAlert = (props) => { message = formatMessage(messages['register.third.party.auth.account.not.linked'], { currentProvider, platformName }); } - if (!currentProvider) { + if (currentProvider) { + // Setting this cookie to capture marketingEmailsOptIn for SSO flow on the onboarding component + setCookie('ssoPipelineRedirectionDone', true); + } else { return null; } diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 0ff25258..6a935935 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -41,6 +41,7 @@ import { getTpaProvider, updatePathWithQueryParams, } from '../data/utils'; +import { removeCookie } from '../data/utils/cookies'; import ResetPasswordSuccess from '../reset-password/ResetPasswordSuccess'; import { trackForgotPasswordLinkClick, trackLoginPageViewed, trackLoginSuccess, @@ -86,6 +87,9 @@ const LoginPage = (props) => { useEffect(() => { if (loginResult.success) { trackLoginSuccess(); + + // Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component + removeCookie('ssoPipelineRedirectionDone'); } }, [loginResult]); diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 50f689bf..8b6dc9fd 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -188,8 +188,10 @@ const RegistrationPage = (props) => { // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); - // remove marketingEmailsOptIn cookie that was set on SSO registration flow + // Remove marketingEmailsOptIn cookie that was set on SSO registration flow removeCookie('marketingEmailsOptIn'); + // Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component + removeCookie('ssoPipelineRedirectionDone'); } }, [registrationResult]); From 2d50ed224fbdc88aff14ba05aec3bcf473c2a46b Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Mon, 29 Jul 2024 11:23:48 +0500 Subject: [PATCH 44/82] fix: retain query params in authenticated user redirection (#1288) --- src/common-components/UnAuthOnlyRoute.jsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/common-components/UnAuthOnlyRoute.jsx b/src/common-components/UnAuthOnlyRoute.jsx index 7be62aae..3e05fe0b 100644 --- a/src/common-components/UnAuthOnlyRoute.jsx +++ b/src/common-components/UnAuthOnlyRoute.jsx @@ -4,9 +4,7 @@ import { getConfig } from '@edx/frontend-platform'; import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth'; import PropTypes from 'prop-types'; -import { - DEFAULT_REDIRECT_URL, -} from '../data/constants'; +import { updatePathWithQueryParams } from '../data/utils'; /** * This wrapper redirects the requester to our default redirect url if they are @@ -25,7 +23,8 @@ const UnAuthOnlyRoute = ({ children }) => { if (isReady) { if (authUser && authUser.username) { - global.location.href = getConfig().LMS_BASE_URL.concat(DEFAULT_REDIRECT_URL); + const updatedPath = updatePathWithQueryParams(window.location.pathname); + global.location.href = getConfig().LMS_BASE_URL.concat(updatedPath); return null; } From efc07aac67da32278c68e14ad374a24eef69852e Mon Sep 17 00:00:00 2001 From: Syed Sajjad Hussain Shah <52817156+syedsajjadkazmii@users.noreply.github.com> Date: Thu, 1 Aug 2024 16:06:20 +0500 Subject: [PATCH 45/82] fix: fix datadog js errors (#1296) --- src/recommendations/track.js | 4 ++-- src/register/RegistrationFields/NameField/validator.js | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/recommendations/track.js b/src/recommendations/track.js index 5d826a3e..9a18b974 100644 --- a/src/recommendations/track.js +++ b/src/recommendations/track.js @@ -15,7 +15,7 @@ const generateProductKey = (product) => ( export const getProductMapping = (recommendedProducts) => recommendedProducts.map((product) => ({ product_key: generateProductKey(product), product_line: product.cardType, - product_source: product.productSource.name, + product_source: product?.productSource?.name, })); export const trackRecommendationClick = (product, position, userId) => { @@ -25,7 +25,7 @@ export const trackRecommendationClick = (product, position, userId) => { recommendation_type: product.recommendationType, product_key: generateProductKey(product), product_line: product.cardType, - product_source: product.productSource.name, + product_source: product?.productSource?.name, user_id: userId, }); diff --git a/src/register/RegistrationFields/NameField/validator.js b/src/register/RegistrationFields/NameField/validator.js index aefaedfb..c8e1d09c 100644 --- a/src/register/RegistrationFields/NameField/validator.js +++ b/src/register/RegistrationFields/NameField/validator.js @@ -11,7 +11,7 @@ export const INVALID_NAME_REGEX = /https?:\/\/(?:[-\w.]|(?:%[\da-fA-F]{2}))*/g; const validateName = (value, formatMessage) => { let fieldError = ''; - if (!value.trim()) { + if (!value || (value && !value.trim())) { fieldError = formatMessage(messages['empty.name.field.error']); } else if (URL_REGEX.test(value) || HTML_REGEX.test(value) || INVALID_NAME_REGEX.test(value)) { fieldError = formatMessage(messages['name.validation.message']); From cd9b3bd08470d126e2136738abbe2306b5c772ae Mon Sep 17 00:00:00 2001 From: Blue Date: Wed, 28 Aug 2024 14:58:40 +0500 Subject: [PATCH 46/82] fix: covert totalRegistrationTime to snake case (#1302) Description: Convert totalRegistrationTime to snake case VAN-1816 Co-authored-by: Ahtesham Quraish --- src/register/RegistrationPage.test.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index a98d55d5..5f23e2c2 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -892,6 +892,7 @@ describe('RegistrationPage', () => { country: 'PK', social_auth_provider: 'Apple', total_registration_time: 0, + app_name: APP_NAME, })); }); }); From ac2548913f8f5d2e29baa5b078104b83fcfa358e Mon Sep 17 00:00:00 2001 From: Mubbshar Anwar <78487564+mubbsharanwar@users.noreply.github.com> Date: Thu, 29 Aug 2024 09:48:21 +0500 Subject: [PATCH 47/82] fix: password reset redirection (#1300) fix authenticated user redirects to 404 if token is invalide for password reset VAN-2052 --- src/common-components/UnAuthOnlyRoute.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/common-components/UnAuthOnlyRoute.jsx b/src/common-components/UnAuthOnlyRoute.jsx index 3e05fe0b..2e9df557 100644 --- a/src/common-components/UnAuthOnlyRoute.jsx +++ b/src/common-components/UnAuthOnlyRoute.jsx @@ -4,6 +4,7 @@ import { getConfig } from '@edx/frontend-platform'; import { fetchAuthenticatedUser, getAuthenticatedUser } from '@edx/frontend-platform/auth'; import PropTypes from 'prop-types'; +import { RESET_PAGE } from '../data/constants'; import { updatePathWithQueryParams } from '../data/utils'; /** @@ -24,6 +25,10 @@ const UnAuthOnlyRoute = ({ children }) => { if (isReady) { if (authUser && authUser.username) { const updatedPath = updatePathWithQueryParams(window.location.pathname); + if (updatedPath.startsWith(RESET_PAGE)) { + global.location.href = getConfig().LMS_BASE_URL; + return null; + } global.location.href = getConfig().LMS_BASE_URL.concat(updatedPath); return null; } From b41fca36056cccb11a8aa895a0512df5d71cc2e1 Mon Sep 17 00:00:00 2001 From: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> Date: Tue, 10 Sep 2024 21:11:44 +0500 Subject: [PATCH 48/82] feat: removed Russian Federation from country list (#1315) --- src/register/components/ConfigurableRegistrationForm.jsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index 971bdd4a..3013be5f 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -40,7 +40,10 @@ const ConfigurableRegistrationForm = (props) => { States' instead of 'United States of America' which does not exist in country dropdown list and gets the user confused and unable to create an account. So we added the United States entry in the dropdown list. */ - const countryList = useMemo(() => getCountryList(getLocale()).concat([{ code: 'US', name: 'United States' }]), []); + + const countryList = useMemo(() => ( + getCountryList(getLocale()).concat([{ code: 'US', name: 'United States' }]).filter(country => country.code !== 'RU') + ), []); let showTermsOfServiceAndHonorCode = false; let showCountryField = false; From 47b0501e1cf4ff1c9de25659ceff85549e25f1fa Mon Sep 17 00:00:00 2001 From: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> Date: Wed, 25 Sep 2024 13:58:01 +0500 Subject: [PATCH 49/82] feat: add MainAppSlot for chatbot plugin (#1320) * feat: add MainAppSlot for chatbot plugin * test: added test for MainAppSlot * chore: add read me for plugin-slot --- package-lock.json | 57 ++++++++++++++++-- package.json | 2 + src/MainApp.jsx | 5 +- .../MainAppSlot/MainAppSlot.test.jsx | 29 +++++++++ src/plugin-slots/MainAppSlot/README.md | 41 +++++++++++++ .../MainAppSlot/images/main_app_slot.png | Bin 0 -> 275766 bytes src/plugin-slots/MainAppSlot/index.jsx | 7 +++ src/plugin-slots/README.md | 3 + 8 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/plugin-slots/MainAppSlot/MainAppSlot.test.jsx create mode 100644 src/plugin-slots/MainAppSlot/README.md create mode 100644 src/plugin-slots/MainAppSlot/images/main_app_slot.png create mode 100644 src/plugin-slots/MainAppSlot/index.jsx create mode 100644 src/plugin-slots/README.md diff --git a/package-lock.json b/package-lock.json index 1b356ecf..b5a8a6af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@fortawesome/free-brands-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "0.2.2", + "@openedx/frontend-plugin-framework": "^1.3.0", "@openedx/paragon": "^22.1.1", "@optimizely/react-sdk": "^2.9.1", "@redux-devtools/extension": "3.3.0", @@ -31,6 +32,7 @@ "query-string": "7.1.3", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-error-boundary": "^4.0.13", "react-helmet": "6.1.0", "react-loading-skeleton": "3.4.0", "react-redux": "7.2.9", @@ -3395,6 +3397,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@openedx/frontend-plugin-framework": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@openedx/frontend-plugin-framework/-/frontend-plugin-framework-1.3.0.tgz", + "integrity": "sha512-qLtX/4HIuWXiIhBdtBuL6mPVbV2un0rsFYx3I5+3tIUf7+T7WRq81a6JHU5QGyAmZy9dfiv7QwbqwiEQOVXVuQ==", + "dependencies": { + "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", + "classnames": "^2.3.2", + "core-js": "3.37.1", + "react-redux": "7.2.9", + "redux": "4.2.1", + "regenerator-runtime": "0.14.1" + }, + "peerDependencies": { + "@edx/frontend-platform": "^7.0.0 || ^8.0.0", + "@openedx/paragon": "^21.0.0 || ^22.0.0", + "prop-types": "^15.8.0", + "react": "^17.0.0", + "react-dom": "^17.0.0", + "react-error-boundary": "^4.0.11" + } + }, + "node_modules/@openedx/frontend-plugin-framework/node_modules/core-js": { + "version": "3.37.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz", + "integrity": "sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/@openedx/paragon": { "version": "22.7.0", "license": "Apache-2.0", @@ -4053,6 +4086,21 @@ } } }, + "node_modules/@testing-library/react-hooks/node_modules/react-error-boundary": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz", + "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "license": "MIT", @@ -13216,15 +13264,12 @@ } }, "node_modules/react-error-boundary": { - "version": "3.1.4", - "license": "MIT", + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.13.tgz", + "integrity": "sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==", "dependencies": { "@babel/runtime": "^7.12.5" }, - "engines": { - "node": ">=10", - "npm": ">=6" - }, "peerDependencies": { "react": ">=16.13.1" } diff --git a/package.json b/package.json index 7d3966bb..f344c8d8 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@fortawesome/free-brands-svg-icons": "6.6.0", "@fortawesome/free-solid-svg-icons": "6.6.0", "@fortawesome/react-fontawesome": "0.2.2", + "@openedx/frontend-plugin-framework": "^1.3.0", "@openedx/paragon": "^22.1.1", "@optimizely/react-sdk": "^2.9.1", "@redux-devtools/extension": "3.3.0", @@ -54,6 +55,7 @@ "query-string": "7.1.3", "react": "^17.0.2", "react-dom": "^17.0.2", + "react-error-boundary": "^4.0.13", "react-helmet": "6.1.0", "react-loading-skeleton": "3.4.0", "react-redux": "7.2.9", diff --git a/src/MainApp.jsx b/src/MainApp.jsx index 26c2cf59..b107e365 100755 --- a/src/MainApp.jsx +++ b/src/MainApp.jsx @@ -6,7 +6,7 @@ import { Helmet } from 'react-helmet'; import { Navigate, Route, Routes } from 'react-router-dom'; import { - EmbeddedRegistrationRoute, NotFoundPage, registerIcons, UnAuthOnlyRoute, Zendesk, + EmbeddedRegistrationRoute, NotFoundPage, registerIcons, UnAuthOnlyRoute, } from './common-components'; import configureStore from './data/configureStore'; import { @@ -22,6 +22,7 @@ import { import { updatePathWithQueryParams } from './data/utils'; import { ForgotPasswordPage } from './forgot-password'; import Logistration from './logistration/Logistration'; +import MainAppSlot from './plugin-slots/MainAppSlot'; import { ProgressiveProfiling } from './progressive-profiling'; import { RecommendationsPage } from './recommendations'; import { RegistrationPage } from './register'; @@ -36,7 +37,6 @@ const MainApp = () => ( - {getConfig().ZENDESK_KEY && } } /> ( } /> } /> + ); diff --git a/src/plugin-slots/MainAppSlot/MainAppSlot.test.jsx b/src/plugin-slots/MainAppSlot/MainAppSlot.test.jsx new file mode 100644 index 00000000..f5262d81 --- /dev/null +++ b/src/plugin-slots/MainAppSlot/MainAppSlot.test.jsx @@ -0,0 +1,29 @@ +import { PluginSlot } from '@openedx/frontend-plugin-framework'; +import { render } from '@testing-library/react'; + +import MainAppSlot from './index'; + +jest.mock('@openedx/frontend-plugin-framework', () => ({ + PluginSlot: jest.fn(() => null), +})); + +describe('MainAppSlot', () => { + it('renders without crashing', () => { + render(); + }); + + it('renders a PluginSlot component', () => { + render(); + expect(PluginSlot).toHaveBeenCalled(); + }); + + it('passes the correct id prop to PluginSlot', () => { + render(); + expect(PluginSlot).toHaveBeenCalledWith({ id: 'main_app_slot' }, {}); + }); + + it('does not render any children', () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); +}); diff --git a/src/plugin-slots/MainAppSlot/README.md b/src/plugin-slots/MainAppSlot/README.md new file mode 100644 index 00000000..ff755fc0 --- /dev/null +++ b/src/plugin-slots/MainAppSlot/README.md @@ -0,0 +1,41 @@ +# Main App Slot + +### Slot ID: `main_app_slot` + +## Description + +This slot is used for adding content at the root level. + +## Example + +The following `env.config.jsx` will render a component at the MFE root level. + +![Screenshot of Content added after the Main App Slot](./images/main_app_slot.png) + +```js +import { + DIRECT_PLUGIN, + PLUGIN_OPERATIONS, +} from "@openedx/frontend-plugin-framework"; +import { ExampleComponent } from "@openedx/frontend-plugin-example"; + +const config = { + pluginSlots: { + main_app_slot: { + plugins: [ + { + op: PLUGIN_OPERATIONS.Insert, + widget: { + id: "example-component", + type: DIRECT_PLUGIN, + priority: 60, + RenderWidget: ExampleComponent, + }, + }, + ], + }, + }, +}; + +export default config; +``` diff --git a/src/plugin-slots/MainAppSlot/images/main_app_slot.png b/src/plugin-slots/MainAppSlot/images/main_app_slot.png new file mode 100644 index 0000000000000000000000000000000000000000..bc842adec1295bc5ab87d0bf4ea693cf86dad8e7 GIT binary patch literal 275766 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 ( + +); + +export default MainAppSlot; diff --git a/src/plugin-slots/README.md b/src/plugin-slots/README.md new file mode 100644 index 00000000..4971bb38 --- /dev/null +++ b/src/plugin-slots/README.md @@ -0,0 +1,3 @@ +# `frontend-app-authn` Plugin Slots + +- [`main_app_slot`](./MainAppSlot/) From c4f1a9731699ba5ef2af2c85f48303726ac6ed63 Mon Sep 17 00:00:00 2001 From: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> Date: Wed, 25 Sep 2024 17:16:32 +0500 Subject: [PATCH 50/82] build: updated webpack.prod.config.js (#1327) * build: updated webpack.prod.config.js * fix: lint error --- .eslintrc.js | 13 ++++++++- example.env.config.js | 60 ++++++++++++++++++++++++++++++++++++++++++ webpack.dev.config.js | 14 ++++++++++ webpack.prod.config.js | 7 +++++ 4 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 example.env.config.js create mode 100644 webpack.dev.config.js diff --git a/.eslintrc.js b/.eslintrc.js index 43df3971..ea7493b8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,7 +1,7 @@ // eslint-disable-next-line import/no-extraneous-dependencies const { createConfig } = require('@openedx/frontend-build'); -module.exports = createConfig('eslint', { +const config = createConfig('eslint', { rules: { // Temporarily update the 'indent', 'template-curly-spacing' and // 'no-multiple-empty-lines' rules since they are causing eslint @@ -50,3 +50,14 @@ module.exports = createConfig('eslint', { 'function-paren-newline': 'off', }, }); + +config.settings = { + 'import/resolver': { + node: { + paths: ['src', 'node_modules'], + extensions: ['.js', '.jsx'], + }, + }, +}; + +module.exports = config; diff --git a/example.env.config.js b/example.env.config.js new file mode 100644 index 00000000..99db8bdf --- /dev/null +++ b/example.env.config.js @@ -0,0 +1,60 @@ +/* +Authn MFE is now able to handle JS-based configuration! + +For the time being, the `.env.*` files are still made available when cloning down this repo or pulling from +the master branch. To switch to using `env.config.js`, make a copy of `example.env.config.js` and configure as needed. + +For testing with Jest Snapshot, there is a mock in `/src/setupTest.jsx` for `getConfig` that will need to be +uncommented. + +Note: having both .env and env.config.js files will follow a predictable order, in which non-empty values in the +JS-based config will overwrite the .env environment variables. + +frontend-platform's getConfig loads configuration in the following sequence: +- .env file config +- optional handlers (commonly used to merge MFE-specific config in via additional process.env variables) +- env.config.js file config +- runtime config +*/ + +module.exports = { + NODE_ENV: 'development', + NODE_PATH: './src', + PORT: 1999, + ACCESS_TOKEN_COOKIE_NAME: 'edx-jwt-cookie-header-payload', + BASE_URL: 'http://localhost:1999', + CREDENTIALS_BASE_URL: 'http://localhost:18150', + CSRF_TOKEN_API_PATH: '/csrf/api/v1/token', + ECOMMERCE_BASE_URL: 'http://localhost:18130', + LANGUAGE_PREFERENCE_COOKIE_NAME: 'openedx-language-preference', + LMS_BASE_URL: 'http://localhost:18000', + LOGIN_URL: 'http://localhost:1999/login', + LOGOUT_URL: 'http://localhost:18000/logout', + LOGO_URL: 'https://edx-cdn.org/v3/default/logo.svg', + LOGO_TRADEMARK_URL: 'https://edx-cdn.org/v3/default/logo-trademark.svg', + LOGO_WHITE_URL: 'https://edx-cdn.org/v3/default/logo-white.svg', + FAVICON_URL: 'https://edx-cdn.org/v3/default/favicon.ico', + MARKETING_SITE_BASE_URL: 'http://localhost:18000', + ORDER_HISTORY_URL: 'http://localhost:1996/orders', + REFRESH_ACCESS_TOKEN_ENDPOINT: 'http://localhost:18000/login_refresh', + SEGMENT_KEY: '', + SITE_NAME: 'Your Platform Name Here', + INFO_EMAIL: 'info@example.com', + ENABLE_DYNAMIC_REGISTRATION_FIELDS: 'true', + ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN: 'true', + SESSION_COOKIE_DOMAIN: 'localhost', + USER_INFO_COOKIE_NAME: 'edx-user-info', + LOGIN_ISSUE_SUPPORT_LINK: 'http://localhost:18000/login-issue-support-url', + TOS_AND_HONOR_CODE: 'http://localhost:18000/honor', + TOS_LINK: 'http://localhost:18000/tos', + PRIVACY_POLICY: 'http://localhost:18000/privacy', + AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK: 'http://localhost:1999/welcome', + BANNER_IMAGE_LARGE: '', + BANNER_IMAGE_MEDIUM: '', + BANNER_IMAGE_SMALL: '', + BANNER_IMAGE_EXTRA_SMALL: '', + APP_ID: '', + MFE_CONFIG_API_URL: '', + ZENDESK_KEY: '', + ZENDESK_LOGO_URL: '', +}; diff --git a/webpack.dev.config.js b/webpack.dev.config.js new file mode 100644 index 00000000..f70fa45a --- /dev/null +++ b/webpack.dev.config.js @@ -0,0 +1,14 @@ +const path = require('path'); + +const { createConfig } = require('@openedx/frontend-build'); + +const config = createConfig('webpack-dev'); + +config.resolve.modules = [ + path.resolve(__dirname, './src'), + 'node_modules', +]; + +config.module.rules[0].exclude = /node_modules\/(?!(fastest-levenshtein|@edx))/; + +module.exports = config; diff --git a/webpack.prod.config.js b/webpack.prod.config.js index 73d22693..eb59b941 100644 --- a/webpack.prod.config.js +++ b/webpack.prod.config.js @@ -1,7 +1,14 @@ +const path = require('path'); + const { createConfig } = require('@openedx/frontend-build'); const config = createConfig('webpack-prod'); +config.resolve.modules = [ + path.resolve(__dirname, './src'), + 'node_modules', +]; + config.module.rules[0].exclude = /node_modules\/(?!(fastest-levenshtein|@edx))/; module.exports = config; From 07ee2392e906e69b092df589cc946bde90881521 Mon Sep 17 00:00:00 2001 From: ayesha waris <73840786+ayesha-waris@users.noreply.github.com> Date: Thu, 17 Oct 2024 20:28:25 +0500 Subject: [PATCH 51/82] feat: added cohesion events tracking (#1329) * feat: added cohesion events tracking * test: fixed failing test cases * refactor: moved cohesion code into a folder * refactor: fire event on successful form submission --------- Co-authored-by: Awais Ansari --- .env | 3 + .env.development | 3 + .env.test | 2 + .eslintignore | 1 + public/index.html | 57 ++++++++++++------- src/cohesion/cohesion.scss | 4 ++ src/cohesion/constants.js | 22 +++++++ src/cohesion/data/actions.js | 6 ++ src/cohesion/data/reducers.js | 17 ++++++ src/cohesion/index.js | 52 +++++++++++++++++ src/cohesion/trackers.js | 24 ++++++++ src/cohesion/utils.js | 6 ++ src/common-components/SocialAuthProviders.jsx | 19 ++++++- src/data/reducers.js | 2 + src/index.jsx | 3 + src/login/LoginPage.jsx | 24 ++++++-- src/login/tests/LoginPage.test.jsx | 3 + src/logistration/Logistration.test.jsx | 1 + src/register/RegistrationPage.jsx | 32 ++++++++--- src/register/RegistrationPage.test.jsx | 3 + .../ConfigurableRegistrationForm.jsx | 13 +++++ .../ConfigurableRegistrationForm.test.jsx | 1 + .../tests/RegistrationFailure.test.jsx | 1 + .../components/tests/ThirdPartyAuth.test.jsx | 3 + 24 files changed, 268 insertions(+), 34 deletions(-) create mode 100644 src/cohesion/cohesion.scss create mode 100644 src/cohesion/constants.js create mode 100644 src/cohesion/data/actions.js create mode 100644 src/cohesion/data/reducers.js create mode 100644 src/cohesion/index.js create mode 100644 src/cohesion/trackers.js create mode 100644 src/cohesion/utils.js diff --git a/.env b/.env index 9d2c9658..3e70f47c 100644 --- a/.env +++ b/.env @@ -16,6 +16,9 @@ SITE_NAME=null INFO_EMAIL='' # ***** Cookies ***** USER_RETENTION_COOKIE_NAME=null +# ***** Cohesion Keys ***** +COHESION_WRITE_KEY='' +COHESION_SOURCE_KEY='' # ***** Links ***** LOGIN_ISSUE_SUPPORT_LINK='' AUTHN_PROGRESSIVE_PROFILING_SUPPORT_LINK=null diff --git a/.env.development b/.env.development index e085d5e3..5134d1c2 100644 --- a/.env.development +++ b/.env.development @@ -25,6 +25,9 @@ ENABLE_PROGRESSIVE_PROFILING_ON_AUTHN='true' # ***** Cookies ***** SESSION_COOKIE_DOMAIN='localhost' USER_INFO_COOKIE_NAME='edx-user-info' +# ***** Cohesion Keys ***** +COHESION_WRITE_KEY='' +COHESION_SOURCE_KEY='' # ***** Links ***** LOGIN_ISSUE_SUPPORT_LINK='http://localhost:18000/login-issue-support-url' TOS_AND_HONOR_CODE='http://localhost:18000/honor' diff --git a/.env.test b/.env.test index a6546d69..592df8a6 100644 --- a/.env.test +++ b/.env.test @@ -18,3 +18,5 @@ SEGMENT_KEY='' SITE_NAME='Your Platform Name Here' APP_ID='' MFE_CONFIG_API_URL='' +COHESION_WRITE_KEY='' +COHESION_SOURCE_KEY='' diff --git a/.eslintignore b/.eslintignore index 8346f2f1..8f123893 100755 --- a/.eslintignore +++ b/.eslintignore @@ -4,3 +4,4 @@ docs node_modules/ __mocks__/ __snapshots__/ +src/cohesion/index.js diff --git a/public/index.html b/public/index.html index 2c851c7b..5ce7bb9e 100644 --- a/public/index.html +++ b/public/index.html @@ -1,25 +1,44 @@ - + - <%= (process.env.SITE_NAME && process.env.SITE_NAME != 'null') ? 'Authentication | ' + process.env.SITE_NAME : 'Authentication' %> - - - - - <% if (process.env.OPTIMIZELY_URL) { %> - - <% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %> - - <% } %> + + <%= (process.env.SITE_NAME && process.env.SITE_NAME !='null' ) ? + 'Authentication | ' + process.env.SITE_NAME : 'Authentication' %> + + + + + + + <% if (process.env.OPTIMIZELY_URL) { %> + + <% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %> + + <% } %> +

diff --git a/src/cohesion/cohesion.scss b/src/cohesion/cohesion.scss new file mode 100644 index 00000000..7cfbaf75 --- /dev/null +++ b/src/cohesion/cohesion.scss @@ -0,0 +1,4 @@ +.preampjs [data-preamp], +.fusejs [data-fuse] { + opacity: 0 !important; +} diff --git a/src/cohesion/constants.js b/src/cohesion/constants.js new file mode 100644 index 00000000..9253f0f5 --- /dev/null +++ b/src/cohesion/constants.js @@ -0,0 +1,22 @@ +export const PAGE_TYPES = { + ACCOUNT_CREATION: 'account-creation', + SIGN_IN: 'sign-in', +}; + +export const ELEMENT_TYPES = { + BUTTON: 'BUTTON', +}; + +export const EVENT_TYPES = { ElementClicked: 'redventures.usertracking.v3.ElementClicked' }; + +export const ELEMENT_TEXT = { + CREATE_ACCOUNT: 'create-account', + OPT_IN_TEXT: 'I agree that edx may send me marketing messages', + SIGN_IN: 'Sign In', +}; + +export const ELEMENT_NAME = { + SIGN_IN: PAGE_TYPES.SIGN_IN, + OPT_OUT: 'opt-out', + CREATE_ACCOUNT: 'Create an account for free', +}; diff --git a/src/cohesion/data/actions.js b/src/cohesion/data/actions.js new file mode 100644 index 00000000..809df583 --- /dev/null +++ b/src/cohesion/data/actions.js @@ -0,0 +1,6 @@ +export const SET_COHESION_EVENT_ELEMENT_STATES = 'SET_COHESION_EVENT_ELEMENT_STATES'; + +export const setCohesionEventStates = (eventData) => ({ + type: SET_COHESION_EVENT_ELEMENT_STATES, + payload: eventData, +}); diff --git a/src/cohesion/data/reducers.js b/src/cohesion/data/reducers.js new file mode 100644 index 00000000..4b8fa693 --- /dev/null +++ b/src/cohesion/data/reducers.js @@ -0,0 +1,17 @@ +import { SET_COHESION_EVENT_ELEMENT_STATES } from './actions'; + +export const storeName = 'cohesion'; + +export const defaultState = { + eventData: {}, +}; + +export const reducer = (state = defaultState, action = {}) => { + if (action.type === SET_COHESION_EVENT_ELEMENT_STATES) { + return { + ...state, + eventData: action.payload, + }; + } + return state; +}; diff --git a/src/cohesion/index.js b/src/cohesion/index.js new file mode 100644 index 00000000..2239527f --- /dev/null +++ b/src/cohesion/index.js @@ -0,0 +1,52 @@ +module.exports = () => { + if (process.env.COHESION_WRITE_KEY && process.env.COHESION_SOURCE_KEY) { + !(function (co, h, e, s, i, o, n) { + const d = "documentElement"; + const a = "className"; + h[d][a] += " preampjs fusejs"; + n.k = e; + co._Cohesion = n; + co._Preamp = { k: s, start: new Date() }; + co._Fuse = { k: i }; + co._Tagular = { k: o }; + [e, s, i, o].map((x) => { + co[x] = + co[x] || + function () { + (co[x].q = co[x].q || []).push([].slice.call(arguments)); + }; + }); + const b = function () { + const u = h[d][a]; + h[d][a] = u.replace(/ ?preampjs| ?fusejs/g, ""); + }; + h.addEventListener("DOMContentLoaded", () => { + co.setTimeout(b, 3e3); + co._Preamp.docReady = co._Fuse.docReady = !0; + }); + const z = h.createElement("script"); + z.async = 1; + z.src = "https://cdn.cohesionapps.com/cohesion/cohesion-latest.min.js"; + z.onerror = function () { + const ce = "error"; + const f = "function"; + for (const o of co[e].q || []) { + o[0] === ce && typeof o[1] === f && o[1](); + } + co[e] = function (n, cb) { + n === ce && typeof cb === f && cb(); + }; + b(); + }; + h.head.appendChild(z); + })(window, document, "cohesion", "preamp", "fuse", "tagular", { + tagular: { + writeKey: process.env.COHESION_WRITE_KEY, + sourceKey: process.env.COHESION_SOURCE_KEY, + taggy: { + enabled: true, + }, + }, + }); + } +}; diff --git a/src/cohesion/trackers.js b/src/cohesion/trackers.js new file mode 100644 index 00000000..0144bb94 --- /dev/null +++ b/src/cohesion/trackers.js @@ -0,0 +1,24 @@ +import { EVENT_TYPES } from './constants'; + +/** + * Tracks cohesion events by setting the page type and tracking a click event. + * + * @param {string} pageType - The type of page where the event occurred. + * @param {string} elementType - The type of the web element (e.g., 'BUTTON', 'LINK'). + * @param {string} webElementText - The text content of the web element. + * @param {string} webElementName - The name of the web element. + */ +const trackCohesionEvent = (eventData) => { + window.chsn_pageType = eventData.pageType; + const webElement = { + elementType: eventData.elementType, + text: eventData.webElementText, + name: eventData.webElementName, + }; + window.tagular('beam', { + '@type': EVENT_TYPES.ElementClicked, + webElement, + }); +}; + +export default trackCohesionEvent; diff --git a/src/cohesion/utils.js b/src/cohesion/utils.js new file mode 100644 index 00000000..248ba5d9 --- /dev/null +++ b/src/cohesion/utils.js @@ -0,0 +1,6 @@ +const mockTagular = () => { + const getTagular = jest.fn(); + window.tagular = getTagular; +}; + +export default mockTagular; diff --git a/src/common-components/SocialAuthProviders.jsx b/src/common-components/SocialAuthProviders.jsx index f76e4968..7b78d2c8 100644 --- a/src/common-components/SocialAuthProviders.jsx +++ b/src/common-components/SocialAuthProviders.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -9,16 +9,31 @@ import { Login } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import messages from './messages'; -import { LOGIN_PAGE, REGISTER_PAGE, SUPPORTED_ICON_CLASSES } from '../data/constants'; +import { PAGE_TYPES } from '../cohesion/constants'; +import { setCohesionEventStates } from '../cohesion/data/actions'; +import { + LOGIN_PAGE, REGISTER_PAGE, SUPPORTED_ICON_CLASSES, +} from '../data/constants'; import { setCookie } from '../data/utils'; const SocialAuthProviders = (props) => { const { formatMessage } = useIntl(); + const dispatch = useDispatch(); const { referrer, socialAuthProviders } = props; const registrationFields = useSelector(state => state.register.registrationFormData); function handleSubmit(e) { e.preventDefault(); + const elementType = e.target.nodeName; + const elementText = e.target.name; + const eventData = { + pageType: referrer === LOGIN_PAGE ? PAGE_TYPES.SIGN_IN : PAGE_TYPES.ACCOUNT_CREATION, + elementType, + webElementText: elementText, + webElementName: elementText.toLowerCase(), + }; + + dispatch(setCohesionEventStates(eventData)); if (referrer === REGISTER_PAGE) { setCookie('marketingEmailsOptIn', registrationFields?.configurableFormFields?.marketingEmailsOptIn); diff --git a/src/data/reducers.js b/src/data/reducers.js index 11c12619..b8d8587c 100755 --- a/src/data/reducers.js +++ b/src/data/reducers.js @@ -1,5 +1,6 @@ import { combineReducers } from 'redux'; +import { reducer as cohesionReducer, storeName as cohesionStoreName } from '../cohesion/data/reducers'; import { reducer as commonComponentsReducer, storeName as commonComponentsStoreName, @@ -31,6 +32,7 @@ const createRootReducer = () => combineReducers({ [commonComponentsStoreName]: commonComponentsReducer, [forgotPasswordStoreName]: forgotPasswordReducer, [resetPasswordStoreName]: resetPasswordReducer, + [cohesionStoreName]: cohesionReducer, [authnProgressiveProfilingStoreName]: authnProgressiveProfilingReducers, }); export default createRootReducer; diff --git a/src/index.jsx b/src/index.jsx index b1131794..2674ea76 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -9,11 +9,14 @@ import { } from '@edx/frontend-platform'; import { ErrorPage } from '@edx/frontend-platform/react'; +import cohesion from './cohesion'; import configuration from './config'; import messages from './i18n'; import MainApp from './MainApp'; +import './cohesion/cohesion.scss'; subscribe(APP_READY, () => { + cohesion(); ReactDOM.render( , document.getElementById('root'), diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 6a935935..107541b8 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { connect } from 'react-redux'; +import { connect, useDispatch, useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { injectIntl, useIntl } from '@edx/frontend-platform/i18n'; @@ -20,6 +20,11 @@ import { import { INVALID_FORM, TPA_AUTHENTICATION_FAILURE } from './data/constants'; import LoginFailureMessage from './LoginFailure'; import messages from './messages'; +import { + ELEMENT_NAME, ELEMENT_TEXT, ELEMENT_TYPES, PAGE_TYPES, +} from '../cohesion/constants'; +import { setCohesionEventStates } from '../cohesion/data/actions'; +import trackCohesionEvent from '../cohesion/trackers'; import { FormGroup, InstitutionLogistration, @@ -31,9 +36,7 @@ import { getThirdPartyAuthContext } from '../common-components/data/actions'; import { thirdPartyAuthContextSelector } from '../common-components/data/selectors'; import EnterpriseSSO from '../common-components/EnterpriseSSO'; import ThirdPartyAuth from '../common-components/ThirdPartyAuth'; -import { - DEFAULT_STATE, PENDING_STATE, RESET_PAGE, -} from '../data/constants'; +import { DEFAULT_STATE, PENDING_STATE, RESET_PAGE } from '../data/constants'; import { getActivationStatus, getAllPossibleQueryParams, @@ -72,8 +75,10 @@ const LoginPage = (props) => { getTPADataFromBackend, } = props; const { formatMessage } = useIntl(); + const dispatch = useDispatch(); const activationMsgType = getActivationStatus(); const queryParams = useMemo(() => getAllPossibleQueryParams(), []); + const cohesionEventData = useSelector(state => state.cohesion.eventData); const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields }); const [errorCode, setErrorCode] = useState({ type: '', count: 0, context: {} }); @@ -87,11 +92,13 @@ const LoginPage = (props) => { useEffect(() => { if (loginResult.success) { trackLoginSuccess(); + // This event is used by cohestion upon successful login + trackCohesionEvent(cohesionEventData); // Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component removeCookie('ssoPipelineRedirectionDone'); } - }, [loginResult]); + }, [loginResult, cohesionEventData]); useEffect(() => { const payload = { ...queryParams }; @@ -152,6 +159,13 @@ const LoginPage = (props) => { const handleSubmit = (event) => { event.preventDefault(); + const eventData = { + pageType: PAGE_TYPES.SIGN_IN, + elementType: ELEMENT_TYPES.BUTTON, + webElementText: ELEMENT_TEXT.SIGN_IN, + webElementName: ELEMENT_NAME.SIGN_IN, + }; + dispatch(setCohesionEventStates(eventData)); if (showResetPasswordSuccessBanner) { props.dismissPasswordResetBanner(); } diff --git a/src/login/tests/LoginPage.test.jsx b/src/login/tests/LoginPage.test.jsx index 38778b8c..6b3e10be 100644 --- a/src/login/tests/LoginPage.test.jsx +++ b/src/login/tests/LoginPage.test.jsx @@ -11,6 +11,7 @@ import { act } from 'react-dom/test-utils'; import { MemoryRouter } from 'react-router-dom'; import configureStore from 'redux-mock-store'; +import mockTagular from '../../cohesion/utils'; import { APP_NAME, COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, } from '../../data/constants'; @@ -25,6 +26,7 @@ jest.mock('@edx/frontend-platform/analytics', () => ({ jest.mock('@edx/frontend-platform/auth', () => ({ getAuthService: jest.fn(), })); +mockTagular(); const IntlLoginPage = injectIntl(LoginPage); const mockStore = configureStore(); @@ -58,6 +60,7 @@ describe('LoginPage', () => { register: { validationApiRateLimited: false, }, + cohesion: { eventData: {} }, }; const secondaryProviders = { diff --git a/src/logistration/Logistration.test.jsx b/src/logistration/Logistration.test.jsx index b4b0d1e9..84c0b3d1 100644 --- a/src/logistration/Logistration.test.jsx +++ b/src/logistration/Logistration.test.jsx @@ -69,6 +69,7 @@ describe('Logistration', () => { usernameSuggestions: [], validationApiRateLimited: false, }, + cohesion: { eventData: {} }, commonComponents: { thirdPartyAuthContext: { providers: [], diff --git a/src/register/RegistrationPage.jsx b/src/register/RegistrationPage.jsx index 8b6dc9fd..b77d758d 100644 --- a/src/register/RegistrationPage.jsx +++ b/src/register/RegistrationPage.jsx @@ -11,6 +11,12 @@ import PropTypes from 'prop-types'; import { Helmet } from 'react-helmet'; import Skeleton from 'react-loading-skeleton'; +import { + InstitutionLogistration, + PasswordField, + RedirectLogistration, + ThirdPartyAuthAlert, +} from '../common-components'; import ConfigurableRegistrationForm from './components/ConfigurableRegistrationForm'; import RegistrationFailure from './components/RegistrationFailure'; import { @@ -34,22 +40,21 @@ import { import messages from './messages'; import { EmailField, NameField, UsernameField } from './RegistrationFields'; import { - InstitutionLogistration, - PasswordField, - RedirectLogistration, - ThirdPartyAuthAlert, -} from '../common-components'; + ELEMENT_NAME, ELEMENT_TEXT, ELEMENT_TYPES, PAGE_TYPES, +} from '../cohesion/constants'; +import { setCohesionEventStates } from '../cohesion/data/actions'; +import trackCohesionEvent from '../cohesion/trackers'; import { getThirdPartyAuthContext as getRegistrationDataFromBackend } from '../common-components/data/actions'; import EnterpriseSSO from '../common-components/EnterpriseSSO'; import ThirdPartyAuth from '../common-components/ThirdPartyAuth'; import { - APP_NAME, COMPLETE_STATE, PENDING_STATE, REGISTER_PAGE, + APP_NAME, COMPLETE_STATE, PENDING_STATE, + REGISTER_PAGE, } from '../data/constants'; import { getAllPossibleQueryParams, getTpaHint, getTpaProvider, isHostAvailableInQueryParams, removeCookie, setCookie, } from '../data/utils'; import { trackRegistrationPageViewed, trackRegistrationSuccess } from '../tracking/trackers/register'; - /** * Main Registration Page component */ @@ -89,6 +94,7 @@ const RegistrationPage = (props) => { const providers = useSelector(state => state.commonComponents.thirdPartyAuthContext.providers); const secondaryProviders = useSelector(state => state.commonComponents.thirdPartyAuthContext.secondaryProviders); const pipelineUserDetails = useSelector(state => state.commonComponents.thirdPartyAuthContext.pipelineUserDetails); + const cohesionEventData = useSelector(state => state.cohesion.eventData); const backendValidations = useSelector(getBackendValidations); const queryParams = useMemo(() => getAllPossibleQueryParams(), []); @@ -185,6 +191,9 @@ const RegistrationPage = (props) => { // This event is used by GTM trackRegistrationSuccess(); + // This event is used by cohestion upon successful registration + trackCohesionEvent(cohesionEventData); + // This is used by the "User Retention Rate Event" on GTM setCookie(getConfig().USER_RETENTION_COOKIE_NAME, true); @@ -193,7 +202,7 @@ const RegistrationPage = (props) => { // Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component removeCookie('ssoPipelineRedirectionDone'); } - }, [registrationResult]); + }, [registrationResult, cohesionEventData]); const handleOnChange = (event) => { const { name } = event.target; @@ -268,6 +277,13 @@ const RegistrationPage = (props) => { const handleSubmit = (e) => { e.preventDefault(); + const eventData = { + pageType: PAGE_TYPES.ACCOUNT_CREATION, + elementType: ELEMENT_TYPES.BUTTON, + webElementText: ELEMENT_TEXT.CREATE_ACCOUNT, + webElementName: ELEMENT_NAME.CREATE_ACCOUNT, + }; + dispatch(setCohesionEventStates(eventData)); registerUser(); }; diff --git a/src/register/RegistrationPage.test.jsx b/src/register/RegistrationPage.test.jsx index 5f23e2c2..8248ae6c 100644 --- a/src/register/RegistrationPage.test.jsx +++ b/src/register/RegistrationPage.test.jsx @@ -17,6 +17,7 @@ import { setUserPipelineDataLoaded, } from './data/actions'; import { INTERNAL_SERVER_ERROR } from './data/constants'; +import mockTagular from '../cohesion/utils'; import { NOT_INITIALIZED } from './data/optimizelyExperiment/helper'; import useAutoGeneratedUsernameExperimentVariation from './data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation'; @@ -37,6 +38,7 @@ jest.mock('./data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariati const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); +mockTagular(); jest.mock('react-router-dom', () => { const mockNavigation = jest.fn(); @@ -106,6 +108,7 @@ describe('RegistrationPage', () => { usernameSuggestions: [], }, + cohesion: { eventData: {} }, commonComponents: { thirdPartyAuthApiStatus: null, thirdPartyAuthContext, diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index 3013be5f..47962084 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -5,6 +5,10 @@ import { getConfig } from '@edx/frontend-platform'; import { getCountryList, getLocale, useIntl } from '@edx/frontend-platform/i18n'; import PropTypes from 'prop-types'; +import { + ELEMENT_NAME, ELEMENT_TEXT, ELEMENT_TYPES, PAGE_TYPES, +} from '../../cohesion/constants'; +import trackCohesionEvent from '../../cohesion/trackers'; import { FormFieldRenderer } from '../../field-renderer'; import { backupRegistrationFormBegin } from '../data/actions'; import { FIELDS } from '../data/constants'; @@ -100,6 +104,15 @@ const ConfigurableRegistrationForm = (props) => { } // setting marketingEmailsOptIn state for SSO authentication flow for register API call if (name === 'marketingEmailsOptIn') { + if (!value) { + const cohesionEventData = { + pageType: PAGE_TYPES.ACCOUNT_CREATION, + elementType: ELEMENT_TYPES.BUTTON, + webElementText: ELEMENT_TEXT.CREATE_ACCOUNT, + webElementName: ELEMENT_NAME.CREATE_ACCOUNT, + }; + trackCohesionEvent(cohesionEventData); + } dispatch(backupRegistrationFormBegin({ ...backedUpFormData, configurableFormFields: { diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 61a25848..fd423b92 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -99,6 +99,7 @@ describe('ConfigurableRegistrationForm', () => { registrationFormData, usernameSuggestions: [], }, + cohesion: { eventData: {} }, commonComponents: { thirdPartyAuthApiStatus: null, thirdPartyAuthContext, diff --git a/src/register/components/tests/RegistrationFailure.test.jsx b/src/register/components/tests/RegistrationFailure.test.jsx index da8867cd..352530b9 100644 --- a/src/register/components/tests/RegistrationFailure.test.jsx +++ b/src/register/components/tests/RegistrationFailure.test.jsx @@ -99,6 +99,7 @@ describe('RegistrationFailure', () => { registrationFormData, usernameSuggestions: [], }, + cohesion: { eventData: {} }, commonComponents: { thirdPartyAuthApiStatus: null, thirdPartyAuthContext, diff --git a/src/register/components/tests/ThirdPartyAuth.test.jsx b/src/register/components/tests/ThirdPartyAuth.test.jsx index 7706d185..74f5da34 100644 --- a/src/register/components/tests/ThirdPartyAuth.test.jsx +++ b/src/register/components/tests/ThirdPartyAuth.test.jsx @@ -9,6 +9,7 @@ import { fireEvent, render } from '@testing-library/react'; import { BrowserRouter as Router } from 'react-router-dom'; import configureStore from 'redux-mock-store'; +import mockTagular from '../../../cohesion/utils'; import { COMPLETE_STATE, LOGIN_PAGE, PENDING_STATE, REGISTER_PAGE, } from '../../../data/constants'; @@ -26,6 +27,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ getLocale: jest.fn(), })); jest.mock('../../data/optimizelyExperiment/useAutoGeneratedUsernameExperimentVariation', () => jest.fn()); +mockTagular(); const IntlRegistrationPage = injectIntl(RegistrationPage); const mockStore = configureStore(); @@ -98,6 +100,7 @@ describe('ThirdPartyAuth', () => { registrationFormData, usernameSuggestions: [], }, + cohesion: { eventData: {} }, commonComponents: { thirdPartyAuthApiStatus: null, thirdPartyAuthContext, From b69ed6e422b8d06fd99da59cedb0bf5e9079eeff Mon Sep 17 00:00:00 2001 From: ayesha waris <73840786+ayesha-waris@users.noreply.github.com> Date: Fri, 18 Oct 2024 14:42:28 +0500 Subject: [PATCH 52/82] fix: fixed opt out event text (#1330) --- src/register/components/ConfigurableRegistrationForm.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index 47962084..3cf11b47 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -108,8 +108,8 @@ const ConfigurableRegistrationForm = (props) => { const cohesionEventData = { pageType: PAGE_TYPES.ACCOUNT_CREATION, elementType: ELEMENT_TYPES.BUTTON, - webElementText: ELEMENT_TEXT.CREATE_ACCOUNT, - webElementName: ELEMENT_NAME.CREATE_ACCOUNT, + webElementText: ELEMENT_TEXT.OPT_IN_TEXT, + webElementName: ELEMENT_NAME.OPT_OUT, }; trackCohesionEvent(cohesionEventData); } From 009125c3efb6a13fa2bfc1280af952d6911b49b0 Mon Sep 17 00:00:00 2001 From: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> Date: Fri, 18 Oct 2024 18:28:40 +0500 Subject: [PATCH 53/82] fix: triggered login event on success (#1331) * fix: triggered login event on success * fix: fixed failing test cases --------- Co-authored-by: ayeshoali --- src/common-components/RedirectLogistration.jsx | 6 ++++++ src/login/LoginPage.jsx | 9 ++------- .../tests/ProgressiveProfiling.test.jsx | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/common-components/RedirectLogistration.jsx b/src/common-components/RedirectLogistration.jsx index 24603f6a..05ba98e7 100644 --- a/src/common-components/RedirectLogistration.jsx +++ b/src/common-components/RedirectLogistration.jsx @@ -1,7 +1,10 @@ +import { useSelector } from 'react-redux'; + import { getConfig } from '@edx/frontend-platform'; import PropTypes from 'prop-types'; import { Navigate } from 'react-router-dom'; +import trackCohesionEvent from '../cohesion/trackers'; import { AUTHN_PROGRESSIVE_PROFILING, RECOMMENDATIONS, REDIRECT, } from '../data/constants'; @@ -21,9 +24,12 @@ const RedirectLogistration = (props) => { registrationEmbedded, host, } = props; + const cohesionEventData = useSelector(state => state.cohesion.eventData); let finalRedirectUrl = ''; if (success) { + // This event is used by cohestion upon successful login + trackCohesionEvent(cohesionEventData); // If we're in a third party auth pipeline, we must complete the pipeline // once user has successfully logged in. Otherwise, redirect to the specified redirect url. // Note: For multiple enterprise use case, we need to make sure that user first visits the diff --git a/src/login/LoginPage.jsx b/src/login/LoginPage.jsx index 107541b8..27b97e60 100644 --- a/src/login/LoginPage.jsx +++ b/src/login/LoginPage.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { connect, useDispatch, useSelector } from 'react-redux'; +import { connect, useDispatch } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { injectIntl, useIntl } from '@edx/frontend-platform/i18n'; @@ -24,7 +24,6 @@ import { ELEMENT_NAME, ELEMENT_TEXT, ELEMENT_TYPES, PAGE_TYPES, } from '../cohesion/constants'; import { setCohesionEventStates } from '../cohesion/data/actions'; -import trackCohesionEvent from '../cohesion/trackers'; import { FormGroup, InstitutionLogistration, @@ -78,7 +77,6 @@ const LoginPage = (props) => { const dispatch = useDispatch(); const activationMsgType = getActivationStatus(); const queryParams = useMemo(() => getAllPossibleQueryParams(), []); - const cohesionEventData = useSelector(state => state.cohesion.eventData); const [formFields, setFormFields] = useState({ ...backedUpFormData.formFields }); const [errorCode, setErrorCode] = useState({ type: '', count: 0, context: {} }); @@ -92,13 +90,10 @@ const LoginPage = (props) => { useEffect(() => { if (loginResult.success) { trackLoginSuccess(); - // This event is used by cohestion upon successful login - trackCohesionEvent(cohesionEventData); - // Remove this cookie that was set to capture marketingEmailsOptIn for the onboarding component removeCookie('ssoPipelineRedirectionDone'); } - }, [loginResult, cohesionEventData]); + }, [loginResult]); useEffect(() => { const payload = { ...queryParams }; diff --git a/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx b/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx index c5786b2e..2654aa79 100644 --- a/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx +++ b/src/progressive-profiling/tests/ProgressiveProfiling.test.jsx @@ -11,6 +11,7 @@ import { import { MemoryRouter, mockNavigate, useLocation } from 'react-router-dom'; import configureStore from 'redux-mock-store'; +import mockTagular from '../../cohesion/utils'; import { APP_NAME, AUTHN_PROGRESSIVE_PROFILING, @@ -25,6 +26,7 @@ import ProgressiveProfiling from '../ProgressiveProfiling'; const IntlProgressiveProfilingPage = injectIntl(ProgressiveProfiling); const mockStore = configureStore(); +mockTagular(); jest.mock('@edx/frontend-platform/analytics', () => ({ sendPageEvent: jest.fn(), @@ -55,6 +57,13 @@ jest.mock('react-router-dom', () => { }; }); +const eventData = { + pageType: 'test-page', + elementType: 'test-element-type', + webElementText: 'test-element-text', + webElementName: 'test-element-name', +}; + describe('ProgressiveProfilingTests', () => { let store = {}; @@ -252,6 +261,9 @@ describe('ProgressiveProfilingTests', () => { ...initialState.welcomePage, success: true, }, + cohesion: { + eventData, + }, }); const { container } = render(reduxWrapper()); const nextButton = container.querySelector('button.btn-brand'); @@ -278,6 +290,9 @@ describe('ProgressiveProfilingTests', () => { ...initialState.welcomePage, success: true, }, + cohesion: { + eventData, + }, }); const { container } = render(reduxWrapper()); @@ -421,6 +436,9 @@ describe('ProgressiveProfilingTests', () => { ...initialState.welcomePage, success: true, }, + cohesion: { + eventData, + }, }); render(reduxWrapper()); From 9239df36201fb645788f19d467ade78406c8600b Mon Sep 17 00:00:00 2001 From: Awais Ansari <79941147+awais-ansari@users.noreply.github.com> Date: Fri, 18 Oct 2024 19:26:04 +0500 Subject: [PATCH 54/82] fix: cohesion script and SSO issue (#1332) * fix: userId error on script load * fix: SSO cohesion event --- .eslintignore | 1 - public/index.html | 14 --- src/MainApp.jsx | 3 + src/cohesion/index.js | 111 ++++++++++-------- .../RedirectLogistration.jsx | 2 +- src/common-components/SocialAuthProviders.jsx | 23 ++-- src/index.jsx | 2 - 7 files changed, 75 insertions(+), 81 deletions(-) diff --git a/.eslintignore b/.eslintignore index 8f123893..8346f2f1 100755 --- a/.eslintignore +++ b/.eslintignore @@ -4,4 +4,3 @@ docs node_modules/ __mocks__/ __snapshots__/ -src/cohesion/index.js diff --git a/public/index.html b/public/index.html index 5ce7bb9e..b21df089 100644 --- a/public/index.html +++ b/public/index.html @@ -18,20 +18,6 @@ crossorigin="anonymous" referrerpolicy="no-referrer" > - <% if (process.env.OPTIMIZELY_URL) { %> <% } else if (process.env.OPTIMIZELY_PROJECT_ID) { %> diff --git a/src/MainApp.jsx b/src/MainApp.jsx index b107e365..d37889b8 100755 --- a/src/MainApp.jsx +++ b/src/MainApp.jsx @@ -5,6 +5,7 @@ import { AppProvider } from '@edx/frontend-platform/react'; import { Helmet } from 'react-helmet'; import { Navigate, Route, Routes } from 'react-router-dom'; +import Cohesion from './cohesion'; import { EmbeddedRegistrationRoute, NotFoundPage, registerIcons, UnAuthOnlyRoute, } from './common-components'; @@ -29,11 +30,13 @@ import { RegistrationPage } from './register'; import { ResetPasswordPage } from './reset-password'; import './index.scss'; +import './cohesion/cohesion.scss'; registerIcons(); const MainApp = () => ( + diff --git a/src/cohesion/index.js b/src/cohesion/index.js index 2239527f..6acfa77e 100644 --- a/src/cohesion/index.js +++ b/src/cohesion/index.js @@ -1,52 +1,63 @@ -module.exports = () => { - if (process.env.COHESION_WRITE_KEY && process.env.COHESION_SOURCE_KEY) { - !(function (co, h, e, s, i, o, n) { - const d = "documentElement"; - const a = "className"; - h[d][a] += " preampjs fusejs"; - n.k = e; - co._Cohesion = n; - co._Preamp = { k: s, start: new Date() }; - co._Fuse = { k: i }; - co._Tagular = { k: o }; - [e, s, i, o].map((x) => { - co[x] = - co[x] || - function () { - (co[x].q = co[x].q || []).push([].slice.call(arguments)); - }; - }); - const b = function () { - const u = h[d][a]; - h[d][a] = u.replace(/ ?preampjs| ?fusejs/g, ""); - }; - h.addEventListener("DOMContentLoaded", () => { - co.setTimeout(b, 3e3); - co._Preamp.docReady = co._Fuse.docReady = !0; - }); - const z = h.createElement("script"); - z.async = 1; - z.src = "https://cdn.cohesionapps.com/cohesion/cohesion-latest.min.js"; - z.onerror = function () { - const ce = "error"; - const f = "function"; - for (const o of co[e].q || []) { - o[0] === ce && typeof o[1] === f && o[1](); +import { useEffect } from 'react'; + +const CohesionScript = () => { + useEffect(() => { + const cohesionScript = document.createElement('script'); + const idStitching = document.createElement('script'); + + cohesionScript.innerHTML = ` + !function (co, h, e, s, i, o, n) { + var d = 'documentElement'; var a = 'className'; h[d][a] += ' preampjs fusejs'; + n.k = e; co._Cohesion = n; co._Preamp = { k: s, start: new Date }; co._Fuse = { k: i }; co._Tagular = { k: o }; + [e, s, i, o].map(function (x) { co[x] = co[x] || function () { (co[x].q = co[x].q || []).push([].slice.call(arguments)) } }); + var b = function () { var u = h[d][a]; h[d][a] = u.replace(/ ?preampjs| ?fusejs/g, '') }; + h.addEventListener('DOMContentLoaded', function () { + co.setTimeout(b, 3e3); + co._Preamp.docReady = co._Fuse.docReady = !0 + }); var z = h.createElement('script'); + z.async = 1; z.src = 'https://cdn.cohesionapps.com/cohesion/cohesion-latest.min.js'; + z.onerror = function () { var ce = 'error',f = 'function'; for (var o of co[e].q || []) o[0] === ce && typeof o[1] == f && o[1](); co[e] = function (n, cb) { n === ce && typeof cb == f && cb() }; b() }; + h.head.appendChild(z); + } + (window, document, 'cohesion', 'preamp', 'fuse', 'tagular', { + tagular: { + writeKey: "${process.env.COHESION_WRITE_KEY}", + sourceKey: "${process.env.COHESION_SOURCE_KEY}", + taggy: { + enabled: true + } } - co[e] = function (n, cb) { - n === ce && typeof cb === f && cb(); - }; - b(); - }; - h.head.appendChild(z); - })(window, document, "cohesion", "preamp", "fuse", "tagular", { - tagular: { - writeKey: process.env.COHESION_WRITE_KEY, - sourceKey: process.env.COHESION_SOURCE_KEY, - taggy: { - enabled: true, - }, - }, - }); - } + }); + `; + document.head.appendChild(cohesionScript); + + // Id Stitching script (executed after the cohesionScript is loaded) + cohesionScript.onload = () => { + idStitching.innerHTML = ` + window.tagular("beam", { + "@type": "core.Identify.v1", + traits: {}, + externalIds: [ + { + id: window.analytics.user().anonymousId(), + type: "segment_anonym_id", + collection: "users", + encoding: "none", + }, + ], + }); + `; + document.head.appendChild(idStitching); + }; + + // Cleanup + return () => { + document.head.removeChild(cohesionScript); + document.head.removeChild(idStitching); + }; + }, []); + + return null; }; + +export default CohesionScript; diff --git a/src/common-components/RedirectLogistration.jsx b/src/common-components/RedirectLogistration.jsx index 05ba98e7..84bb4610 100644 --- a/src/common-components/RedirectLogistration.jsx +++ b/src/common-components/RedirectLogistration.jsx @@ -28,7 +28,7 @@ const RedirectLogistration = (props) => { let finalRedirectUrl = ''; if (success) { - // This event is used by cohestion upon successful login + // This event is used by cohesion upon successful login trackCohesionEvent(cohesionEventData); // If we're in a third party auth pipeline, we must complete the pipeline // once user has successfully logged in. Otherwise, redirect to the specified redirect url. diff --git a/src/common-components/SocialAuthProviders.jsx b/src/common-components/SocialAuthProviders.jsx index 7b78d2c8..744d7a38 100644 --- a/src/common-components/SocialAuthProviders.jsx +++ b/src/common-components/SocialAuthProviders.jsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -9,8 +9,8 @@ import { Login } from '@openedx/paragon/icons'; import PropTypes from 'prop-types'; import messages from './messages'; -import { PAGE_TYPES } from '../cohesion/constants'; -import { setCohesionEventStates } from '../cohesion/data/actions'; +import { ELEMENT_TYPES, PAGE_TYPES } from '../cohesion/constants'; +import trackCohesionEvent from '../cohesion/trackers'; import { LOGIN_PAGE, REGISTER_PAGE, SUPPORTED_ICON_CLASSES, } from '../data/constants'; @@ -18,22 +18,19 @@ import { setCookie } from '../data/utils'; const SocialAuthProviders = (props) => { const { formatMessage } = useIntl(); - const dispatch = useDispatch(); const { referrer, socialAuthProviders } = props; const registrationFields = useSelector(state => state.register.registrationFormData); - function handleSubmit(e) { + function handleSubmit(e, providerName) { e.preventDefault(); - const elementType = e.target.nodeName; - const elementText = e.target.name; const eventData = { pageType: referrer === LOGIN_PAGE ? PAGE_TYPES.SIGN_IN : PAGE_TYPES.ACCOUNT_CREATION, - elementType, - webElementText: elementText, - webElementName: elementText.toLowerCase(), + elementType: ELEMENT_TYPES.BUTTON, + webElementText: providerName, + webElementName: providerName.toLowerCase(), }; - - dispatch(setCohesionEventStates(eventData)); + // This event is used by cohesion upon successful login + trackCohesionEvent(eventData); if (referrer === REGISTER_PAGE) { setCookie('marketingEmailsOptIn', registrationFields?.configurableFormFields?.marketingEmailsOptIn); @@ -49,7 +46,7 @@ const SocialAuthProviders = (props) => { type="button" className={`btn-social btn-${provider.id} ${index % 2 === 0 ? 'mr-3' : ''}`} data-provider-url={referrer === LOGIN_PAGE ? provider.loginUrl : provider.registerUrl} - onClick={handleSubmit} + onClick={(event) => handleSubmit(event, provider?.name)} > {provider.iconImage ? (