From 55402ee8c5e91d389d0bb63159d08d164839c483 Mon Sep 17 00:00:00 2001 From: Matthew Crouch Date: Wed, 12 Aug 2026 16:48:56 -0400 Subject: [PATCH 1/4] EDSC-4678: Adds GrowthBook SDK for A/B testing --- bin/deploy-bamboo.sh | 5 + .../package-lock.json | 2 +- .../package.json | 2 +- .../lib/earthdata-search-functions.ts | 18 + cdk/earthdata-search/package-lock.json | 2 +- cdk/earthdata-search/package.json | 2 +- package-lock.json | 360 ++++++++++-------- package.json | 2 + .../__tests__/handler.test.js | 76 ++++ serverless/src/experimentLogger/handler.js | 56 +++ static.config.json | 8 +- static/src/js/App.jsx | 41 +- .../GrowthBookLoader/GrowthBookLoader.tsx | 44 +++ .../__tests__/GrowthBookLoader.test.tsx | 89 +++++ .../GrowthBookWrapper/GrowthBookWrapper.tsx | 52 +++ .../__tests__/GrowthBookWrapper.test.tsx | 54 +++ static/src/js/routes/Home/Home.tsx | 6 +- .../js/routes/Home/__tests__/Home.test.tsx | 38 +- .../experiments/__tests__/logEvent.test.ts | 52 +++ .../js/util/metrics/experiments/logEvent.ts | 40 ++ .../request/__tests__/loggerRequest.test.js | 14 + static/src/js/util/request/loggerRequest.js | 4 + .../__tests__/createGrowthBookSlice.test.ts | 28 ++ .../zustand/slices/createGrowthBookSlice.ts | 22 ++ static/src/js/zustand/types.ts | 13 + static/src/js/zustand/useEdscStore.ts | 2 + 26 files changed, 842 insertions(+), 190 deletions(-) create mode 100644 serverless/src/experimentLogger/__tests__/handler.test.js create mode 100644 serverless/src/experimentLogger/handler.js create mode 100644 static/src/js/components/GrowthBookLoader/GrowthBookLoader.tsx create mode 100644 static/src/js/components/GrowthBookLoader/__tests__/GrowthBookLoader.test.tsx create mode 100644 static/src/js/components/GrowthBookWrapper/GrowthBookWrapper.tsx create mode 100644 static/src/js/components/GrowthBookWrapper/__tests__/GrowthBookWrapper.test.tsx create mode 100644 static/src/js/util/metrics/experiments/__tests__/logEvent.test.ts create mode 100644 static/src/js/util/metrics/experiments/logEvent.ts create mode 100644 static/src/js/zustand/slices/__tests__/createGrowthBookSlice.test.ts create mode 100644 static/src/js/zustand/slices/createGrowthBookSlice.ts diff --git a/bin/deploy-bamboo.sh b/bin/deploy-bamboo.sh index 8138d4b9bb..ff4112ce3d 100755 --- a/bin/deploy-bamboo.sh +++ b/bin/deploy-bamboo.sh @@ -38,8 +38,13 @@ config="`jq '.application.mapPointsSimplifyThreshold = $newValue' --arg newValue config="`jq '.application.nlpSearch = $newValue' --arg newValue $bamboo_NLP_SEARCH <<< $config`" config="`jq '.application.numberOfGranules = $newValue' --arg newValue "$bamboo_NUMBER_OF_GRANULES" <<< $config`" config="`jq '.application.placeLabelsStyleUrl = $newValue' --arg newValue $bamboo_PLACE_LABELS_STYLE_URL <<< $config`" +config="`jq '.application.growthbookEnabled = $newValue' --arg newValue $bamboo_GROWTHBOOK_ENABLED <<< $config`" + config="`jq '.environment.production.apiHost = $newValue' --arg newValue $bamboo_API_HOST <<< $config`" config="`jq '.environment.production.edscHost = $newValue' --arg newValue $bamboo_EDSC_HOST <<< $config`" +config="`jq '.environment.production.growthbookFirehoseStreamName = $newValue' --arg newValue $bamboo_GROWTHBOOK_FIREHOSE_STREAM_NAME <<< $config`" +config="`jq '.environment.production.growthbookApiHost = $newValue' --arg newValue $bamboo_GROWTHBOOK_API_HOST <<< $config`" +config="`jq '.environment.production.growthbookClientKey = $newValue' --arg newValue $bamboo_GROWTHBOOK_CLIENT_KEY <<< $config`" # Wrap the bamboo variable in quotes for a string value that could contain spaces config="`jq '.application.emergencyNotification = $newValue' --arg newValue "$bamboo_EMERGENCY_NOTIFICATION" <<< $config`" diff --git a/cdk/earthdata-search-infrastructure/package-lock.json b/cdk/earthdata-search-infrastructure/package-lock.json index f80b8d64c6..2c5a48ec9b 100644 --- a/cdk/earthdata-search-infrastructure/package-lock.json +++ b/cdk/earthdata-search-infrastructure/package-lock.json @@ -17,7 +17,7 @@ }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "22.7.9", + "@types/node": "^22.7.9", "aws-cdk": "^2.1129.0", "jest": "^29.7.0", "ts-jest": "^29.2.5", diff --git a/cdk/earthdata-search-infrastructure/package.json b/cdk/earthdata-search-infrastructure/package.json index e60a689c2c..935604dd47 100644 --- a/cdk/earthdata-search-infrastructure/package.json +++ b/cdk/earthdata-search-infrastructure/package.json @@ -12,7 +12,7 @@ }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "22.7.9", + "@types/node": "^22.7.9", "aws-cdk": "^2.1129.0", "jest": "^29.7.0", "ts-jest": "^29.2.5", diff --git a/cdk/earthdata-search/lib/earthdata-search-functions.ts b/cdk/earthdata-search/lib/earthdata-search-functions.ts index 7402cbf2af..095e694470 100644 --- a/cdk/earthdata-search/lib/earthdata-search-functions.ts +++ b/cdk/earthdata-search/lib/earthdata-search-functions.ts @@ -279,6 +279,24 @@ export class Functions extends Construct { functionNamePrefix }) + /** + * Experiment Logger + */ + const experimentLoggerNestedStack = new cdk.NestedStack(scope, 'ExperimentLoggerNestedStack') + // eslint-disable-next-line no-new + new application.NodeJsFunction(experimentLoggerNestedStack, 'ExperimentLoggerLambda', { + ...defaultLambdaConfig, + api: { + apiGatewayDeployment, + apiGatewayRestApi, + methods: ['POST'], + path: 'experiment_logger' + }, + entry: '../../serverless/src/experimentLogger/handler.js', + functionName: 'experimentLogger', + functionNamePrefix + }) + /** * Fix Retrieval Collection Metadata */ diff --git a/cdk/earthdata-search/package-lock.json b/cdk/earthdata-search/package-lock.json index f6d6cd9b74..963d759d81 100644 --- a/cdk/earthdata-search/package-lock.json +++ b/cdk/earthdata-search/package-lock.json @@ -17,7 +17,7 @@ }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "22.7.9", + "@types/node": "^22.7.9", "aws-cdk": "^2.1129.0", "esbuild": "^0.24.2", "jest": "^29.7.0", diff --git a/cdk/earthdata-search/package.json b/cdk/earthdata-search/package.json index f74bb99511..1b83965b79 100644 --- a/cdk/earthdata-search/package.json +++ b/cdk/earthdata-search/package.json @@ -12,7 +12,7 @@ }, "devDependencies": { "@types/jest": "^29.5.14", - "@types/node": "22.7.9", + "@types/node": "^22.7.9", "aws-cdk": "^2.1129.0", "esbuild": "^0.24.2", "jest": "^29.7.0", diff --git a/package-lock.json b/package-lock.json index a8f5f92f25..3c92d0747b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@apollo/client": "^3.13.8", "@apollo/server": "^5.5.1", "@as-integrations/aws-lambda": "^4.0.1", + "@aws-sdk/client-firehose": "^3.1105.0", "@aws-sdk/client-lambda": "^3.1049.0", "@aws-sdk/client-s3": "^3.352.0", "@aws-sdk/client-secrets-manager": "^3.352.0", @@ -38,6 +39,7 @@ "@edsc/timeline": "^1.1.8", "@graphql-tools/merge": "^9.0.24", "@graphql-tools/schema": "^10.0.23", + "@growthbook/growthbook-react": "^1.6.5", "@luckycatfactory/esbuild-graphql-loader": "^3.8.1", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "2.0.0", @@ -1000,6 +1002,25 @@ "node": ">=14.0.0" } }, + "node_modules/@aws-sdk/client-firehose": { + "version": "3.1105.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-firehose/-/client-firehose-3.1105.0.tgz", + "integrity": "sha512-ZtzH5m9QjGE9ScGNf5+WwBcqKyGwbaJxbLRWffsHoE3vdYolQUb8TANM6EXWEOZY/K7vO52D9glEmoIjQXklcg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-node": "^3.972.78", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-lambda": { "version": "3.1049.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1049.0.tgz", @@ -1240,17 +1261,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.974.12", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.12.tgz", - "integrity": "sha512-qrqgioqYFjwR6LatVNS1L2Vk++EwRIxqSQXPKNv5Ofux2D8UNgqMQ1znnMyEImXquVPTtbf71fc128pvmU6y9A==", + "version": "3.977.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.6.tgz", + "integrity": "sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@aws-sdk/xml-builder": "^3.972.24", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.974.2", + "@aws-sdk/xml-builder": "^3.972.37", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -1258,6 +1279,15 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/core/node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/crc64-nvme": { "version": "3.972.4", "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.4.tgz", @@ -1272,15 +1302,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.38.tgz", - "integrity": "sha512-m3WjZEgPtioMhPmwqUt+DhlTJ2i9ufR6DhfkyXojb9puEvfR+ur2U5shavu5/Cc9WHHsDCvALi6UFHgcqjhQ5w==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.67.tgz", + "integrity": "sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1288,17 +1318,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.40", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.40.tgz", - "integrity": "sha512-D78L/m2Dr6cJnnSvWoAudPhQmCwmJ7j6APXsPYmFpPaKfQTfCSu0rdm8j14Np+VmXF9z8Aj8HE3xFpsrwtfgeg==", + "version": "3.972.69", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.69.tgz", + "integrity": "sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1306,23 +1336,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.42.tgz", - "integrity": "sha512-Mu5ESvFXeinafVM8jTIvRqcvK2Ehj4kz3auT39yUcHwu1Vfxo6xRlmUafdKLW4tusjAJukQwK09sCSMgOm7OKg==", + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.12.tgz", + "integrity": "sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-login": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-login": "^3.972.74", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1330,16 +1360,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.42.tgz", - "integrity": "sha512-O6WkZga3kf0yqyJYd1dbeJqVhEgJx/x1UaLgtbR+XuL/YP+K5y6QTxQKL7ka9z3jnQASESKGAPnRyt4D5hQrxA==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.74.tgz", + "integrity": "sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1347,21 +1377,21 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.43", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.43.tgz", - "integrity": "sha512-D/DJmbrWRP5BXEO3FH+ar4el+2n6OlGofiud7dQun2jES+AQEJjczenp1jBb4MBN7CpGpS8nsWGQLtuzc9tQbA==", + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.78.tgz", + "integrity": "sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.38", - "@aws-sdk/credential-provider-http": "^3.972.40", - "@aws-sdk/credential-provider-ini": "^3.972.42", - "@aws-sdk/credential-provider-process": "^3.972.38", - "@aws-sdk/credential-provider-sso": "^3.972.42", - "@aws-sdk/credential-provider-web-identity": "^3.972.42", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/credential-provider-imds": "^4.3.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/credential-provider-env": "^3.972.67", + "@aws-sdk/credential-provider-http": "^3.972.69", + "@aws-sdk/credential-provider-ini": "^3.973.12", + "@aws-sdk/credential-provider-process": "^3.972.67", + "@aws-sdk/credential-provider-sso": "^3.973.11", + "@aws-sdk/credential-provider-web-identity": "^3.972.73", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1369,15 +1399,15 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.38", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.38.tgz", - "integrity": "sha512-EnbYVajGgbkb24s0K1eo4VNAPV5mHIET7LSvirTaFCwkfrfaOJxtSE+wY/tJdKDS21cEYkZs2ruCaAm+W4iblg==", + "version": "3.972.67", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.67.tgz", + "integrity": "sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1385,17 +1415,17 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.42.tgz", - "integrity": "sha512-RVV/9NbFwI8ZHEH5dn39lGyFmSbSVj1+orZdr6QsOe1mW9DCglmlen0cFaNZmCcqkqc7erNRHNBduxbeZuHAnw==", + "version": "3.973.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.11.tgz", + "integrity": "sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/token-providers": "3.1049.0", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/token-providers": "3.1103.0", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1403,16 +1433,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.42.tgz", - "integrity": "sha512-/67fXX0ddllD4u2Nujc5PvT4byHgpMUfz6+RxIKi/0nFIckeorm7JvXgzBuDyVKw0s58EbofmETDWUf9vTEuHQ==", + "version": "3.972.73", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.73.tgz", + "integrity": "sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1612,20 +1642,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.10.tgz", - "integrity": "sha512-FtQ/Bt327peZJuyo4WZSOLVUTw9ujRxntepiC7L65FxA2P82Xlq0g14T22BuqBUeMjDoxa9nvwiMHjLIfP3eUg==", + "version": "3.997.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.41.tgz", + "integrity": "sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/signature-v4-multi-region": "^3.996.27", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/fetch-http-handler": "^5.4.2", - "@smithy/node-http-handler": "^4.7.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.43", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1668,15 +1696,14 @@ } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.27.tgz", - "integrity": "sha512-0Phbz4t6HI3D3skxvG2uI+VWU034/nSIw1T8d+FPzzQG9EQTrw94o9mOKO2Gv3n3Oc8P7JD7RAUxkoneLWv5Eg==", + "version": "3.996.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.43.tgz", + "integrity": "sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/signature-v4": "^5.4.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/types": "^3.974.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1684,16 +1711,16 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1049.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1049.0.tgz", - "integrity": "sha512-r7+d0lQMTHKypkmaF5jRTBYLYHCUHzt3gaVoN9SidLhQeWhCmHk3AKrboDTpPF5b7Pt7vKu3+oeMjznM2Eu1ow==", + "version": "3.1103.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1103.0.tgz", + "integrity": "sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.974.12", - "@aws-sdk/nested-clients": "^3.997.10", - "@aws-sdk/types": "^3.973.8", - "@smithy/core": "^3.24.2", - "@smithy/types": "^4.14.1", + "@aws-sdk/core": "^3.977.6", + "@aws-sdk/nested-clients": "^3.997.41", + "@aws-sdk/types": "^3.974.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1701,12 +1728,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.8", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", - "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", + "version": "3.974.2", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -1805,14 +1832,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.24", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.24.tgz", - "integrity": "sha512-V8z5YcDPfsvzrBlj0xR1vhRtocblhYbqdreCJB/voGd4Sr5zjNAeWxexbnqVtskTJe0vFb5KMqbSL++ePl+zRw==", + "version": "3.972.37", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", + "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", "license": "Apache-2.0", "dependencies": { - "@nodable/entities": "2.1.0", - "@smithy/types": "^4.14.1", - "fast-xml-parser": "5.7.3", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -5052,6 +5077,33 @@ "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, + "node_modules/@growthbook/growthbook": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook/-/growthbook-1.6.5.tgz", + "integrity": "sha512-mUaMsgeUTpRIUOTn33EUXHRK6j7pxBjwqH4WpQyq+pukjd1AIzWlEa6w7i6bInJUcweGgP2beXZmaP6b6UPn7A==", + "license": "MIT", + "dependencies": { + "dom-mutator": "^0.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@growthbook/growthbook-react": { + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@growthbook/growthbook-react/-/growthbook-react-1.6.5.tgz", + "integrity": "sha512-afi/RUbwazVNKv2acn6wDQz4BJNRAEpwIuHfggQup2/aE5PLAxy3+95gjjRMgCcPR0Pf3sFmhYGvOmxLD0ZRbQ==", + "license": "MIT", + "dependencies": { + "@growthbook/growthbook": "^1.6.5" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": "^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" + } + }, "node_modules/@hapi/boom": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", @@ -8488,13 +8540,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.24.3", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", - "integrity": "sha512-Ep/7tPamGY8mgESE3LyLKtxJyy6U52WWAqr/3wial47Sj4u3PiIF73AOGI27UyLy9duTkhZbgzodOfLV4TduZg==", + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.31.1.tgz", + "integrity": "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==", "license": "Apache-2.0", "dependencies": { - "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.14.2", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -8502,13 +8553,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.3.tgz", - "integrity": "sha512-I2Bti0DKFo2IJyN28ijCsx51BAumEYR4/1yZ1FXyBygy9MqbnMqCev4JPth/MbpRfBSRAX35hITSnAdJRo1u5w==", + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.16.tgz", + "integrity": "sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -8586,13 +8637,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.3.tgz", - "integrity": "sha512-F+DRf8IJazRJgYog2A/yJK7eYVc0rqTlRzO+5ZxjJd4WkZoKz0IJRncf7G6t1pdVT3kryJcwuTFhN1c5m6N47A==", + "version": "5.6.13", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.13.tgz", + "integrity": "sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -8778,13 +8829,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", - "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "version": "4.9.13", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.13.tgz", + "integrity": "sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -8870,13 +8921,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.3.tgz", - "integrity": "sha512-53+75QuPl6DL+ct6vVEB51FDO5oulXr20TPV46VvJZg76lIlXNWfxi8j+G2V/t0I2qxCBOa3vX/8bmjrpFVo9g==", + "version": "5.6.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.12.tgz", + "integrity": "sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.24.3", - "@smithy/types": "^4.14.2", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", "tslib": "^2.6.2" }, "engines": { @@ -8902,9 +8953,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.2", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", - "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "version": "4.16.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -16409,6 +16460,15 @@ "csstype": "^3.0.2" } }, + "node_modules/dom-mutator": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/dom-mutator/-/dom-mutator-0.6.0.tgz", + "integrity": "sha512-iCt9o0aYfXMUkz/43ZOAUFQYotjGB+GNbYJiJdz4TgXkyToXbbRy5S6FbTp72lRBtfpUMwEc1KmpFEU4CZeoNg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/dom-serializer": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", diff --git a/package.json b/package.json index 8176c92e0d..dee411090a 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "@apollo/client": "^3.13.8", "@apollo/server": "^5.5.1", "@as-integrations/aws-lambda": "^4.0.1", + "@aws-sdk/client-firehose": "^3.1105.0", "@aws-sdk/client-lambda": "^3.1049.0", "@aws-sdk/client-s3": "^3.352.0", "@aws-sdk/client-secrets-manager": "^3.352.0", @@ -141,6 +142,7 @@ "@edsc/timeline": "^1.1.8", "@graphql-tools/merge": "^9.0.24", "@graphql-tools/schema": "^10.0.23", + "@growthbook/growthbook-react": "^1.6.5", "@luckycatfactory/esbuild-graphql-loader": "^3.8.1", "@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-select": "2.0.0", diff --git a/serverless/src/experimentLogger/__tests__/handler.test.js b/serverless/src/experimentLogger/__tests__/handler.test.js new file mode 100644 index 0000000000..05ab65a756 --- /dev/null +++ b/serverless/src/experimentLogger/__tests__/handler.test.js @@ -0,0 +1,76 @@ +import { FirehoseClient } from '@aws-sdk/client-firehose' + +import experimentLogger from '../handler' + +import * as getEnvironmentConfig from '../../../../sharedUtils/config' + +vi.mock('@aws-sdk/client-firehose', async () => { + const original = await vi.importActual('@aws-sdk/client-firehose') + const sendMock = vi.fn().mockResolvedValue() + + return { + ...original, + FirehoseClient: vi.fn(class { + send = sendMock + }) + } +}) + +const client = new FirehoseClient() + +beforeEach(() => { + vi.spyOn(getEnvironmentConfig, 'getEnvironmentConfig').mockImplementation(() => ({ growthbookFirehoseStreamName: 'mock-stream-name' })) +}) + +describe('experimentLogger', () => { + test('logs the event body', async () => { + const event = { + body: JSON.stringify({ + params: { + eventData: { + eventType: 'test_event', + userId: 'test_user' + } + } + }) + } + + const response = await experimentLogger(event) + + expect(response.statusCode).toBe(200) + + expect(client.send).toHaveBeenCalledTimes(1) + expect(client.send).toHaveBeenCalledWith(expect.objectContaining({ + input: expect.objectContaining({ + DeliveryStreamName: 'mock-stream-name' + }) + })) + }) + + describe('when the Firehose client throws an error', () => { + test('logs the error and returns a 200 response', async () => { + const error = new Error('Firehose error') + client.send.mockRejectedValueOnce(error) + + const event = { + body: JSON.stringify({ + params: { + eventData: { + eventType: 'test_event', + userId: 'test_user' + } + } + }) + } + + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + + const response = await experimentLogger(event) + + expect(response.statusCode).toBe(200) + + expect(client.send).toHaveBeenCalledTimes(1) + expect(consoleErrorSpy).toHaveBeenCalledWith('Error streaming to Firehose:', error) + }) + }) +}) diff --git a/serverless/src/experimentLogger/handler.js b/serverless/src/experimentLogger/handler.js new file mode 100644 index 0000000000..136cff1ca0 --- /dev/null +++ b/serverless/src/experimentLogger/handler.js @@ -0,0 +1,56 @@ +import { FirehoseClient, PutRecordCommand } from '@aws-sdk/client-firehose' + +import { getApplicationConfig, getEnvironmentConfig } from '../../../sharedUtils/config' + +let firehoseClient + +/** + * Logs an experiment event reported by a client + * @param {Object} event Details about the HTTP request that it received + */ +const experimentLogger = async (event) => { + const { body } = event + const { params = {} } = JSON.parse(body) + const { eventData } = params + + const { + eventType, + userId + } = eventData + + if (!firehoseClient) { + firehoseClient = new FirehoseClient({ region: 'us-east-1' }) + } + + const payload = { + event_id: crypto.randomUUID(), + user_id: userId || 'anonymous', + event_type: eventType, + timestamp: new Date().toISOString(), + ...eventData + } + + const { growthbookFirehoseStreamName } = getEnvironmentConfig() + + const command = new PutRecordCommand({ + DeliveryStreamName: growthbookFirehoseStreamName, + Record: { + Data: new TextEncoder().encode(JSON.stringify(payload)) + } + }) + + try { + await firehoseClient.send(command) + } catch (error) { + console.error('Error streaming to Firehose:', error) + } + + const { defaultResponseHeaders } = getApplicationConfig() + + return { + statusCode: 200, + headers: defaultResponseHeaders + } +} + +export default experimentLogger diff --git a/static.config.json b/static.config.json index 35367d657b..39f665d6e0 100644 --- a/static.config.json +++ b/static.config.json @@ -55,7 +55,8 @@ "mapPointsSimplifyThreshold": 2000, "emergencyNotification": "false", "emergencyNotificationType": "error", - "numberOfGranules": "2.2 billion+" + "numberOfGranules": "2.2 billion+", + "growthbookEnabled": "false" }, "environment": { "test": { @@ -68,7 +69,10 @@ "dbName": "edsc_dev", "databasePort": 5432, "apiHost": "http://localhost:3001", - "edscHost": "http://localhost:8080" + "edscHost": "http://localhost:8080", + "growthbookFirehoseStreamName": "mock-firehose-stream-name", + "growthbookApiHost": "http://localhost:4100", + "growthbookClientKey": "mock-client-key" }, "production": { "apiHost": "http://localhost:3001", diff --git a/static/src/js/App.jsx b/static/src/js/App.jsx index a2b99e232c..ca2fc3a7b5 100644 --- a/static/src/js/App.jsx +++ b/static/src/js/App.jsx @@ -18,6 +18,7 @@ import Home from './routes/Home/Home' // Components import ErrorBoundary from './components/Errors/ErrorBoundary' import NotFound from './components/Errors/NotFound' +import GrowthBookWrapper from './components/GrowthBookWrapper/GrowthBookWrapper' import RouterErrorBoundary from './components/Errors/RouterErrorBoundary' import Spinner from './components/Spinner/Spinner' @@ -307,25 +308,27 @@ const App = () => { return ( - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + ) } diff --git a/static/src/js/components/GrowthBookLoader/GrowthBookLoader.tsx b/static/src/js/components/GrowthBookLoader/GrowthBookLoader.tsx new file mode 100644 index 0000000000..d8dbedd32e --- /dev/null +++ b/static/src/js/components/GrowthBookLoader/GrowthBookLoader.tsx @@ -0,0 +1,44 @@ +import React, { useEffect } from 'react' +import { FeaturesReady, useFeatureIsOn } from '@growthbook/growthbook-react' + +import Spinner from '../Spinner/Spinner' +import useEdscStore from '../../zustand/useEdscStore' + +interface GrowthBookLoaderProps { + children: React.ReactNode +} + +const GrowthBookLoader = ({ children }: GrowthBookLoaderProps) => { + const { setFeatureFlags } = useEdscStore((state) => ({ + setFeatureFlags: state.growthbook.setFeatureFlags + })) + + const nlpSearchValue = useFeatureIsOn('nlpSearch') + + // On initial load, set a user id and session id for GrowthBook tracking. + useEffect(() => { + const gbUserId = window.localStorage.getItem('gbUserId') + if (!gbUserId) { + const newGbUserId = crypto.randomUUID() + window.localStorage.setItem('gbUserId', newGbUserId) + } + + const gbSessionId = window.sessionStorage.getItem('gbSessionId') + if (!gbSessionId) { + const newGbSessionId = crypto.randomUUID() + window.sessionStorage.setItem('gbSessionId', newGbSessionId) + } + }, []) + + useEffect(() => { + setFeatureFlags('nlpSearch', nlpSearchValue) + }, [nlpSearchValue]) + + return ( + }> + {children} + + ) +} + +export default GrowthBookLoader diff --git a/static/src/js/components/GrowthBookLoader/__tests__/GrowthBookLoader.test.tsx b/static/src/js/components/GrowthBookLoader/__tests__/GrowthBookLoader.test.tsx new file mode 100644 index 0000000000..f90906e85f --- /dev/null +++ b/static/src/js/components/GrowthBookLoader/__tests__/GrowthBookLoader.test.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { GrowthBookProvider, useFeatureIsOn } from '@growthbook/growthbook-react' + +import setupTest from '../../../../../../vitestConfigs/setupTest' + +import GrowthBookLoader from '../GrowthBookLoader' +import Spinner from '../../Spinner/Spinner' + +vi.mock('../../Spinner/Spinner', () => ({ default: vi.fn(() => null) })) +vi.mock('@growthbook/growthbook-react', async () => { + const actual = await vi.importActual('@growthbook/growthbook-react') + + return { + ...actual, + useFeatureIsOn: vi.fn().mockReturnValue(true) + } +}) + +const TestComponent = () => ( + + +
Test Children
+
+
+) + +const setup = setupTest({ + Component: TestComponent, + defaultZustandState: { + growthbook: { + setFeatureFlags: vi.fn() + } + } +}) + +describe('GrowthBookLoader', () => { + test('updates zustand store with feature flags', async () => { + const { zustandState } = setup() + + expect(useFeatureIsOn).toHaveBeenCalledTimes(1) + expect(useFeatureIsOn).toHaveBeenCalledWith('nlpSearch') + + expect(zustandState.growthbook.setFeatureFlags).toHaveBeenCalledTimes(1) + expect(zustandState.growthbook.setFeatureFlags).toHaveBeenCalledWith('nlpSearch', true) + }) + + test('saves a user id and session id to localStorage and sessionStorage', async () => { + const localStorageGetItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockReturnValue(null) + const localStorageSetItemSpy = vi.spyOn(Storage.prototype, 'setItem') + + setup() + + expect(localStorageGetItemSpy).toHaveBeenCalledTimes(2) + expect(localStorageGetItemSpy).toHaveBeenNthCalledWith(1, 'gbUserId') + expect(localStorageGetItemSpy).toHaveBeenNthCalledWith(2, 'gbSessionId') + + expect(localStorageSetItemSpy).toHaveBeenCalledTimes(2) + expect(localStorageSetItemSpy).toHaveBeenNthCalledWith(1, 'gbUserId', expect.any(String)) + expect(localStorageSetItemSpy).toHaveBeenNthCalledWith(2, 'gbSessionId', expect.any(String)) + }) + + describe('when the user id and session id already exist in localStorage and sessionStorage', () => { + test('does not overwrite the existing user id and session id', async () => { + const localStorageGetItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('mock-value') + const localStorageSetItemSpy = vi.spyOn(Storage.prototype, 'setItem') + + setup() + + expect(localStorageGetItemSpy).toHaveBeenCalledTimes(2) + expect(localStorageGetItemSpy).toHaveBeenNthCalledWith(1, 'gbUserId') + expect(localStorageGetItemSpy).toHaveBeenNthCalledWith(2, 'gbSessionId') + + expect(localStorageSetItemSpy).toHaveBeenCalledTimes(0) + }) + }) + + test('renders the spinner then the children', async () => { + setup() + + expect(Spinner).toHaveBeenCalledTimes(1) + expect(Spinner).toHaveBeenCalledWith({ + type: 'dots', + className: 'root__spinner spinner spinner--dots spinner--small' + }, {}) + + expect(screen.queryByText('Test Children')).not.toBeInTheDocument() + }) +}) diff --git a/static/src/js/components/GrowthBookWrapper/GrowthBookWrapper.tsx b/static/src/js/components/GrowthBookWrapper/GrowthBookWrapper.tsx new file mode 100644 index 0000000000..dd8f1f55d3 --- /dev/null +++ b/static/src/js/components/GrowthBookWrapper/GrowthBookWrapper.tsx @@ -0,0 +1,52 @@ +import React from 'react' +import { GrowthBook, GrowthBookProvider } from '@growthbook/growthbook-react' + +// @ts-expect-error: Types do not exist for this file +import { getApplicationConfig, getEnvironmentConfig } from '../../../../../sharedUtils/config' +import GrowthBookLoader from '../GrowthBookLoader/GrowthBookLoader' + +interface GrowthBookWrapperProps { + children: React.ReactNode +} + +const GrowthBookWrapper = ({ children }: GrowthBookWrapperProps) => { + const { growthbookEnabled } = getApplicationConfig() + + // If GrowthBook is not enabled, return the children + if (growthbookEnabled !== 'true') { + return children + } + + // If GrowthBook is enabled, initialize the GrowthBook client and wrap the children in the GrowthBookProvider + + const { + growthbookApiHost = 'http://localhost:4100', + growthbookClientKey + } = getEnvironmentConfig() + + const growthbook = new GrowthBook({ + apiHost: growthbookApiHost, + clientKey: growthbookClientKey, + enableDevMode: true, + // Only required for A/B testing + // Called every time a user is put into an experiment + trackingCallback: (experiment, result) => { + // TODO call logEvent? + console.log('Experiment Viewed', { + experimentId: experiment.key, + variationId: result.key + }) + } + }) + growthbook.init({}) + + return ( + + + {children} + + + ) +} + +export default GrowthBookWrapper diff --git a/static/src/js/components/GrowthBookWrapper/__tests__/GrowthBookWrapper.test.tsx b/static/src/js/components/GrowthBookWrapper/__tests__/GrowthBookWrapper.test.tsx new file mode 100644 index 0000000000..d1d471e27c --- /dev/null +++ b/static/src/js/components/GrowthBookWrapper/__tests__/GrowthBookWrapper.test.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { screen } from '@testing-library/react' + +import setupTest from '../../../../../../vitestConfigs/setupTest' + +// @ts-expect-error: Types do not exist for this file +import * as getApplicationConfig from '../../../../../../sharedUtils/config' + +import GrowthBookWrapper from '../GrowthBookWrapper' +import GrowthBookLoader from '../../GrowthBookLoader/GrowthBookLoader' + +vi.mock('../../GrowthBookLoader/GrowthBookLoader', () => ({ default: vi.fn(() => null) })) + +const setup = setupTest({ + Component: GrowthBookWrapper, + defaultProps: { + children:
Test Children
+ } +}) + +describe('GrowthBookWrapper', () => { + describe('when growthbookEnabled is false', () => { + test('renders child components', () => { + vi.spyOn(getApplicationConfig, 'getApplicationConfig').mockImplementation(() => ({ + growthbookEnabled: 'false' + })) + + vi.spyOn(getApplicationConfig, 'getEnvironmentConfig').mockImplementation(() => ({ + growthbookClientKey: 'mock-client-key' + })) + + setup() + + expect(screen.getByText('Test Children')).toBeInTheDocument() + + expect(GrowthBookLoader).toHaveBeenCalledTimes(0) + }) + }) + + describe('when growthbookEnabled is true', () => { + test('renders child components', () => { + vi.spyOn(getApplicationConfig, 'getApplicationConfig').mockImplementation(() => ({ + growthbookEnabled: 'true' + })) + + setup() + + expect(GrowthBookLoader).toHaveBeenCalledTimes(1) + expect(GrowthBookLoader).toHaveBeenCalledWith(expect.objectContaining({ + children:
Test Children
+ }), {}) + }) + }) +}) diff --git a/static/src/js/routes/Home/Home.tsx b/static/src/js/routes/Home/Home.tsx index 4dd5e2c487..c7fa416868 100644 --- a/static/src/js/routes/Home/Home.tsx +++ b/static/src/js/routes/Home/Home.tsx @@ -165,13 +165,11 @@ export const Home: React.FC = () => { const [isNlpNavigationPending, setIsNlpNavigationPending] = useState(false) const { isLoading } = useEdscStore(getCollectionsPageInfo) + const featureFlags = useEdscStore((state) => state.growthbook.featureFlags) + const { nlpSearch: isNlpEnabled } = featureFlags const { numberOfGranules } = getApplicationConfig() - // Check if NLP search is enabled. If so, utlize the nlp endpoint and alert users of the change through UI elements. - const { nlpSearch } = getApplicationConfig() - const isNlpEnabled = nlpSearch === 'true' - useEffect(() => { // Focus the search input when the component mounts if (inputRef.current) { diff --git a/static/src/js/routes/Home/__tests__/Home.test.tsx b/static/src/js/routes/Home/__tests__/Home.test.tsx index f7b75502b5..f93ec85752 100644 --- a/static/src/js/routes/Home/__tests__/Home.test.tsx +++ b/static/src/js/routes/Home/__tests__/Home.test.tsx @@ -9,8 +9,6 @@ import HomeTopicCard from '../HomeTopicCard' import HomePortalCard from '../HomePortalCard' import { Home } from '../Home' -// @ts-expect-error: Types do not exist for this file -import { getApplicationConfig } from '../../../../../../sharedUtils/config' import Spinner from '../../../components/Spinner/Spinner' import { routes } from '../../../constants/routes' @@ -330,21 +328,31 @@ describe('Home', () => { }) describe('when nlpSearch is disabled', () => { - beforeEach(() => { - getApplicationConfig.mockReturnValue({ - nlpSearch: 'false' - }) - }) - test('renders temporal and spatial buttons', () => { - setup() + setup({ + overrideZustandState: { + growthbook: { + featureFlags: { + nlpSearch: false + } + } + } + }) expect(screen.getByRole('button', { name: 'Open temporal filters' })).toBeInTheDocument() expect(screen.getByRole('button', { name: 'spatial-selection-dropdown' })).toBeInTheDocument() }) test('calls getCollections and navigate when the search form is submitted with no value', async () => { - const { user, zustandState } = setup() + const { user, zustandState } = setup({ + overrideZustandState: { + growthbook: { + featureFlags: { + nlpSearch: false + } + } + } + }) await user.click(screen.getByRole('button', { name: /search/i })) @@ -356,7 +364,15 @@ describe('Home', () => { }) test('calls getCollections and navigate when the search form is submitted with values', async () => { - const { user, zustandState } = setup() + const { user, zustandState } = setup({ + overrideZustandState: { + growthbook: { + featureFlags: { + nlpSearch: false + } + } + } + }) const searchInput = screen.getByPlaceholderText('Type to search for data') diff --git a/static/src/js/util/metrics/experiments/__tests__/logEvent.test.ts b/static/src/js/util/metrics/experiments/__tests__/logEvent.test.ts new file mode 100644 index 0000000000..9068615315 --- /dev/null +++ b/static/src/js/util/metrics/experiments/__tests__/logEvent.test.ts @@ -0,0 +1,52 @@ +import useEdscStore from '../../../../zustand/useEdscStore' +import logEvent from '../logEvent' + +// @ts-expect-error This file does not have types +import LoggerRequest from '../../../request/loggerRequest' + +describe('logEvent', () => { + test('calls LoggerRequest.logExperiment with the correct parameters when nlpSearch is enabled', async () => { + const loggerRequestMock = vi.spyOn(LoggerRequest.prototype, 'logExperiment').mockResolvedValue({ + status: 200, + statusText: 'OK' + }) + + const { growthbook } = useEdscStore.getState() + growthbook.setFeatureFlags('nlpSearch', true) + + const eventType = 'test_event' + const eventData = 'test_data' + + await logEvent(eventType, eventData) + + expect(loggerRequestMock).toHaveBeenCalledTimes(1) + expect(loggerRequestMock).toHaveBeenCalledWith({ + eventData: { + experiment_id: 'nlpSearch', + variation_id: true, + event_type: eventType, + event_data: eventData, + session_id: expect.any(String), + user_id: expect.any(String) + } + }) + }) + + describe('when the feature flag is not enabled', () => { + test('does not call LoggerRequest.logExperiment and logs a message to the console', async () => { + const loggerRequestMock = vi.spyOn(LoggerRequest.prototype, 'logExperiment') + const consoleLogMock = vi.spyOn(console, 'log').mockImplementation(() => {}) + + const { growthbook } = useEdscStore.getState() + growthbook.setFeatureFlags('nlpSearch', false) + + const eventType = 'test_event' + const eventData = 'test_data' + + await logEvent(eventType, eventData) + + expect(loggerRequestMock).not.toHaveBeenCalled() + expect(consoleLogMock).toHaveBeenCalledWith('Experiment nlpSearch is not enabled. Event will not be logged.') + }) + }) +}) diff --git a/static/src/js/util/metrics/experiments/logEvent.ts b/static/src/js/util/metrics/experiments/logEvent.ts new file mode 100644 index 0000000000..ed530b368e --- /dev/null +++ b/static/src/js/util/metrics/experiments/logEvent.ts @@ -0,0 +1,40 @@ +// @ts-expect-error This file does not have types +import LoggerRequest from '../../request/loggerRequest' + +import useEdscStore from '../../../zustand/useEdscStore' + +const logEvent = async (eventType: string, eventData: string) => { + try { + const { growthbook } = useEdscStore.getState() + const { featureFlags } = growthbook + const { nlpSearch: nlpSearchValue } = featureFlags + + if (!nlpSearchValue) { + console.log('Experiment nlpSearch is not enabled. Event will not be logged.') + + return + } + + const gbUserId = window.localStorage.getItem('gbUserId') || 'unknown-user-id' + const gbSessionId = window.sessionStorage.getItem('gbSessionId') || 'unknown-session-id' + + const loggerRequest = new LoggerRequest() + + const params = { + eventData: { + experiment_id: 'nlpSearch', + variation_id: nlpSearchValue, + event_type: eventType, + event_data: eventData, + session_id: gbSessionId, + user_id: gbUserId + } + } + + await loggerRequest.logExperiment(params) + } catch (error) { + console.error('Error logging event:', error) + } +} + +export default logEvent diff --git a/static/src/js/util/request/__tests__/loggerRequest.test.js b/static/src/js/util/request/__tests__/loggerRequest.test.js index 3a3c466576..1357437e80 100644 --- a/static/src/js/util/request/__tests__/loggerRequest.test.js +++ b/static/src/js/util/request/__tests__/loggerRequest.test.js @@ -51,3 +51,17 @@ describe('LoggerRequest#logRelevancy', () => { expect(postMock).toHaveBeenCalledWith('relevancy_logger', params) }) }) + +describe('LoggerRequest#logExperiment', () => { + test('calls Request#post', () => { + const request = new LoggerRequest() + + const postMock = vi.spyOn(Request.prototype, 'post').mockImplementation() + + const params = { data: { mock: 'data' } } + request.logExperiment(params) + + expect(postMock).toHaveBeenCalledTimes(1) + expect(postMock).toHaveBeenCalledWith('experiment_logger', params) + }) +}) diff --git a/static/src/js/util/request/loggerRequest.js b/static/src/js/util/request/loggerRequest.js index 654e876f46..1b13374604 100644 --- a/static/src/js/util/request/loggerRequest.js +++ b/static/src/js/util/request/loggerRequest.js @@ -22,4 +22,8 @@ export default class LoggerRequest extends Request { logRelevancy(params) { return this.post('relevancy_logger', params) } + + logExperiment(params) { + return this.post('experiment_logger', params) + } } diff --git a/static/src/js/zustand/slices/__tests__/createGrowthBookSlice.test.ts b/static/src/js/zustand/slices/__tests__/createGrowthBookSlice.test.ts new file mode 100644 index 0000000000..22765c1114 --- /dev/null +++ b/static/src/js/zustand/slices/__tests__/createGrowthBookSlice.test.ts @@ -0,0 +1,28 @@ +import useEdscStore from '../../useEdscStore' + +describe('createGrowthBookSlice', () => { + test('sets the default state', () => { + const zustandState = useEdscStore.getState() + const { growthbook } = zustandState + + expect(growthbook).toEqual({ + featureFlags: { + nlpSearch: expect.any(Boolean) + }, + setFeatureFlags: expect.any(Function) + }) + }) + + describe('setFeatureFlags', () => { + test('updates featureFlags', () => { + const zustandState = useEdscStore.getState() + const { growthbook } = zustandState + const { setFeatureFlags } = growthbook + setFeatureFlags('nlpSearch', true) + + const updatedState = useEdscStore.getState() + const { growthbook: updatedGrowthbook } = updatedState + expect(updatedGrowthbook.featureFlags.nlpSearch).toBe(true) + }) + }) +}) diff --git a/static/src/js/zustand/slices/createGrowthBookSlice.ts b/static/src/js/zustand/slices/createGrowthBookSlice.ts new file mode 100644 index 0000000000..8dfcf0fd18 --- /dev/null +++ b/static/src/js/zustand/slices/createGrowthBookSlice.ts @@ -0,0 +1,22 @@ +import { GrowthBookSlice, ImmerStateCreator } from '../types' + +// @ts-expect-error This file does not have types +import { getApplicationConfig } from '../../../../../sharedUtils/config' + +// Default the value to the config value, but allow it to be overridden by the feature flag +const { nlpSearch } = getApplicationConfig() + +const createGrowthBookSlice: ImmerStateCreator = (set) => ({ + growthbook: { + featureFlags: { + nlpSearch: nlpSearch && nlpSearch === 'true' + }, + setFeatureFlags: (key, value) => { + set((state) => { + state.growthbook.featureFlags[key] = value + }) + } + } +}) + +export default createGrowthBookSlice diff --git a/static/src/js/zustand/types.ts b/static/src/js/zustand/types.ts index 0c3f9c7e47..1c763e6c62 100644 --- a/static/src/js/zustand/types.ts +++ b/static/src/js/zustand/types.ts @@ -339,6 +339,18 @@ export type GranulesSlice = { } } +export type GrowthBookSlice = { + /** The GrowthBook Slice of the store. This saves the metadata for growthbook experiments */ + growthbook: { + featureFlags: { + /** The metadata for the growthbook experiments */ + [key: string]: boolean // In the future this might need to accept more types, but for now we are only using boolean flags + } + /** Function to set the growthbook feature flags */ + setFeatureFlags: (key: string, value: boolean) => void + } +} + export type HomeSlice = { /** The Home Slice of the store */ home: { @@ -1308,6 +1320,7 @@ export type EdscStore = & FacetParamsSlice & GranuleSlice & GranulesSlice + & GrowthBookSlice & HomeSlice & MapSlice & PanelsSlice diff --git a/static/src/js/zustand/useEdscStore.ts b/static/src/js/zustand/useEdscStore.ts index a74f47d784..d295033368 100644 --- a/static/src/js/zustand/useEdscStore.ts +++ b/static/src/js/zustand/useEdscStore.ts @@ -14,6 +14,7 @@ import createFacetsSlice from './slices/createFacetsSlice' import createFacetParamsSlice from './slices/createFacetParamsSlice' import createGranuleSlice from './slices/createGranuleSlice' import createGranulesSlice from './slices/createGranulesSlice' +import createGrowthBookSlice from './slices/createGrowthBookSlice' import createHomeSlice from './slices/createHomeSlice' import createMapSlice from './slices/createMapSlice' import createPortalSlice from './slices/createPortalSlice' @@ -41,6 +42,7 @@ const useEdscStore = create()( ...createFacetParamsSlice(...args), ...createGranuleSlice(...args), ...createGranulesSlice(...args), + ...createGrowthBookSlice(...args), ...createHomeSlice(...args), ...createMapSlice(...args), ...createProjectPanelsSlice(...args), From f66bcfb223305d996fb28867f7613abdd7bd12c6 Mon Sep 17 00:00:00 2001 From: Matthew Crouch Date: Thu, 13 Aug 2026 14:03:30 -0400 Subject: [PATCH 2/4] EDSC-4678: Fixes event log, fixes logEvent when growthbookEnabled is false --- serverless/src/experimentLogger/handler.js | 7 ------ .../experiments/__tests__/logEvent.test.ts | 25 +++++++++++++------ .../js/util/metrics/experiments/logEvent.ts | 21 +++++++++------- 3 files changed, 30 insertions(+), 23 deletions(-) diff --git a/serverless/src/experimentLogger/handler.js b/serverless/src/experimentLogger/handler.js index 136cff1ca0..70f3cd50e9 100644 --- a/serverless/src/experimentLogger/handler.js +++ b/serverless/src/experimentLogger/handler.js @@ -13,19 +13,12 @@ const experimentLogger = async (event) => { const { params = {} } = JSON.parse(body) const { eventData } = params - const { - eventType, - userId - } = eventData - if (!firehoseClient) { firehoseClient = new FirehoseClient({ region: 'us-east-1' }) } const payload = { event_id: crypto.randomUUID(), - user_id: userId || 'anonymous', - event_type: eventType, timestamp: new Date().toISOString(), ...eventData } diff --git a/static/src/js/util/metrics/experiments/__tests__/logEvent.test.ts b/static/src/js/util/metrics/experiments/__tests__/logEvent.test.ts index 9068615315..92377cc789 100644 --- a/static/src/js/util/metrics/experiments/__tests__/logEvent.test.ts +++ b/static/src/js/util/metrics/experiments/__tests__/logEvent.test.ts @@ -1,6 +1,10 @@ import useEdscStore from '../../../../zustand/useEdscStore' import logEvent from '../logEvent' +// @ts-expect-error This file does not have types + +import * as config from '../../../../../../../sharedUtils/config' + // @ts-expect-error This file does not have types import LoggerRequest from '../../../request/loggerRequest' @@ -11,18 +15,23 @@ describe('logEvent', () => { statusText: 'OK' }) + vi.spyOn(config, 'getApplicationConfig').mockImplementation(() => ({ + growthbookEnabled: 'true' + })) + const { growthbook } = useEdscStore.getState() - growthbook.setFeatureFlags('nlpSearch', true) + growthbook.setFeatureFlags('test_experiment', true) + const eventKey = 'test_experiment' const eventType = 'test_event' const eventData = 'test_data' - await logEvent(eventType, eventData) + await logEvent(eventKey, eventType, eventData) expect(loggerRequestMock).toHaveBeenCalledTimes(1) expect(loggerRequestMock).toHaveBeenCalledWith({ eventData: { - experiment_id: 'nlpSearch', + experiment_id: 'test_experiment', variation_id: true, event_type: eventType, event_data: eventData, @@ -37,16 +46,18 @@ describe('logEvent', () => { const loggerRequestMock = vi.spyOn(LoggerRequest.prototype, 'logExperiment') const consoleLogMock = vi.spyOn(console, 'log').mockImplementation(() => {}) - const { growthbook } = useEdscStore.getState() - growthbook.setFeatureFlags('nlpSearch', false) + vi.spyOn(config, 'getApplicationConfig').mockImplementation(() => ({ + growthbookEnabled: 'false' + })) + const eventKey = 'test_experiment' const eventType = 'test_event' const eventData = 'test_data' - await logEvent(eventType, eventData) + await logEvent(eventKey, eventType, eventData) expect(loggerRequestMock).not.toHaveBeenCalled() - expect(consoleLogMock).toHaveBeenCalledWith('Experiment nlpSearch is not enabled. Event will not be logged.') + expect(consoleLogMock).toHaveBeenCalledWith('GrowthBook is not enabled. Event will not be logged.') }) }) }) diff --git a/static/src/js/util/metrics/experiments/logEvent.ts b/static/src/js/util/metrics/experiments/logEvent.ts index ed530b368e..8f3a9d5dfb 100644 --- a/static/src/js/util/metrics/experiments/logEvent.ts +++ b/static/src/js/util/metrics/experiments/logEvent.ts @@ -1,20 +1,23 @@ // @ts-expect-error This file does not have types import LoggerRequest from '../../request/loggerRequest' +// @ts-expect-error This file does not have types +import { getApplicationConfig } from '../../../../../../sharedUtils/config' import useEdscStore from '../../../zustand/useEdscStore' -const logEvent = async (eventType: string, eventData: string) => { +const logEvent = async (eventKey: string, eventType: string, eventData: string) => { try { - const { growthbook } = useEdscStore.getState() - const { featureFlags } = growthbook - const { nlpSearch: nlpSearchValue } = featureFlags - - if (!nlpSearchValue) { - console.log('Experiment nlpSearch is not enabled. Event will not be logged.') + const { growthbookEnabled } = getApplicationConfig() + if (growthbookEnabled !== 'true') { + console.log('GrowthBook is not enabled. Event will not be logged.') return } + const { growthbook } = useEdscStore.getState() + const { featureFlags } = growthbook + const { [eventKey]: eventValue } = featureFlags + const gbUserId = window.localStorage.getItem('gbUserId') || 'unknown-user-id' const gbSessionId = window.sessionStorage.getItem('gbSessionId') || 'unknown-session-id' @@ -22,8 +25,8 @@ const logEvent = async (eventType: string, eventData: string) => { const params = { eventData: { - experiment_id: 'nlpSearch', - variation_id: nlpSearchValue, + experiment_id: eventKey, + variation_id: eventValue, event_type: eventType, event_data: eventData, session_id: gbSessionId, From 009057305cb2fbc36f01174e82399a692272eecd Mon Sep 17 00:00:00 2001 From: Matthew Crouch Date: Fri, 14 Aug 2026 15:34:59 -0400 Subject: [PATCH 3/4] EDSC-4678: Updates test, use node env for devtools flag --- .../GrowthBookLoader/GrowthBookLoader.tsx | 4 +- .../__tests__/GrowthBookLoader.test.tsx | 48 ++++++++++++++----- .../GrowthBookWrapper/GrowthBookWrapper.tsx | 4 +- 3 files changed, 39 insertions(+), 17 deletions(-) diff --git a/static/src/js/components/GrowthBookLoader/GrowthBookLoader.tsx b/static/src/js/components/GrowthBookLoader/GrowthBookLoader.tsx index d8dbedd32e..22b92a2f99 100644 --- a/static/src/js/components/GrowthBookLoader/GrowthBookLoader.tsx +++ b/static/src/js/components/GrowthBookLoader/GrowthBookLoader.tsx @@ -9,9 +9,7 @@ interface GrowthBookLoaderProps { } const GrowthBookLoader = ({ children }: GrowthBookLoaderProps) => { - const { setFeatureFlags } = useEdscStore((state) => ({ - setFeatureFlags: state.growthbook.setFeatureFlags - })) + const setFeatureFlags = useEdscStore((state) => state.growthbook.setFeatureFlags) const nlpSearchValue = useFeatureIsOn('nlpSearch') diff --git a/static/src/js/components/GrowthBookLoader/__tests__/GrowthBookLoader.test.tsx b/static/src/js/components/GrowthBookLoader/__tests__/GrowthBookLoader.test.tsx index f90906e85f..292a1941ad 100644 --- a/static/src/js/components/GrowthBookLoader/__tests__/GrowthBookLoader.test.tsx +++ b/static/src/js/components/GrowthBookLoader/__tests__/GrowthBookLoader.test.tsx @@ -1,6 +1,10 @@ import React from 'react' -import { screen } from '@testing-library/react' -import { GrowthBookProvider, useFeatureIsOn } from '@growthbook/growthbook-react' +import { act, screen } from '@testing-library/react' +import { + GrowthBook, + GrowthBookProvider, + useFeatureIsOn +} from '@growthbook/growthbook-react' import setupTest from '../../../../../../vitestConfigs/setupTest' @@ -17,13 +21,21 @@ vi.mock('@growthbook/growthbook-react', async () => { } }) -const TestComponent = () => ( - - -
Test Children
-
-
-) +const TestComponent = () => { + const growthbook = new GrowthBook({ + apiHost: 'http://localhost:4100', + clientKey: 'mock-client-key' + }) + growthbook.init({}) + + return ( + + +
Test Children
+
+
+ ) +} const setup = setupTest({ Component: TestComponent, @@ -41,7 +53,10 @@ describe('GrowthBookLoader', () => { expect(useFeatureIsOn).toHaveBeenCalledTimes(1) expect(useFeatureIsOn).toHaveBeenCalledWith('nlpSearch') - expect(zustandState.growthbook.setFeatureFlags).toHaveBeenCalledTimes(1) + await act(async () => { + expect(zustandState.growthbook.setFeatureFlags).toHaveBeenCalledTimes(1) + }) + expect(zustandState.growthbook.setFeatureFlags).toHaveBeenCalledWith('nlpSearch', true) }) @@ -51,7 +66,10 @@ describe('GrowthBookLoader', () => { setup() - expect(localStorageGetItemSpy).toHaveBeenCalledTimes(2) + await act(async () => { + expect(localStorageGetItemSpy).toHaveBeenCalledTimes(2) + }) + expect(localStorageGetItemSpy).toHaveBeenNthCalledWith(1, 'gbUserId') expect(localStorageGetItemSpy).toHaveBeenNthCalledWith(2, 'gbSessionId') @@ -67,7 +85,10 @@ describe('GrowthBookLoader', () => { setup() - expect(localStorageGetItemSpy).toHaveBeenCalledTimes(2) + await act(async () => { + expect(localStorageGetItemSpy).toHaveBeenCalledTimes(2) + }) + expect(localStorageGetItemSpy).toHaveBeenNthCalledWith(1, 'gbUserId') expect(localStorageGetItemSpy).toHaveBeenNthCalledWith(2, 'gbSessionId') @@ -84,6 +105,7 @@ describe('GrowthBookLoader', () => { className: 'root__spinner spinner spinner--dots spinner--small' }, {}) - expect(screen.queryByText('Test Children')).not.toBeInTheDocument() + // Wait for the children to be rendered after the spinner + expect(await screen.findByText('Test Children')).toBeInTheDocument() }) }) diff --git a/static/src/js/components/GrowthBookWrapper/GrowthBookWrapper.tsx b/static/src/js/components/GrowthBookWrapper/GrowthBookWrapper.tsx index dd8f1f55d3..57ef284255 100644 --- a/static/src/js/components/GrowthBookWrapper/GrowthBookWrapper.tsx +++ b/static/src/js/components/GrowthBookWrapper/GrowthBookWrapper.tsx @@ -24,10 +24,12 @@ const GrowthBookWrapper = ({ children }: GrowthBookWrapperProps) => { growthbookClientKey } = getEnvironmentConfig() + const { NODE_ENV } = process.env + const growthbook = new GrowthBook({ apiHost: growthbookApiHost, clientKey: growthbookClientKey, - enableDevMode: true, + enableDevMode: NODE_ENV === 'development', // Only required for A/B testing // Called every time a user is put into an experiment trackingCallback: (experiment, result) => { From 9849c349b5bd0f29947af49aaf3bc01991f5a7c8 Mon Sep 17 00:00:00 2001 From: Matthew Crouch Date: Fri, 28 Aug 2026 14:10:36 -0400 Subject: [PATCH 4/4] EDSC-4678: Updates NLP prompt --- serverless/src/nlpSearch/handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/serverless/src/nlpSearch/handler.js b/serverless/src/nlpSearch/handler.js index 395df72ab3..df1def08b1 100644 --- a/serverless/src/nlpSearch/handler.js +++ b/serverless/src/nlpSearch/handler.js @@ -265,7 +265,7 @@ ${query} Required workflow: 1) Identify spatial, temporal, and keyword values from the query. 2) For every value you find, call tool "reportFound" once per field. Do not wait for the results of the reportFound tool before calling other tools. If multiple spatial values exist, include all values in the a single call to "reportFound. -3) If spatial exists, call tool "lookupSpatial" with the spatial value. If multiple spatial values exist, include all values in the a single call to "lookupSpatial". +3) If spatial exists, call tool "lookupSpatial" with the spatial value. If multiple spatial values exist, include all values in the a single call to "lookupSpatial". If you think you found a spatial area but it is adjacent to some numbers, like "ATL03", ignore that value as spatial. 4) If temporal exists, call tool "convertTemporal" with the temporal value. 5) After all tools have been called and have returned their results, call the "finalCall" tool to indicate that processing is complete.`, tools: {