Skip to content

add edit experiment modal#2754

Merged
bcb37 merged 5 commits intoexperiment-design-refreshfrom
feature/2725-edit-experiment-modal
Dec 5, 2025
Merged

add edit experiment modal#2754
bcb37 merged 5 commits intoexperiment-design-refreshfrom
feature/2725-edit-experiment-modal

Conversation

@bcb37
Copy link
Copy Markdown
Collaborator

@bcb37 bcb37 commented Nov 20, 2025

No description provided.

@bcb37 bcb37 requested review from danoswaltCL and zackcl November 20, 2025 20:23
@bcb37 bcb37 linked an issue Nov 20, 2025 that may be closed by this pull request
Copy link
Copy Markdown
Collaborator

@zackcl zackcl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems the Metrics table isn’t being cleared after changing the App Context. Also, it might be better to disable the "Include All" toggle when the App Context changes, since that’s the default cleared state.

@bcb37 bcb37 requested a review from zackcl December 2, 2025 19:52
Copy link
Copy Markdown
Collaborator

@zackcl zackcl left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When we switch the app context with “Include All” enabled, the toggle flips back to “Exclude All,” but the Include Lists section stays collapsed. That feels confusing. Expanding it again would make the change clearer. I asked Copilot for a quick fix and it suggested the diff below, which tracks the previous filter mode and expands the card automatically when we transition from INCLUDE_ALL to EXCLUDE_ALL. Please take a look, and if you prefer another approach, feel free:

@@ -46,6 +46,7 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr
   experimentInclusions$ = this.experimentService.selectExperimentInclusions$;
   tableRowCount$ = this.experimentService.selectExperimentInclusionsLength$;
   subscriptions = new Subscription();
+  private previousFilterMode?: FILTER_MODE;
 
   menuButtonItems: IMenuButtonItem[] = [
     {
@@ -80,6 +81,22 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr
 
   ngOnInit() {
     this.permissions$ = this.authService.userPermissions$;
+    // Expand section when include-all mode transitions back to exclude-all (e.g., after context change)
+    this.subscriptions.add(
+      this.selectedExperiment$.subscribe((experiment) => {
+        if (!experiment) {
+          this.previousFilterMode = undefined;
+          return;
+        }
+
+        const nextFilterMode = experiment.filterMode;
+        if (this.previousFilterMode === FILTER_MODE.INCLUDE_ALL && nextFilterMode === FILTER_MODE.EXCLUDE_ALL) {
+          this.isSectionCardExpanded = true;
+        }
+
+        this.previousFilterMode = nextFilterMode;
+      })
+    );
   }
 
   onAddIncludeListClick(appContext: string, experimentId: string): void {
@@ -123,15 +140,11 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr
         experiment: experiment,
         filterMode: newFilterMode,
       });
-      this.updateSectionCardExpansion(newFilterMode);
+      // Expansion state will be updated automatically by the subscription when the experiment updates
     }
     // If user cancels, the toggle state is already reverted, so no action needed
   }
 
-  updateSectionCardExpansion(newFilterMode: FILTER_MODE): void {
-    this.isSectionCardExpanded = newFilterMode !== FILTER_MODE.INCLUDE_ALL;
-  }
-
   onMenuButtonItemClick(event: string, experiment: Experiment): void {
     switch (event) {
       case EXPERIMENT_BUTTON_ACTION.IMPORT_INCLUDE_LIST:

@bcb37
Copy link
Copy Markdown
Collaborator Author

bcb37 commented Dec 3, 2025

When we switch the app context with “Include All” enabled, the toggle flips back to “Exclude All,” but the Include Lists section stays collapsed. That feels confusing. Expanding it again would make the change clearer. I asked Copilot for a quick fix and it suggested the diff below, which tracks the previous filter mode and expands the card automatically when we transition from INCLUDE_ALL to EXCLUDE_ALL. Please take a look, and if you prefer another approach, feel free:

@@ -46,6 +46,7 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr
   experimentInclusions$ = this.experimentService.selectExperimentInclusions$;
   tableRowCount$ = this.experimentService.selectExperimentInclusionsLength$;
   subscriptions = new Subscription();
+  private previousFilterMode?: FILTER_MODE;
 
   menuButtonItems: IMenuButtonItem[] = [
     {
@@ -80,6 +81,22 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr
 
   ngOnInit() {
     this.permissions$ = this.authService.userPermissions$;
+    // Expand section when include-all mode transitions back to exclude-all (e.g., after context change)
+    this.subscriptions.add(
+      this.selectedExperiment$.subscribe((experiment) => {
+        if (!experiment) {
+          this.previousFilterMode = undefined;
+          return;
+        }
+
+        const nextFilterMode = experiment.filterMode;
+        if (this.previousFilterMode === FILTER_MODE.INCLUDE_ALL && nextFilterMode === FILTER_MODE.EXCLUDE_ALL) {
+          this.isSectionCardExpanded = true;
+        }
+
+        this.previousFilterMode = nextFilterMode;
+      })
+    );
   }
 
   onAddIncludeListClick(appContext: string, experimentId: string): void {
@@ -123,15 +140,11 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr
         experiment: experiment,
         filterMode: newFilterMode,
       });
-      this.updateSectionCardExpansion(newFilterMode);
+      // Expansion state will be updated automatically by the subscription when the experiment updates
     }
     // If user cancels, the toggle state is already reverted, so no action needed
   }
 
-  updateSectionCardExpansion(newFilterMode: FILTER_MODE): void {
-    this.isSectionCardExpanded = newFilterMode !== FILTER_MODE.INCLUDE_ALL;
-  }
-
   onMenuButtonItemClick(event: string, experiment: Experiment): void {
     switch (event) {
       case EXPERIMENT_BUTTON_ACTION.IMPORT_INCLUDE_LIST:

@zackcl Do we want to have the exclusion and inclusion lists always expanded after editing, even if they were collapsed by the user before editing?

@zackcl
Copy link
Copy Markdown
Collaborator

zackcl commented Dec 3, 2025

@zackcl Do we want to have the exclusion and inclusion lists always expanded after editing, even if they were collapsed by the user before editing?

Only the inclusion card needs to expand after the list gets cleared (when "Include All" was enabled), making it obvious the list is now empty. I don't think it should expand if the "Include All" was already disabled and the section card was collapsed by the user.

Screenshot 2025-12-03 at 4 34 18 PM

@bcb37 bcb37 requested a review from zackcl December 3, 2025 21:45
@zackcl
Copy link
Copy Markdown
Collaborator

zackcl commented Dec 3, 2025

This might not be related to this PR, but while I was testing updating an experiment in this branch, I've encountered an occasional crash that seems to be related to the access token validation. It looks like the frontend is sending an expired Google token, so auth eventually crashes:

{
  request_id: '66687e14-254c-47ac-8058-555c127d1c94',
  endpoint: '/metric',
  request_method_type: 'GET',
  level: 'warn',
  message: {
    message: 'ID token validation failed, attempting access token validation',
    error: Error: Token used too late, 1764801365.743 > 1764692745: {"iss":"https://accounts.google.com","azp":"656936260684-hdsihcgcdrcbeom72n56o8n5rb2mtc58.apps.googleusercontent.com","aud":"656936260684-hdsihcgcdrcbeom72n56o8n5rb2mtc58.apps.googleusercontent.com","sub":"114058157248121932953","hd":"carnegielearning.com","email":"zlee@carnegielearning.com","email_verified":true,"nbf":1764688545,"name":"Zack Lee","given_name":"Zack","family_name":"Lee","iat":1764688845,"exp":1764692445,"jti":"d84e19640ce029ec152da7e8dbe08dbdfbfa625d"}
        at OAuth2Client.verifySignedJwtWithCertsAsync (/Users/zlee/Desktop/code/UpGrade/backend/packages/Upgrade/node_modules/google-auth-library/build/src/auth/oauth2client.js:724:19)
        at processTicksAndRejections (node:internal/process/task_queues:105:5)
        at async OAuth2Client.verifyIdTokenAsync (/Users/zlee/Desktop/code/UpGrade/backend/packages/Upgrade/node_modules/google-auth-library/build/src/auth/oauth2client.js:508:23),
    requestPath: '/api/metric'
  },
  timestamp: '2025-12-03T22:36:05.743Z'
}

Please note that I made the following change to log that details:

+++ b/backend/packages/Upgrade/src/auth/AuthService.ts
@@ -35,6 +35,11 @@ export class AuthService {
       payload = ticket.getPayload();
       request.logger.info({ message: 'ID token validated' });
     } catch (error) {
+      request.logger.warn({
+        message: 'ID token validation failed, attempting access token validation',
+        error,
+        requestPath: request.originalUrl ?? request.path,
+      });
       // If ID token verification fails, try to verify it as an access token
       try {
         // env.google.serviceAccountId is an array of service account IDs
@@ -50,7 +55,11 @@ export class AuthService {
         };
         request.logger.info({ message: 'Access token validated' });
       } catch (error) {
-        request.logger.error(error);
+        request.logger.error({
+          message: 'Token validation failed',
+          error,
+          requestPath: request.originalUrl ?? request.path,
+        });
         throw error;
       }
     }

Here's the video reproducing that error (seems to happen randomly though):

Screen.Recording.2025-12-03.at.5.35.30.PM.mov

@bcb37
Copy link
Copy Markdown
Collaborator Author

bcb37 commented Dec 4, 2025

This might not be related to this PR, but while I was testing updating an experiment in this branch, I've encountered an occasional crash that seems to be related to the access token validation. It looks like the frontend is sending an expired Google token, so auth eventually crashes:

{
  request_id: '66687e14-254c-47ac-8058-555c127d1c94',
  endpoint: '/metric',
  request_method_type: 'GET',
  level: 'warn',
  message: {
    message: 'ID token validation failed, attempting access token validation',
    error: Error: Token used too late, 1764801365.743 > 1764692745: {"iss":"https://accounts.google.com","azp":"656936260684-hdsihcgcdrcbeom72n56o8n5rb2mtc58.apps.googleusercontent.com","aud":"656936260684-hdsihcgcdrcbeom72n56o8n5rb2mtc58.apps.googleusercontent.com","sub":"114058157248121932953","hd":"carnegielearning.com","email":"zlee@carnegielearning.com","email_verified":true,"nbf":1764688545,"name":"Zack Lee","given_name":"Zack","family_name":"Lee","iat":1764688845,"exp":1764692445,"jti":"d84e19640ce029ec152da7e8dbe08dbdfbfa625d"}
        at OAuth2Client.verifySignedJwtWithCertsAsync (/Users/zlee/Desktop/code/UpGrade/backend/packages/Upgrade/node_modules/google-auth-library/build/src/auth/oauth2client.js:724:19)
        at processTicksAndRejections (node:internal/process/task_queues:105:5)
        at async OAuth2Client.verifyIdTokenAsync (/Users/zlee/Desktop/code/UpGrade/backend/packages/Upgrade/node_modules/google-auth-library/build/src/auth/oauth2client.js:508:23),
    requestPath: '/api/metric'
  },
  timestamp: '2025-12-03T22:36:05.743Z'
}

Please note that I made the following change to log that details:

+++ b/backend/packages/Upgrade/src/auth/AuthService.ts
@@ -35,6 +35,11 @@ export class AuthService {
       payload = ticket.getPayload();
       request.logger.info({ message: 'ID token validated' });
     } catch (error) {
+      request.logger.warn({
+        message: 'ID token validation failed, attempting access token validation',
+        error,
+        requestPath: request.originalUrl ?? request.path,
+      });
       // If ID token verification fails, try to verify it as an access token
       try {
         // env.google.serviceAccountId is an array of service account IDs
@@ -50,7 +55,11 @@ export class AuthService {
         };
         request.logger.info({ message: 'Access token validated' });
       } catch (error) {
-        request.logger.error(error);
+        request.logger.error({
+          message: 'Token validation failed',
+          error,
+          requestPath: request.originalUrl ?? request.path,
+        });
         throw error;
       }
     }

Here's the video reproducing that error (seems to happen randomly though):

Screen.Recording.2025-12-03.at.5.35.30.PM.mov

@zackcl This issue is fixed in #2772

@zackcl
Copy link
Copy Markdown
Collaborator

zackcl commented Dec 4, 2025

When the Unit of Assignment is updated from "Group" to "Individual", the Group Type (stored as "group") persists in the experiment data, which might create confusion.

Experiment data after updating the Unit of Assignment from Group to Individual:

{
    "assignmentUnit": "individual",
    "group": "schoolId"
}

To reproduce:

  1. Create an experiment with the Unit of Assignment set to "Group".
  2. Update the Unit of Assignment to "Individual".
  3. Refresh the page.
  4. Open the Network tab and check the response from /api/experiments/single/:id. You will see that "group" is not reset to null (for example, it remains "schoolId").

I think this happens because we are not explicitly including { "group": null } in the payload when updating the experiment with a PUT request (unlike in the "dev" branch). Ideally, the backend should handle this so that if the Unit of Assignment is "individual", the "group" field is automatically forced to null. But if that is not straightforward, we should always include the "group" field in the PUT payload to prevent this. Let me know what you think.

For reference, it seems like simply updating the fallback to null fixes the issue:

@@ -536,7 +536,7 @@ export class UpsertExperimentModalComponent implements OnInit, OnDestroy {
       conditionOrder: unitOfAssignment === ASSIGNMENT_UNIT.WITHIN_SUBJECTS ? conditionOrder : undefined, // Conditional validation
       assignmentAlgorithm: assignmentAlgorithm || undefined, // @IsOptional
       stratificationFactor: stratificationFactorObj,
-      group: groupType || undefined,
+      group: groupType || null,
       tags,
       state: EXPERIMENT_STATE.INACTIVE,
       filterMode: FILTER_MODE.EXCLUDE_ALL,
@@ -595,7 +595,7 @@ export class UpsertExperimentModalComponent implements OnInit, OnDestroy {
       conditionOrder: unitOfAssignment === ASSIGNMENT_UNIT.WITHIN_SUBJECTS ? conditionOrder : undefined,
       assignmentAlgorithm: assignmentAlgorithm || undefined,
       stratificationFactor: stratificationFactorObj,
-      group: groupType || undefined,
+      group: groupType || null,
       tags,

@bcb37
Copy link
Copy Markdown
Collaborator Author

bcb37 commented Dec 4, 2025

When the Unit of Assignment is updated from "Group" to "Individual", the Group Type (stored as "group") persists in the experiment data, which might create confusion.

Experiment data after updating the Unit of Assignment from Group to Individual:

{
    "assignmentUnit": "individual",
    "group": "schoolId"
}

To reproduce:

  1. Create an experiment with the Unit of Assignment set to "Group".
  2. Update the Unit of Assignment to "Individual".
  3. Refresh the page.
  4. Open the Network tab and check the response from /api/experiments/single/:id. You will see that "group" is not reset to null (for example, it remains "schoolId").

I think this happens because we are not explicitly including { "group": null } in the payload when updating the experiment with a PUT request (unlike in the "dev" branch). Ideally, the backend should handle this so that if the Unit of Assignment is "individual", the "group" field is automatically forced to null. But if that is not straightforward, we should always include the "group" field in the PUT payload to prevent this. Let me know what you think.

For reference, it seems like simply updating the fallback to null fixes the issue:

@@ -536,7 +536,7 @@ export class UpsertExperimentModalComponent implements OnInit, OnDestroy {
       conditionOrder: unitOfAssignment === ASSIGNMENT_UNIT.WITHIN_SUBJECTS ? conditionOrder : undefined, // Conditional validation
       assignmentAlgorithm: assignmentAlgorithm || undefined, // @IsOptional
       stratificationFactor: stratificationFactorObj,
-      group: groupType || undefined,
+      group: groupType || null,
       tags,
       state: EXPERIMENT_STATE.INACTIVE,
       filterMode: FILTER_MODE.EXCLUDE_ALL,
@@ -595,7 +595,7 @@ export class UpsertExperimentModalComponent implements OnInit, OnDestroy {
       conditionOrder: unitOfAssignment === ASSIGNMENT_UNIT.WITHIN_SUBJECTS ? conditionOrder : undefined,
       assignmentAlgorithm: assignmentAlgorithm || undefined,
       stratificationFactor: stratificationFactorObj,
-      group: groupType || undefined,
+      group: groupType || null,
       tags,

@zackcl yeah this was fixed in the old UI by #2645 so we can do something similar

@bcb37 bcb37 merged commit 91ca854 into experiment-design-refresh Dec 5, 2025
10 checks passed
@bcb37 bcb37 deleted the feature/2725-edit-experiment-modal branch December 5, 2025 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Edit Experiment Design (Overview Section Card)

2 participants