Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix #19644: Remove user role from UI, redisplay list of users in the event of a backend failure #20201

Merged
merged 28 commits into from May 14, 2024

Conversation

raafeahmed
Copy link
Contributor

@raafeahmed raafeahmed commented Apr 23, 2024

Overview

  1. This PR fixes Exception: This user does not have any role in exploration with ID #19644.

  2. This PR does the following:
    • Modifies the removeRoleAsync function by first making a copy of the list of users prior to the deletion. This is for in the event of an error from the backend or if the deletion was unsuccessful. Next, the user role is immediately removed from the frontend. This is to prevent the user from attempting to delete the user role twice in the event of a delay from the server. Finally, a catch block is added to account for an unsuccessful deletion or error from the backend. This is for reverting/ redisplaying the list of users to the state prior to the deletion attempt using the copy made earlier, and alerts the end user that the user could not be removed.
    • Tests this functionality. The first test checks whether the user was removed immediately from the UI while the backend continues to process the deletion request. Once the backend returns, the frontend becomes in sync with the backend data. The second test redisplays the list of users by forcing an error to be thrown. It verifies that the user is initially removed from the UI before the backend returns, and then the UI reverts back to its previous state once the backend returns an error. Both tests are passing.

  3. The original bug occurred because there's a server delay when deleting users through the Settings tab in the exploration editor. The user is not removed until the deletion has fully completed, so the same user may be deleted twice and this causes the error to occur. By immediately removing the user from the frontend while the backend processes the actual deletion request, the end user will never have the ability to delete a role twice. There is no associated PR that introduced this bug, but rather just the cause of server delays that happen periodically.

Essential Checklist

Please follow the instructions for making a code change.

  • I have linked the issue that this PR fixes in the "Development" section of the sidebar.
  • I have checked the "Files Changed" tab and confirmed that the changes are what I want to make.
  • I have written tests for my code.
  • The PR title starts with "Fix #bugnum: " or "Fix part of #bugnum: ...", followed by a short, clear summary of the changes.
  • I have assigned the correct reviewers to this PR (or will leave a comment with the phrase "@{{reviewer_username}} PTAL" if I can't assign them directly).

Proof that changes are correct

Before:

https://github.com/oppia/oppia/assets/123130903/276c8fed-9b3f-46f9-856f-bc340d45633b
The second attempt is what raises the error linked in the issue.

EDIT: updated screen recordings (5/2)

After:

https://github.com/oppia/oppia/assets/123130903/56958ff3-bdc5-449c-a256-b5c9a97542da
This is again shown with a slight delay, time.sleep(2), to demonstrate that the user is removed immediately with the new code. Then once the backend successfully deletes the user, the confirmation is displayed to the end user. In practice, without the extra delay, the confirmation appears almost immediately.

https://github.com/oppia/oppia/assets/123130903/683b4690-2786-4ff3-8719-7801aa04c9fb
Performs the same with the new code by keeping the user list exactly the way it was before the deletion was attempted and alerts the user.

PR Pointers

@raafeahmed raafeahmed requested a review from a team as a code owner April 23, 2024 08:12
Copy link

oppiabot bot commented Apr 23, 2024

Assigning @Lawful2002 for the first pass review of this PR. Thanks!

Copy link
Member

@seanlip seanlip left a comment

Choose a reason for hiding this comment

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

Thanks @raafeahmed, UI looks good! Left a few notes for your consideration.

@@ -230,6 +230,20 @@ export class ExplorationRightsService {
}

removeRoleAsync(memberUsername: string): Promise<void> {
const initialState = {
Copy link
Member

Choose a reason for hiding this comment

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

Can you do this in a way that doesn't assume there will always be three categories of usernames? For example, store just the name of the field containing the username in question and remove the member username from it, then add the member username back later to the same category if needed (or reinstate the old version of the category).

Other fields should not be affected here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Copy link
Member

@seanlip seanlip Apr 25, 2024

Choose a reason for hiding this comment

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

Not done -- per the previous comment, I don't expect to see reference to three separate things here. Please make the code more general so that if we add a new username category, we don't have to update this code. (As a bonus, this will also reduce the repeated logic.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry, I assumed you had meant to just check which array the member username was in. The new code has been generalized to iterate through the specific categories and do the relevant operations. The only thing that would have to be updated for future is simply the first line, where you would just have to add more username categories to the categories array.

@@ -220,7 +220,7 @@ describe('Beam Jobs Tab Component', () => {
expect(await table.getRows()).toHaveSize(2);

component.ngOnDestroy();
});
}, 10000);
Copy link
Member

Choose a reason for hiding this comment

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

Which step here in particular is taking a long time? Are these tests failing on CI or your local machine?

Same question for other tests. The concern I have is that these tests aren't failing elsewhere (AFAIK) so I don't understand what is causing this timeout for this PR specifically.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The tests will fail randomly on my local machine, and is usually one or two of them that fail at a time on each run. I provided an example screenshot of what one of these errors look like in the proof of changes section in the PR. The reason I changed it was because I unfortunately couldn't get past these errors in order to push my code for the actual issue and create this PR. Modifying the timeout is what allowed me to do so.

I also ran into these timeout errors when running the original/current code repository, so it's unrelated to my changes and could be related to my local machine only. If so, is it possible for this change to omitted without me having to push the code without this timeout modification? Or if there's something else that I need to do to make it pass on my machine?

Copy link
Member

Choose a reason for hiding this comment

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

Can you investigate (perhaps by commenting out bits) which line of the test in particular is taking a long time on your machine?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Was able to push without it, so these modifications have been reverted to the original code.

@@ -247,6 +261,14 @@ export class ExplorationRightsService {
response.rights.community_owned,
response.rights.viewable_if_private
);
})
.catch(error => {
Copy link
Member

Choose a reason for hiding this comment

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

Just a thought: now that the frontend doesn't actually reflect the state of the backend, I think we need to show some confirmation to the user. Can you show a green toast in the bottom-left/bottom-right on successful deletion saying something like "Successfully removed owner/editor/viewer: {{username}}"? (I think we might already have some functionality for this sort of thing, so I'd suggest reusing it.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

).and.returnValue(Promise.reject(new Error('Error')));

ers.removeRoleAsync('ownerName').then(successHandler, failHandler);
expect(ers.ownerNames).not.toEqual(['ownerName']);
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, try to assert equality rather than inequality (it's a stronger check).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

tick();

expect(successHandler).toHaveBeenCalled();
expect(failHandler).not.toHaveBeenCalled();
Copy link
Member

Choose a reason for hiding this comment

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

In the failure case, shouldn't the failHandler be called?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm, when i switched it it threw an error. Regardless though, the main point of the test case is to show that the list is empty before tick() meaning the user is removed, and then after tick, the list has the original value again, due to the error being returned. I'm just going to remove these two lines since it might be confusing and not exactly a relevant detail to the test case.

Copy link
Member

Choose a reason for hiding this comment

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

No, I don't think that suffices ... please check to understand whether the right thing is happening here with the handlers. I think it's a good idea to keep them because it serves as additional confirmation about what is being called in the frontend when the response returns.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. The error wasn't being returned properly which for some reason caused successhandler to be called and not the fail handler. Adding 'throw error' to the end of the catch block fixed it. I also put several expect statements to verify the states of the categories at every moment. Test is passing.

Copy link
Contributor Author

@raafeahmed raafeahmed left a comment

Choose a reason for hiding this comment

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

@seanlip I have made some changes, please take another look.

@@ -247,6 +261,14 @@ export class ExplorationRightsService {
response.rights.community_owned,
response.rights.viewable_if_private
);
})
.catch(error => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@@ -230,6 +230,20 @@ export class ExplorationRightsService {
}

removeRoleAsync(memberUsername: string): Promise<void> {
const initialState = {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

tick();

expect(successHandler).toHaveBeenCalled();
expect(failHandler).not.toHaveBeenCalled();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm, when i switched it it threw an error. Regardless though, the main point of the test case is to show that the list is empty before tick() meaning the user is removed, and then after tick, the list has the original value again, due to the error being returned. I'm just going to remove these two lines since it might be confusing and not exactly a relevant detail to the test case.

@@ -220,7 +220,7 @@ describe('Beam Jobs Tab Component', () => {
expect(await table.getRows()).toHaveSize(2);

component.ngOnDestroy();
});
}, 10000);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Was able to push without it, so these modifications have been reverted to the original code.

).and.returnValue(Promise.reject(new Error('Error')));

ers.removeRoleAsync('ownerName').then(successHandler, failHandler);
expect(ers.ownerNames).not.toEqual(['ownerName']);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@raafeahmed raafeahmed requested a review from seanlip April 25, 2024 03:38
@raafeahmed raafeahmed changed the title Fix #19644: Remove user role from UI, redisplay list of users in the event of a backend failure, fix beam jobs tab async timeout errors Fix #19644: Remove user role from UI, redisplay list of users in the event of a backend failure Apr 25, 2024
Copy link
Member

@seanlip seanlip left a comment

Choose a reason for hiding this comment

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

Thanks, left a few more comments. Please also see #20201 (comment).

@@ -230,6 +230,20 @@ export class ExplorationRightsService {
}

removeRoleAsync(memberUsername: string): Promise<void> {
const initialState = {
Copy link
Member

@seanlip seanlip Apr 25, 2024

Choose a reason for hiding this comment

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

Not done -- per the previous comment, I don't expect to see reference to three separate things here. Please make the code more general so that if we add a new username category, we don't have to update this code. (As a bonus, this will also reduce the repeated logic.)

).and.returnValue(Promise.reject(new Error('Error')));

ers.removeRoleAsync('ownerName').then(successHandler, failHandler);
expect(ers.ownerNames).toEqual([]);
Copy link
Member

Choose a reason for hiding this comment

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

Verify that the other categories are not affected. Ditto below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Copy link

oppiabot bot commented May 2, 2024

Hi @raafeahmed, I'm going to mark this PR as stale because it hasn't had any updates for 7 days. If no further activity occurs within 4 days, it will be automatically closed so that others can take up the issue.
If you are still working on this PR, please make a follow-up commit within 4 days (and submit it for review, if applicable). Please also let us know if you are stuck so we can help you!

@oppiabot oppiabot bot removed the stale label May 3, 2024
Copy link
Contributor Author

@raafeahmed raafeahmed left a comment

Choose a reason for hiding this comment

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

@seanlip Apologies for the inactivity, was finishing up finals. I've addressed your comments, and I've also updated the screen recordings, please take another look.

).and.returnValue(Promise.reject(new Error('Error')));

ers.removeRoleAsync('ownerName').then(successHandler, failHandler);
expect(ers.ownerNames).toEqual([]);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@@ -230,6 +230,20 @@ export class ExplorationRightsService {
}

removeRoleAsync(memberUsername: string): Promise<void> {
const initialState = {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sorry, I assumed you had meant to just check which array the member username was in. The new code has been generalized to iterate through the specific categories and do the relevant operations. The only thing that would have to be updated for future is simply the first line, where you would just have to add more username categories to the categories array.

tick();

expect(successHandler).toHaveBeenCalled();
expect(failHandler).not.toHaveBeenCalled();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. The error wasn't being returned properly which for some reason caused successhandler to be called and not the fail handler. Adding 'throw error' to the end of the catch block fixed it. I also put several expect statements to verify the states of the categories at every moment. Test is passing.

@raafeahmed raafeahmed requested a review from seanlip May 3, 2024 08:00
Copy link
Member

@seanlip seanlip left a comment

Choose a reason for hiding this comment

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

Thanks @raafeahmed, took another pass, PTAL.

@@ -230,6 +230,14 @@ export class ExplorationRightsService {
}

removeRoleAsync(memberUsername: string): Promise<void> {
const categories = ['ownerNames', 'editorNames', 'viewerNames'] as const;
let initialState: {[key: string]: string[]} = {};
Copy link
Member

Choose a reason for hiding this comment

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

Try picking a different name besides "state", since we already use "state" in the exploration editor to mean something else. Perhaps initialUsernamesByCategory or similar.

That said, I'm a bit confused about why you need to maintain initialState at all. Wouldn't editing this[category] be sufficient?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this[category] is supposed to represent this.ownerNames, this.editorNames, etc when doing the lookup in the for loop. The initalUsernamesByCategory is for making a copy of the respective array prior to the deletion, and then using that copy to restore it in the catch block.

const categories = ['ownerNames', 'editorNames', 'viewerNames'] as const;
let initialState: {[key: string]: string[]} = {};
categories.forEach(category => {
if (this[category]?.includes(memberUsername)) {
Copy link
Member

Choose a reason for hiding this comment

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

Why the question mark? If we want to be careful about handling the case where the category is non-existent then an error should be thrown if we don't expect that case to happen.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The ? is basically due to some of the unit tests not defining a category, but this doesn't mean an error has to be thrown. For instance, if only ers.editorNames is initialized, and then removeRoleAsync is called, ers.ownerNames and ers.viewerNames were never initalized. So when this if check runs, if its not defined, it will just skip over that category and check the next one for the username. In practice, on the UI side, I think that all the user categories are initialized. This statement was initially if(this[category] && this[category].includes[memberUsername]), which is equivalent.

Copy link
Member

Choose a reason for hiding this comment

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

Hm, I think in that case it would be better if you had the unit tests define whatever categories are necessary, and removed the "?".

An alternative is to have categories be defined more globally in this serivce, e.g. change this.ownerNames to this.roles.ownerNames and similarly for the others, and define categories as the keys of this.roles. Then tests can specify whatever they like for this.roles and it should "just work". But this might be more of a refactor than you'd want to take on, in which case please see the first paragraph.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@seanlip So it appears that some of the other frontend tests failed when I removed the "?". Specifically, in settings-tab.component.spec.ts, as some of the tests call removeRole, for instance ,

component.removeRole('username', 'editor');

removeRole will then call removeRoleasync, and since only the editor category was created here originally, this causes the same error to be thrown (ownerNames, viewerNames would be undefined). Would it be simpler to just keep the "?" instead, as I'm not sure I could define these categories before component.removeRole() is called.

Copy link
Member

Choose a reason for hiding this comment

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

No, I don't think we should be making that change just for tests. Why can't you define the necessary categories in the test?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed

);
})
.catch(error => {
categories.forEach(category => {
Copy link
Member

Choose a reason for hiding this comment

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

You probably don't need to store the entire initialState, just store the category for which the user was removed, and you can then reference that again here.

Better still -- modify this function so that the category is passed into it (in addition to the username). That way, I think you'll have much simpler logic in this function.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok, I'm storing just the specific category, which is then referenced in the catch block.

Copy link
Contributor Author

@raafeahmed raafeahmed left a comment

Choose a reason for hiding this comment

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

@seanlip PTAL. Lines 235 and 236 in exploration-rights.service.ts are due to a 'no index signature found' compile error related to the userRole variable being used in the catch block. These two lines fix that error.

@raafeahmed raafeahmed requested a review from seanlip May 8, 2024 21:40
Copy link
Member

@seanlip seanlip left a comment

Choose a reason for hiding this comment

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

Thanks -- sorry for the late reply, got swamped the past few days. Only 2 things left, PTAL!

const categories = ['ownerNames', 'editorNames', 'viewerNames'] as const;
let initialUsernamesByCategory: string[];
type Category = (typeof categories)[number];
let userRole: Category;
Copy link
Member

Choose a reason for hiding this comment

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

Suggest roleOfRemovedUser (for clarity)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

const categories = ['ownerNames', 'editorNames', 'viewerNames'] as const;
let initialState: {[key: string]: string[]} = {};
categories.forEach(category => {
if (this[category]?.includes(memberUsername)) {
Copy link
Member

Choose a reason for hiding this comment

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

Hm, I think in that case it would be better if you had the unit tests define whatever categories are necessary, and removed the "?".

An alternative is to have categories be defined more globally in this serivce, e.g. change this.ownerNames to this.roles.ownerNames and similarly for the others, and define categories as the keys of this.roles. Then tests can specify whatever they like for this.roles and it should "just work". But this might be more of a refactor than you'd want to take on, in which case please see the first paragraph.

Copy link
Contributor Author

@raafeahmed raafeahmed left a comment

Choose a reason for hiding this comment

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

@seanlip Edited the test that was failing previously, PTAL.

@raafeahmed raafeahmed requested a review from seanlip May 13, 2024 07:00
Copy link
Member

@seanlip seanlip left a comment

Choose a reason for hiding this comment

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

LGTM, thanks @raafeahmed! Congrats on your first Oppia PR :)

@Lawful2002 could you PTAL as codeowner? Thanks.

@Lawful2002 Lawful2002 added this pull request to the merge queue May 14, 2024
Copy link

oppiabot bot commented May 14, 2024

Unassigning @Lawful2002 since they have already approved the PR.

@oppiabot oppiabot bot added the PR: LGTM label May 14, 2024
Copy link

oppiabot bot commented May 14, 2024

Hi @raafeahmed, this PR is ready to be merged. Please address any remaining comments prior to merging, and feel free to ask someone to merge your PR once the CI checks pass and you're happy with it. Thanks!

@github-merge-queue github-merge-queue bot removed this pull request from the merge queue due to failed status checks May 14, 2024
@seanlip seanlip added this pull request to the merge queue May 14, 2024
Merged via the queue into oppia:develop with commit e364c9b May 14, 2024
82 checks passed
@raafeahmed raafeahmed deleted the remove-user-role branch May 14, 2024 19:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Exception: This user does not have any role in exploration with ID
3 participants