Skip to content

Conversation

@LianaHarris360
Copy link
Member

@LianaHarris360 LianaHarris360 commented Aug 14, 2025

Summary

New Functionality:

  • Removal of learners and coaches from one or more classes
  • MembershipResource is used to bulk un-enroll or re-enroll (undo) learners.
  • RoleResource is used to bulk un-assign or re-assign (undo) coaches.
  • A loading spinner is shown within the side panel while the API request is being made and the side panel closes once the request has succeeded.
  • If the Admin has not selected a class, the cancel button closes the side panel without confirmation. Otherwise, a Discard Changes? modal appears.
  • After the selected users are removed, the side panel closes and a snackbar allowing the removal to be undone is displayed.
Screenshot 2025-08-19
Screen.Recording.2025-08-13.at.3.34.35.PM.mov

References

Closes #13388
Figma

Reviewer guidance

  1. On the Facility > Users page, select one or most users and click the remove icon button.
  2. Once the side panel opens, confirm that the correct warnings apply based on if the selected users contain any coaches, and/or learners.
  3. Select a class to remove the users from as well as undoing the removal. Users that are not assigned or enrolled in the selected class(es) should not be affected by selecting REMOVE or UNDO.

@LianaHarris360 LianaHarris360 added TAG: new feature New user-facing feature P0 - critical Priority: Release blocker or regression labels Aug 14, 2025
@github-actions github-actions bot added DEV: backend Python, databases, networking, filesystem... APP: Facility Re: Facility App (user/class management, facility settings, csv import/export, etc.) DEV: frontend SIZE: medium labels Aug 14, 2025
@github-actions
Copy link
Contributor

github-actions bot commented Aug 14, 2025

Copy link
Member

@nucleogenesis nucleogenesis left a comment

Choose a reason for hiding this comment

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

Great work overall. I left a few questions and code review will pass when they're resolved. Otherwise, this is ready for QA

Comment on lines 192 to 225
classCoaches.value = Object.keys(rolesByUser.value);
} finally {
Copy link
Member

Choose a reason for hiding this comment

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

Is this a place where we'd want to catch and show the default error similar to the undoUserRemoval function below?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, that would be good to include. I will update it to include a catch.

}));
await MembershipResource.saveCollection({ data: enrollments });
}
if (hasCoachRoles) {
Copy link
Member

Choose a reason for hiding this comment

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

If the learner membership save throws an error, should we still try to save the coach roles in a separate try/catch?

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, in this function we're undoing the user removals. So if undoing the learner memberships fails and throws an error, and we undo the coach removals in a separate try/catch statement, should we include a different error message informing the user which undo removal (coach or learners) failed?

Copy link
Member Author

@LianaHarris360 LianaHarris360 Aug 19, 2025

Choose a reason for hiding this comment

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

Or should the saveCollection for the memberships and roles be wrapped into one promise, similar to lines 177-187?

Comment on lines +189 to +221
membershipsByUser.value = groupBy(membershipsData, 'user');
rolesByUser.value = groupBy(coachRoles, 'user');
Copy link
Member

Choose a reason for hiding this comment

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

I really like this approach to have the two separated but structured the same way so that other operations can be used on both of them (ie, getItemsToRemove, removeItems) - it's a little thing but it's the kind of little thing that makes this file easy to read and reason about.

Copy link
Member

@AlexVelezLl AlexVelezLl left a comment

Choose a reason for hiding this comment

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

Just a couple of thoughts!

<SidePanelModal
alignment="right"
sidePanelWidth="700px"
@closePanel="$router.back()"
Copy link
Member

Choose a reason for hiding this comment

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

Just noting that here we should use the useGoBack composable to prevent the 4th point that Peter mentioned in this PR #13608 (comment)

Copy link
Member Author

Choose a reason for hiding this comment

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

This is a good point, I will update the side panel to use this.

@closePanel="$router.back()"
>
<template #header>
<h1>{{ removeUsersFromClassesHeading$({ numUsers: selectedUsers.size }) }}</h1>
Copy link
Member

Choose a reason for hiding this comment

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

A very small deviation from the figma specs. In the figma this font-size is 20px, and with this h1 we are getting a 32px font-size.

:style="{ color: $themeTokens.error }"
class="warning-text"
>
<span>{{ defaultErrorMessage$() }}</span>
Copy link
Member

Choose a reason for hiding this comment

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

In general I think we should make consistent the error handling for the api requests 😅. In other side panels I think we just show a snackback error, but I think showing the api error in the side panel is slightly better. Some decisions need to be made around there to have a consistent behavior across side panels.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, I agree with this. Depending on the preferred approach, perhaps we should open an issue to update all of the side panels to have the same error handling approach? @nucleogenesis

Copy link
Member

Choose a reason for hiding this comment

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

I think opening a follow up for a consistent approach is a great idea

Copy link
Member

Choose a reason for hiding this comment

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

Definitely - I'll be putting together some notes tomorrow re: some inconsistencies such as these and will make a follow-up.

</template>
</div>
</div>
<h2 id="remove-from-selected-classes">{{ SelectClassesLabel$() }}</h2>
Copy link
Member

Choose a reason for hiding this comment

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

Here there is a little difference from the designs, this font-size should be 16px.

<KButton
:text="coreString('cancelAction')"
:disabled="loading"
@click="closeSidePanel(selectedOptions.length > 0 ? true : false)"
Copy link
Member

Choose a reason for hiding this comment

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

If the users closes the side panel by clicking outside the side panel, should it also show the confirmation message? Should it show it if the user hits the browser back button?

If so, we have this CloseConfirmationGuard component. We can add some props and a content slot to match the specs for this use case, but in general it has the common guard checks before the route leave happens, and even if the user closes the tab.

Here is an example of how we can use it. (Just noticed the beforeRouteLeave link that needs to be done is not well docummented in the component 😓).

Copy link
Member Author

Choose a reason for hiding this comment

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

That is a good point I hadn't thought of. I'll update it so that the CloseConfirmationGuard is displayed when the user hits the browser back button, the cancel button, or clicking outside the side panel when changes have been made.

// Combine and deduplicate class IDs
const uniqueClassIds = new Set([...learnerClassIds, ...coachClassIds]);
return props.classes
Copy link
Member

Choose a reason for hiding this comment

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

I'll get ahead of Peter's comment 😅. Could we add a sort here to have the classes ordered alphabetically?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, thanks for looking ahead.

membershipsByUser.value = groupBy(membershipsData, 'user');
rolesByUser.value = groupBy(coachRoles, 'user');
classLearners.value = Object.keys(membershipsByUser.value);
Copy link
Member

Choose a reason for hiding this comment

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

It seems like we are not using this variable.

Copy link
Member Author

Choose a reason for hiding this comment

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

Good catch, I'll remove it.

user,
kind: UserKinds.COACH,
}));
await RoleResource.saveCollection({ data: assignments });
Copy link
Member

Choose a reason for hiding this comment

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

Since these two save requests are independent. Perhaps a good idea would be to use the Promise.all to save then concurrently?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, this is what I suggested doing in response to Jacob's earlier comment, I'll move forward with that approach.

}
}
try {
await removeItems(MembershipResource, learnerMembershipsToRemove);
Copy link
Member

Choose a reason for hiding this comment

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

idem, perhaps we can use Promise.all here too.

Copy link
Member Author

Choose a reason for hiding this comment

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

I don't know if I can for these two items specifically. I initially tried to put them into one Promise.all call but a "Database is not available for write operations" error is always returned from the MembershipResource.deleteCollection, I haven't been able to figure out why.

autoDismiss: true,
duration: 6000,
actionText: undoAction$(),
actionCallback: () => undoUserRemoval(),
Copy link
Member

Choose a reason for hiding this comment

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

Nice!

Copy link
Member

@pcenov pcenov left a comment

Choose a reason for hiding this comment

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

Thanks @LianaHarris360 - LGTM, implemented as specified.

Comment on lines +247 to +254
await Promise.all([
enrollments.length
? MembershipResource.saveCollection({ data: enrollments })
: Promise.resolve(),
assignments.length
? RoleResource.saveCollection({ data: assignments })
: Promise.resolve(),
]);
Copy link
Member

Choose a reason for hiding this comment

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

👍

@nucleogenesis nucleogenesis merged commit 195e66f into learningequality:develop Aug 20, 2025
80 of 82 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

APP: Facility Re: Facility App (user/class management, facility settings, csv import/export, etc.) DEV: backend Python, databases, networking, filesystem... DEV: frontend P0 - critical Priority: Release blocker or regression SIZE: medium TAG: new feature New user-facing feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUM SidePanel: Remove (learners and/or coaches) from classes

5 participants