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
Conversation
… error response from the backend.
…ng the copy made earlier and display error message to end user.
Assigning @Lawful2002 for the first pass review of this PR. Thanks! |
There was a problem hiding this 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 = { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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']); |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…ific list, and only add back user to its specific list on failure.
There was a problem hiding this 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 => { |
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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']); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
There was a problem hiding this 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 = { |
There was a problem hiding this comment.
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([]); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done
…s category and adding a user back to its category on failure
…fy state of each category
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. |
There was a problem hiding this 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([]); |
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
There was a problem hiding this 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[]} = {}; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
…from, use as a reference in catch block
There was a problem hiding this 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.
There was a problem hiding this 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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggest roleOfRemovedUser
(for clarity)
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
There was a problem hiding this 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.
Unassigning @Lawful2002 since they have already approved the PR. |
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! |
Overview
This PR fixes Exception: This user does not have any role in exploration with ID #19644.
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.
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.
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