Repository: oppia/oppia

Issue:
[Feature Request]: Improve the saving flow for the Preferences page using a "sticky footer".
### Is your feature request related to a problem? Please describe.

Currently, changes to the Preferences page are auto-saved. We would like to change this so that there is a fixed save-bar at the bottom. 

### Describe the solution (or solutions) you'd like

See https://www.figma.com/file/YWe7SqfUVjxlJLKTUn0UZa/Project-2?type=design&node-id=7076-365949&mode=design&t=v3mhfxAkI0l9V53z-0 for mocks (middle columns).

Changes in those mocks other than those related to the saving flow are not part of this project.

### Describe alternatives you've considered and rejected

_No response_

### Additional context

This is filed based on the design issue here: https://github.com/oppia/design-team/issues/69


Discussion:
Also /cc @juliafalarini, the designer for this project, who can answer questions if needed.
Hello @seanlip , I would like to work on this issue.
I have made changes in files "core/templates/pages/preferences-page/preferences-page.component.css", "core/templates/pages/preferences-page/preferences-page.component.html". So the preferences page with sticky - save button looks like below:

[oppia-sticky-save.webm](https://github.com/oppia/oppia/assets/119884665/767f4550-c956-4d98-aa71-5954f62521bb)






I will create a new 'savePreferences' function, which calls the existing functions when "Save Changes" button is clicked.
But I have a doubt. How to save preferences when button is clicked. In 'savePreferences' function , Should I call every save function when button is clicked && some changes were made in preferences(managed with a single boolean variable like 'hasUnsavedChanges')  like this  


![Screenshot](https://github.com/oppia/oppia/assets/119884665/a17e8133-63bd-489b-b51b-26208a75559e)


or Should I use boolean variables for each preference and then save that particular preference if it was changed ?

Info message will be called from this new 'savePreferences' function rather than in "_saveDataItem" function like earlier.So "Saved" toast will be displayed only once even there are changes in multiple preferences.

Could you please assign this issue to me ?
@AkashPaloju Let's finish the technical discussion first, and if everything looks good, you can have this issue.

But before that, I think it is important to understand what the backend API looks like here. Which handler in the backend is the preferences page calling on-save, and what data does it accept? You can use this to craft the appropriate request in the frontend code. 

Please show how you end up proposing to design the saving function after investigating the above. Thanks!
Hello @seanlip . Could you please check this small [google doc](https://docs.google.com/document/d/1t55ThJHxpMQLaDy08V1wkT_zhYDtY23GJ9NrqSNRLj8/edit?usp=sharing)  . I felt, doc is needed to make things clear. Sorry if I made any mistake anywhere. Thanks!
@AkashPaloju Well done -- the doc is great and I favour approach #3.

This is actually a good opportunity to fix some code cruft in the backend. The current approach which calls each of the update_ functions is still too slow and inefficient. Instead, I suggest that what you do is the following:

- Look at how the "get" and "save" methods in exp_services work for explorations. As you can see, those methods are the only methods which interact with the datastore. The general flow is that the code retrieves an exploration model from the datastore and converts it to a domain object (the "get"), then modifications are made to the Exploration domain object, then finally the domain object is saved to the datastore (via the "save" method).
- We should do something similar for UserSettingsModel. In other words, there should be a generic function that loads that model and creates a domain object out of it. We can then modify properties on that domain object (see e.g. the methods on the Exploration domain object for an analogue). Then finally we would save the domain object to the datastore.

If this sounds good to you, I would suggest doing this in two PRs:
- The first PR doesn't change any logic / behaviour at all. It operates in the backend only, and all it does is to refactor the get/put/update logic to follow the pattern above. In particular, after this PR, there should be no more direct editing of UserSettingsModels. But all the functions with their existing contracts can still remain (they would just call the generic "get" function, make modifications, and then the generic "save" function).
- The second PR then makes all the changes you describe in Approach 3, gets rid of the individual backend functions, and has a general "update preferences" functionality that involves modifying a domain object based on what's received in the frontend. Let's also take care, in this PR, to make sure that the frontend only sends at most one update object for each field, so that there is no ambiguity about what changes the backend should apply.

Again, thanks for outlining your thought processes properly in the doc, that made it much easier to get a sense that you know what you're doing here :) I'm happy to assign you to this issue. Please let me know if the above plan sounds good to you and when you'll be able to make the PRs for it by. Thanks!
hello @seanlip I am very much interested to execute these plans. I have seen all the code managing Explorations. Before making the 1st PR I will make a draft of code changes by tomorrow positively, after it's approval, I will make changes in tests also , then I will be able to make 1st PR . For the 2nd PR , frontend code is finished. To raise the  1st PR it will take 3-4 days (sorry because I am going to travel from my home town to my Univeristy the next 2 days,but I am committed to complete this ASAP). For making 2nd PR 3-4 days is enough mostly( for making changes in backend and their tests). If it is really urgent I can manage things to meet the deadline. Thanks!
Hello @seanlip. (About 1st PR) How you want me to change this existing flow ?
1. The update_user_bio function.
```
def update_user_bio(user_id: str, user_bio: str) -> None:
    """Updates user_bio of user with given user_id.
    """
    user_settings = get_user_settings(user_id, strict=True)
    user_settings.user_bio = user_bio
    save_user_settings(user_settings)
```
2. The get_user_settings call get_users_settings and takes 1st element
```
def get_user_settings(
    user_id: str, strict: bool = True
) -> Optional[user_domain.UserSettings]:
    """Return the user settings for a single user.
    """
    user_settings = get_users_settings([user_id])[0]
    if strict and user_settings is None:
        logging.error('Could not find user with id %s' % user_id)
        raise Exception('User not found.')
    return user_settings
```
3. The get_users_settings function makes a datastore GET call and  it gets a domain object by calling _get_user_settings_from_model  function
![image](https://github.com/oppia/oppia/assets/119884665/b0802124-c822-4fab-b56b-59017aaa0c00)
4. The _get_user_settings_from_model  function making a domain object from given model. You have asked about a generic function which makes 'domain object of the UserSettings Model' but I guess it is already here If I'm not wrong.
```
def _get_user_settings_from_model(
    user_settings_model: user_models.UserSettingsModel
) -> user_domain.UserSettings:
    """Transform user settings storage model to domain object.
    Args:
        user_settings_model: UserSettingsModel. The model to be converted.
    Returns:
        UserSettings. Domain object for user settings.
    """
    return user_domain.UserSettings(
        user_id=user_settings_model.id,
        email=user_settings_model.email,
        # some other settings
    )
```
5. The save_user_settings function making a PUT datastore call
```
def save_user_settings(user_settings: user_domain.UserSettings) -> None:
    """Commits a user settings object to the datastore.
    Args:
        user_settings: UserSettings. The user setting domain object to be saved.
    Returns:
        UserSettingsModel. The updated user settings model that was saved.
    """
    user_model = convert_to_user_settings_model(user_settings)
    user_model.update_timestamps()
    user_model.put()
```
6. The convert_to_user_settings_model function
```
def convert_to_user_settings_model(
    user_settings: user_domain.UserSettings
) -> user_models.UserSettingsModel:
    """Converts a UserSettings domain object to a UserSettingsModel.
    """
    user_settings.validate()
    user_settings_dict = user_settings.to_dict()
    user_model = user_models.UserSettingsModel.get_by_id(user_settings.user_id)
    if user_model is not None:
        user_model.populate(**user_settings_dict)
    else:
        user_settings_dict['id'] = user_settings.user_id
        user_model = user_models.UserSettingsModel(**user_settings_dict)
    return user_model
```
Now Can the modified function 'update_user_bio' be like ?
```
def update_user_bio(
    user_settings: user_domain.UserSettings, 
    user_bio: str
) -> user_domain.UserSettings:
    user_settings.user_bio = user_bio
    return user_settings
```
Thanks!
Ah yes, the modifications you have to update_user_bio look good. And if the logic mentioned for the first PR already exists, no need to change it. Thanks for clarifying.
Hello @seanlip , Could you please check this [Code Changes Doc](https://docs.google.com/document/d/1x8OcxaOuWS2ZdrVzRegUDFj0ormRlLO9d9WJSw0hl5o/edit?usp=sharing) related to backend code refactoring. Please let me know any further changes are needed in them. After your approval, I will make changes in their 'Tests' . I was bit confused after your last comment, can you please clarify this - Should I make 1 PR till here(including their tests modification) and remaining(Approach 3) in another PR or Should I make everything in a single PR ? .  Thanks!
Hm, I'd say, still do 2 PRs, one to "prep everything" and the other to actually implement the functionality.

Looking at the doc changes more broadly, I don't quite agree with the changes in your doc. In general I think these should be methods defined on the domain object, so that we call `user_settings.update_usr_bio(user_bio)`, say, similar to the methods on the Exploration domain object. Apologies for missing this before in the previous comment.

Let me know if you have follow-up questions about this, thanks!
@seanlip That's ok . Please check this [updated doc](https://docs.google.com/document/d/1x8OcxaOuWS2ZdrVzRegUDFj0ormRlLO9d9WJSw0hl5o/edit?usp=sharing). I have not added 'generate_initial_profile_picture' as a method on UserSettings domain object, it is in 'user_services.py' only. As it was only used when signing up,I think it was not needed as a method in the domain object. Should I add that too? 

One more thing is ,I have not changed anything related to 'update_email_preferences' since there is a separate 'UserEmailPreferencesModel'  for that implementation.

Thanks!
Yup, something along the lines of your doc seems good. Just one note, if no additional logic is needed for "update" methods, then the caller can probably just call `user_settings.preferred_audio_language_code = preferred_audio_language_code` directly -- we probably don't need separate one-liner methods for that. Generally other things look good, let's save the full review for when you open the PR. 

For the generate_initial_profile_picture() method, I think it should just return the profile picture and the caller should attach it to the user_settings object; I agree that it doesn't need to be part of the domain object since we want the domain objects to be self-contained. I agree with leaving UserEmailPreferencesModel-related stuff as-is, too.

Thanks!
![image](https://github.com/oppia/oppia/assets/119884665/1c2aa51d-d3be-4a93-8127-86794b479999)
@seanlip I have seen the above one-liner methods in Exploration domain object. So I thought I should do similar to that here, to preserve the existing contracts of the functions. I will remove the one-liner methods in UserSettings and make changes accordingly and generate a PR. 
Thanks!

Resolving patch:
diff --git a/core/controllers/profile.py b/core/controllers/profile.py
--- a/core/controllers/profile.py
+++ b/core/controllers/profile.py
@@ -335,65 +335,75 @@ def get(self) -> None:
     def put(self) -> None:
         """Handles PUT requests."""
         assert self.user_id is not None
-        update_type = self.payload.get('update_type')
-        data = self.payload.get('data')
+        updates = self.payload['updates']
         bulk_email_signup_message_should_be_shown = False
         user_settings = user_services.get_user_settings(self.user_id)
 
-        if update_type == 'subject_interests':
-            self.__validate_data_type(update_type, list, data)
-            user_settings.subject_interests = data
-        elif update_type == 'preferred_language_codes':
-            self.__validate_data_type(update_type, list, data)
-            user_settings.preferred_language_codes = data
-        elif update_type == 'email_preferences':
-            required_keys = [
-                'can_receive_email_updates', 'can_receive_editor_role_email',
-                'can_receive_feedback_message_email',
-                'can_receive_subscription_email']
-            # Check if all required keys are in the data dictionary.
-            if not all(key in data for key in required_keys):
+        for update in updates:
+            update_type = update['update_type']
+            data = update['data']
+
+            if update_type == 'subject_interests':
+                self.__validate_data_type(update_type, list, data)
+                user_settings.subject_interests = data
+            elif update_type == 'preferred_language_codes':
+                self.__validate_data_type(update_type, list, data)
+                user_settings.preferred_language_codes = data
+            elif update_type == 'email_preferences':
+                required_keys = [
+                    'can_receive_email_updates',
+                    'can_receive_editor_role_email',
+                    'can_receive_feedback_message_email',
+                    'can_receive_subscription_email']
+                missing_keys = [
+                    key for key in required_keys if key not in data]
+                if missing_keys:
+                    raise self.InvalidInputException(
+                        'Expected data dict to have all the required keys. '
+                        'Missing keys: %s .' % ', '.join(missing_keys))
+
+                non_boolean_keys = [
+                    key for key, value in data.items()
+                    if not isinstance(value, bool)]
+                if non_boolean_keys:
+                    raise self.InvalidInputException(
+                        'Expected all values of data to be booleans. '
+                        'Non-boolean values found for keys: %s .'
+                        % ', '.join(non_boolean_keys))
+
+                bulk_email_signup_message_should_be_shown = (
+                    user_services.update_email_preferences(
+                        self.user_id, data['can_receive_email_updates'],
+                        data['can_receive_editor_role_email'],
+                        data['can_receive_feedback_message_email'],
+                        data['can_receive_subscription_email']))
+            elif update_type == 'user_bio':
+                self.__validate_data_type(update_type, str, data)
+                if len(data) > feconf.MAX_BIO_LENGTH_IN_CHARS:
+                    raise self.InvalidInputException(
+                        'User bio exceeds maximum character limit: %s'
+                        % feconf.MAX_BIO_LENGTH_IN_CHARS)
+                user_settings.user_bio = data
+            elif update_type == 'preferred_site_language_code':
+                self.__validate_data_type(update_type, str, data)
+                user_settings.preferred_site_language_code = data
+            elif update_type == 'preferred_audio_language_code':
+                self.__validate_data_type(update_type, str, data)
+                user_settings.preferred_audio_language_code = data
+            elif update_type == 'preferred_translation_language_code':
+                self.__validate_data_type(update_type, str, data)
+                user_settings.preferred_translation_language_code = data
+            elif update_type == 'default_dashboard':
+                self.__validate_data_type(update_type, str, data)
+                user_settings.default_dashboard = data
+            elif update_type == 'profile_picture_data_url':
+                self.__validate_data_type(update_type, str, data)
+                assert user_settings.username is not None
+                user_services.update_profile_picture_data_url(
+                    user_settings.username, data)
+            else:
                 raise self.InvalidInputException(
-                    'Expected data to contain the fields,%s, received %s'
-                    % (required_keys, data))
-            if not all(isinstance(value, bool) for value in data.values()):
-                raise self.InvalidInputException(
-                    'Expected all values of data to be boolean,received %s'
-                    % data)
-
-            bulk_email_signup_message_should_be_shown = (
-                user_services.update_email_preferences(
-                    self.user_id, data['can_receive_email_updates'],
-                    data['can_receive_editor_role_email'],
-                    data['can_receive_feedback_message_email'],
-                    data['can_receive_subscription_email']))
-        elif update_type == 'user_bio':
-            self.__validate_data_type(update_type, str, data)
-            if len(data) > feconf.MAX_BIO_LENGTH_IN_CHARS:
-                raise self.InvalidInputException(
-                    'User bio exceeds maximum character limit: %s'
-                    % feconf.MAX_BIO_LENGTH_IN_CHARS)
-            user_settings.user_bio = data
-        elif update_type == 'preferred_site_language_code':
-            self.__validate_data_type(update_type, str, data)
-            user_settings.preferred_site_language_code = data
-        elif update_type == 'preferred_audio_language_code':
-            self.__validate_data_type(update_type, str, data)
-            user_settings.preferred_audio_language_code = data
-        elif update_type == 'preferred_translation_language_code':
-            self.__validate_data_type(update_type, str, data)
-            user_settings.preferred_translation_language_code = data
-        elif update_type == 'default_dashboard':
-            self.__validate_data_type(update_type, str, data)
-            user_settings.default_dashboard = data
-        elif update_type == 'profile_picture_data_url':
-            self.__validate_data_type(update_type, str, data)
-            assert user_settings.username is not None
-            user_services.update_profile_picture_data_url(
-                user_settings.username, data)
-        else:
-            raise self.InvalidInputException(
-                'Invalid update type: %s' % update_type)
+                    'Invalid update type: %s' % update_type)
 
         user_services.save_user_settings(user_settings)
         self.render_json({


Test patch:
diff --git a/core/controllers/library_test.py b/core/controllers/library_test.py
--- a/core/controllers/library_test.py
+++ b/core/controllers/library_test.py
@@ -411,10 +411,12 @@ def test_library_index_handler_for_user_preferred_language(self) -> None:
         csrf_token = self.get_new_csrf_token()
 
         # Change the user's preferred language to de.
-        self.put_json(
-            feconf.PREFERENCES_DATA_URL,
-            {'update_type': 'preferred_language_codes', 'data': ['de']},
-            csrf_token=csrf_token)
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
+                'update_type': 'preferred_language_codes',
+                'data': ['de']
+            }]
+        }, csrf_token=csrf_token)
         response_dict = self.get_json(feconf.LIBRARY_INDEX_DATA_URL)
         self.assertDictContainsSubset({
             'activity_summary_dicts_by_category': [],
@@ -542,10 +544,12 @@ def test_library_group_page_for_user_preferred_language(self) -> None:
 
         csrf_token = self.get_new_csrf_token()
 
-        self.put_json(
-            feconf.PREFERENCES_DATA_URL,
-            {'update_type': 'preferred_language_codes', 'data': ['de']},
-            csrf_token=csrf_token)
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
+                'update_type': 'preferred_language_codes',
+                'data': ['de']
+            }]
+        }, csrf_token=csrf_token)
 
         response_dict = self.get_json(
             feconf.LIBRARY_GROUP_DATA_URL,
diff --git a/core/controllers/profile_test.py b/core/controllers/profile_test.py
--- a/core/controllers/profile_test.py
+++ b/core/controllers/profile_test.py
@@ -66,22 +66,18 @@ def test_page_not_found(self) -> None:
             self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME)
             self.login(self.EDITOR_EMAIL)
             csrf_token = self.get_new_csrf_token()
-            self.put_json(
-                '/preferenceshandler/data',
-                {
+            self.put_json(feconf.PREFERENCES_DATA_URL, {
+                'updates': [{
                     'update_type': 'user_bio',
                     'data': 'Bio data of the editor'
-                },
-                csrf_token=csrf_token
-            )
-            self.put_json(
-                '/preferenceshandler/data',
-                {
+                }]
+            }, csrf_token=csrf_token)
+            self.put_json(feconf.PREFERENCES_DATA_URL, {
+                'updates': [{
                     'update_type': 'subject_interests',
                     'data': ['editor', 'writing']
-                },
-                csrf_token=csrf_token
-            )
+                }]
+            }, csrf_token=csrf_token)
             self.logout()
             self.signup(self.VIEWER_EMAIL, self.VIEWER_USERNAME)
             self.login(self.VIEWER_EMAIL)
@@ -129,32 +125,38 @@ def test_preference_page_updates(self) -> None:
         self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME)
         self.login(self.EDITOR_EMAIL)
         csrf_token = self.get_new_csrf_token()
-        original_preferences = self.get_json('/preferenceshandler/data')
+        original_preferences = self.get_json(feconf.PREFERENCES_DATA_URL)
         self.assertEqual(
             ['en'], original_preferences['preferred_language_codes'])
         self.assertIsNone(original_preferences['preferred_site_language_code'])
         self.assertIsNone(original_preferences['preferred_audio_language_code'])
         self.assertIsNone(
             original_preferences['preferred_translation_language_code'])
-        self.put_json(
-            '/preferenceshandler/data',
-            {'update_type': 'preferred_site_language_code', 'data': 'en'},
-            csrf_token=csrf_token)
-        self.put_json(
-            '/preferenceshandler/data',
-            {'update_type': 'preferred_audio_language_code', 'data': 'hi-en'},
-            csrf_token=csrf_token)
-        self.put_json(
-            '/preferenceshandler/data', {
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
+                'update_type': 'preferred_site_language_code', 
+                'data': 'en'
+            }]
+        }, csrf_token=csrf_token)
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
+                'update_type': 'preferred_audio_language_code', 
+                'data': 'hi-en'
+            }]
+        }, csrf_token=csrf_token)
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
                 'update_type': 'preferred_translation_language_code',
                 'data': 'en'
-            },
-            csrf_token=csrf_token)
-        self.put_json(
-            '/preferenceshandler/data',
-            {'update_type': 'preferred_language_codes', 'data': ['de']},
-            csrf_token=csrf_token)
-        new_preferences = self.get_json('/preferenceshandler/data')
+            }]
+        }, csrf_token=csrf_token)
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
+                'update_type': 'preferred_language_codes',
+                'data': ['de']
+            }]
+        }, csrf_token=csrf_token)
+        new_preferences = self.get_json(feconf.PREFERENCES_DATA_URL)
         self.assertEqual(new_preferences['preferred_language_codes'], ['de'])
         self.assertEqual(new_preferences['preferred_site_language_code'], 'en')
         self.assertEqual(
@@ -168,27 +170,35 @@ def test_profile_data_is_independent_of_currently_logged_in_user(
         self.signup(self.EDITOR_EMAIL, self.EDITOR_USERNAME)
         self.login(self.EDITOR_EMAIL)
         csrf_token = self.get_new_csrf_token()
-        self.put_json(
-            '/preferenceshandler/data',
-            {'update_type': 'user_bio', 'data': 'My new editor bio'},
-            csrf_token=csrf_token)
-        self.put_json(
-            '/preferenceshandler/data',
-            {'update_type': 'subject_interests', 'data': ['editor', 'editing']},
-            csrf_token=csrf_token)
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
+                'update_type': 'user_bio',
+                'data': 'My new editor bio'
+            }]
+        }, csrf_token=csrf_token)
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
+                'update_type': 'subject_interests',
+                'data': ['editor', 'editing']
+            }]
+        }, csrf_token=csrf_token)
         self.logout()
 
         self.signup(self.VIEWER_EMAIL, self.VIEWER_USERNAME)
         self.login(self.VIEWER_EMAIL)
         csrf_token = self.get_new_csrf_token()
-        self.put_json(
-            '/preferenceshandler/data',
-            {'update_type': 'user_bio', 'data': 'My new viewer bio'},
-            csrf_token=csrf_token)
-        self.put_json(
-            '/preferenceshandler/data',
-            {'update_type': 'subject_interests', 'data': ['viewer', 'viewing']},
-            csrf_token=csrf_token)
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
+                'update_type': 'user_bio',
+                'data': 'My new viewer bio'
+            }]
+        }, csrf_token=csrf_token)
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
+                'update_type': 'subject_interests',
+                'data': ['viewer', 'viewing']
+            }]
+        }, csrf_token=csrf_token)
         self.logout()
 
         # Viewer looks at editor's profile page.
@@ -395,12 +405,12 @@ def test_can_update_profile_picture_data_url(self) -> None:
         fs = fs_services.GcsFileSystem(
             feconf.ENTITY_TYPE_USER, user_settings.username)
         fs.commit('profile_picture.png', raw_image, mimetype='image/png')
-        self.put_json(
-            feconf.PREFERENCES_DATA_URL,
-            {
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
                 'update_type': 'profile_picture_data_url',
-                'data': user_services.DEFAULT_IDENTICON_DATA_URL},
-            csrf_token=csrf_token)
+                'data': user_services.DEFAULT_IDENTICON_DATA_URL
+            }]
+        }, csrf_token=csrf_token)
         profile_data = utils.convert_image_binary_to_data_url(
             fs.get('profile_picture.png'), 'png')
         self.assertEqual(profile_data, user_services.DEFAULT_IDENTICON_DATA_URL)
@@ -410,12 +420,12 @@ def test_can_update_default_dashboard(self) -> None:
         self.login(self.OWNER_EMAIL)
         csrf_token = self.get_new_csrf_token()
         user_settings = user_services.get_user_settings(self.owner_id)
-        self.put_json(
-            feconf.PREFERENCES_DATA_URL,
-            {
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
                 'update_type': 'default_dashboard',
-                'data': constants.DASHBOARD_TYPE_CREATOR},
-            csrf_token=csrf_token)
+                'data': constants.DASHBOARD_TYPE_CREATOR
+            }]
+        }, csrf_token=csrf_token)
         user_settings = user_services.get_user_settings(self.owner_id)
         self.assertEqual(
             user_settings.default_dashboard, constants.DASHBOARD_TYPE_CREATOR)
@@ -427,10 +437,12 @@ def test_update_preferences_with_invalid_update_type_raises_exception(
         self.login(self.OWNER_EMAIL)
         csrf_token = self.get_new_csrf_token()
         with self.assertRaisesRegex(Exception, 'Invalid update type:'):
-            self.put_json(
-                feconf.PREFERENCES_DATA_URL,
-                {'update_type': 'invalid_update_type'},
-                csrf_token=csrf_token)
+            self.put_json(feconf.PREFERENCES_DATA_URL, {
+                'updates': [{
+                    'update_type': 'invalid_update_type',
+                    'data': 'data'
+                }]
+            }, csrf_token=csrf_token)
         self.logout()
 
     def test_update_subject_interests_non_list_input_raises_exception(
@@ -439,10 +451,12 @@ def test_update_subject_interests_non_list_input_raises_exception(
         csrf_token = self.get_new_csrf_token()
         with self.assertRaisesRegex(
             Exception, 'Expected subject_interests to be a list'):
-            self.put_json(
-                feconf.PREFERENCES_DATA_URL,
-                {'update_type': 'subject_interests', 'data': 'not a list'},
-                csrf_token=csrf_token)
+            self.put_json(feconf.PREFERENCES_DATA_URL, {
+                'updates': [{
+                    'update_type': 'subject_interests',
+                    'data': 'not a list'
+                }]
+            }, csrf_token=csrf_token)
         self.logout()
 
     def test_update_preferrd_language_codes_non_list_input_raises_exception(
@@ -451,10 +465,12 @@ def test_update_preferrd_language_codes_non_list_input_raises_exception(
         csrf_token = self.get_new_csrf_token()
         with self.assertRaisesRegex(
             Exception, 'Expected preferred_language_codes to be a list'):
-            self.put_json(
-                feconf.PREFERENCES_DATA_URL,
-                {'update_type': 'preferred_language_codes', 'data': 'en'},
-                csrf_token=csrf_token)
+            self.put_json(feconf.PREFERENCES_DATA_URL, {
+                'updates': [{
+                    'update_type': 'preferred_language_codes',
+                    'data': 'en'
+                }]
+            }, csrf_token=csrf_token)
         self.logout()
 
     def test_incorrect_key_in_email_data_dict_raises_exception(self) -> None:
@@ -467,11 +483,13 @@ def test_incorrect_key_in_email_data_dict_raises_exception(self) -> None:
             'can_receive_subscription_email_this_key_is_wrong': False
         }
         with self.assertRaisesRegex(
-            Exception, 'Expected data to contain the fields,'):
-            self.put_json(
-                feconf.PREFERENCES_DATA_URL,
-                {'update_type': 'email_preferences', 'data': data},
-                csrf_token=csrf_token)
+            Exception, 'Missing keys: can_receive_subscription_email'):
+            self.put_json(feconf.PREFERENCES_DATA_URL, {
+                'updates': [{
+                    'update_type': 'email_preferences',
+                    'data': data
+                }]
+            }, csrf_token=csrf_token)
         self.logout()
 
     def test_missing_key_in_email_data_dict_raises_exception(self) -> None:
@@ -483,11 +501,13 @@ def test_missing_key_in_email_data_dict_raises_exception(self) -> None:
             'can_receive_feedback_message_email': False,
         }
         with self.assertRaisesRegex(
-            Exception, 'Expected data to contain the fields'):
-            self.put_json(
-                feconf.PREFERENCES_DATA_URL,
-                {'update_type': 'email_preferences', 'data': data},
-                csrf_token=csrf_token)
+            Exception, 'Missing keys: can_receive_subscription_email'):
+            self.put_json(feconf.PREFERENCES_DATA_URL, {
+                'updates': [{
+                    'update_type': 'email_preferences',
+                    'data': data
+                }]
+            }, csrf_token=csrf_token)
         self.logout()
 
     def test_non_boolean_values_in_email_data_dict_raises_exception(
@@ -501,11 +521,14 @@ def test_non_boolean_values_in_email_data_dict_raises_exception(
             'can_receive_subscription_email': 1
         }
         with self.assertRaisesRegex(
-            Exception, 'Expected all values of data to be boolean'):
-            self.put_json(
-                feconf.PREFERENCES_DATA_URL,
-                {'update_type': 'email_preferences', 'data': data},
-                csrf_token=csrf_token)
+            Exception, 'Non-boolean values found for keys: '
+                'can_receive_subscription_email'):
+            self.put_json(feconf.PREFERENCES_DATA_URL, {
+                'updates': [{
+                    'update_type': 'email_preferences',
+                    'data': data
+                }]
+            }, csrf_token=csrf_token)
         self.logout()
 
     def test_update_prfrence_which_need_str_with_non_str_input_raise_exception(
@@ -519,10 +542,11 @@ def test_update_prfrence_which_need_str_with_non_str_input_raise_exception(
         for update_type in update_types:
             with self.assertRaisesRegex(
                 Exception, 'Expected %s to be a str' % update_type):
-                self.put_json(
-                    feconf.PREFERENCES_DATA_URL,
-                    {'update_type': update_type, 'data': 1},
-                    csrf_token=csrf_token)
+                self.put_json(feconf.PREFERENCES_DATA_URL, {
+                    'updates': [{
+                        'update_type': update_type, 'data': 1
+                    }]
+                }, csrf_token=csrf_token)
         self.logout()
 
 
@@ -536,12 +560,13 @@ def test_userbio_within_limit(self) -> None:
         self.signup(self.EMAIL_A, self.USERNAME_A)
         self.login(self.EMAIL_A)
         csrf_token = self.get_new_csrf_token()
-        self.put_json(
-            '/preferenceshandler/data', {
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
                 'update_type': 'user_bio',
-                'data': 'I am within 2000 char limit',
-            }, csrf_token=csrf_token)
-        preferences = self.get_json('/preferenceshandler/data')
+                'data': 'I am within 2000 char limit'
+            }]
+        }, csrf_token=csrf_token)
+        preferences = self.get_json(feconf.PREFERENCES_DATA_URL)
         self.assertIsNotNone(preferences)
         self.assertEqual(
             preferences['user_bio'], 'I am within 2000 char limit')
@@ -551,12 +576,12 @@ def test_user_bio_exceeds_limit(self) -> None:
         self.signup(self.EMAIL_B, self.USERNAME_B)
         self.login(self.EMAIL_B)
         csrf_token = self.get_new_csrf_token()
-        user_bio_response = self.put_json(
-            '/preferenceshandler/data', {
+        user_bio_response = self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
                 'update_type': 'user_bio',
                 'data': 'I am not within 2000 char limit' * 200
-            },
-            csrf_token=csrf_token, expected_status_int=400)
+            }]
+        }, csrf_token=csrf_token, expected_status_int=400)
         self.assertEqual(user_bio_response['status_code'], 400)
         self.assertIn(
             'User bio exceeds maximum character limit: %s'
@@ -724,18 +749,20 @@ def test_email_preferences_updates(self) -> None:
         csrf_token = self.get_new_csrf_token()
 
         payload = {
-            'update_type': 'email_preferences',
-            'data': {
-                'can_receive_email_updates': True,
-                'can_receive_editor_role_email': True,
-                'can_receive_feedback_message_email': True,
-                'can_receive_subscription_email': True
-            }
+            'updates': [{
+                'update_type': 'email_preferences',
+                'data': {
+                    'can_receive_email_updates': True,
+                    'can_receive_editor_role_email': True,
+                    'can_receive_feedback_message_email': True,
+                    'can_receive_subscription_email': True
+                }
+            }]
         }
 
         # Allow all emails.
         self.put_json(
-            '/preferenceshandler/data', payload, csrf_token=csrf_token)
+            feconf.PREFERENCES_DATA_URL, payload, csrf_token=csrf_token)
 
         email_preferences = user_services.get_email_preferences(editor_id)
         self.assertTrue(email_preferences.can_receive_email_updates)
@@ -744,18 +771,20 @@ def test_email_preferences_updates(self) -> None:
         self.assertTrue(email_preferences.can_receive_subscription_email)
 
         payload = {
-            'update_type': 'email_preferences',
-            'data': {
-                'can_receive_email_updates': False,
-                'can_receive_editor_role_email': False,
-                'can_receive_feedback_message_email': False,
-                'can_receive_subscription_email': False
-            }
+            'updates': [{
+                'update_type': 'email_preferences',
+                'data': {
+                    'can_receive_email_updates': False,
+                    'can_receive_editor_role_email': False,
+                    'can_receive_feedback_message_email': False,
+                    'can_receive_subscription_email': False
+                }
+            }]
         }
 
         # Disallow all emails.
         self.put_json(
-            '/preferenceshandler/data', payload, csrf_token=csrf_token)
+            feconf.PREFERENCES_DATA_URL, payload, csrf_token=csrf_token)
 
         email_preferences = user_services.get_email_preferences(editor_id)
         self.assertFalse(email_preferences.can_receive_email_updates)
@@ -1496,13 +1525,14 @@ def test_save_site_language_handler(self) -> None:
         language_code = 'es'
         self.login(self.EDITOR_EMAIL)
         csrf_token = self.get_new_csrf_token()
-        self.put_json(
-            '/preferenceshandler/data', {
+        self.put_json(feconf.PREFERENCES_DATA_URL, {
+            'updates': [{
                 'update_type': 'preferred_site_language_code',
-                'data': language_code,
-            }, csrf_token=csrf_token)
+                'data': language_code
+            }]
+        }, csrf_token=csrf_token)
 
-        preferences = self.get_json('/preferenceshandler/data')
+        preferences = self.get_json(feconf.PREFERENCES_DATA_URL)
         self.assertIsNotNone(preferences)
         self.assertEqual(
             preferences['preferred_site_language_code'], language_code)
diff --git a/core/tests/webdriverio_desktop/navigation.js b/core/tests/webdriverio_desktop/navigation.js
--- a/core/tests/webdriverio_desktop/navigation.js
+++ b/core/tests/webdriverio_desktop/navigation.js
@@ -294,11 +294,16 @@ describe('Error reporting', function () {
     await browser.pause(2000);
     // Deleting the cookies simulates an expired session error.
     await browser.deleteCookies();
+    // Expect the cookies are deleted.
+    let cookies = await browser.getCookies();
+    expect(cookies).toEqual([]);
     await browser.setupInterceptor();
     // Expect that the frontend error is sent to the backend handler.
     await browser.expectRequest('POST', '/frontend_errors', 200);
     let creatorDashboardRadio = $('.e2e-test-creator-dashboard-radio');
+    let saveChangesButton = $('.e2e-test-save-changes-button');
     await action.click('Creator Dashboard radio', creatorDashboardRadio);
+    await action.click('Save Changes button', saveChangesButton);
     // Add a 1 second delay to ensure that expected request gets triggered.
     // eslint-disable-next-line oppia/e2e-practices
     await browser.pause(1000);
diff --git a/core/tests/webdriverio_utils/PreferencesPage.js b/core/tests/webdriverio_utils/PreferencesPage.js
--- a/core/tests/webdriverio_utils/PreferencesPage.js
+++ b/core/tests/webdriverio_utils/PreferencesPage.js
@@ -41,6 +41,7 @@ var PreferencesPage = function () {
   var profilePhotoClickable = $('.e2e-test-photo-clickable');
   var profilePhotoCropper = $('.e2e-test-photo-crop .cropper-container');
   var profilePhotoUploadError = $('.e2e-test-upload-error');
+  var saveChangesButton = $('.e2e-test-save-changes-button');
   var subscription = $('.e2e-test-subscription-name');
   var subscriptionsSelector = function () {
     return $$('.e2e-test-subscription-name');
@@ -49,6 +50,13 @@ var PreferencesPage = function () {
   var userInterestsInput = $('.e2e-test-subject-interests-input');
 
   var saveNewChanges = async function (fieldName) {
+    // If the following click event is omitted, the '(change)' event
+    // won't be triggered for language-selector, failing to register changes.
+    await action.click('Navbar Button', navBar);
+    await clickSaveChangesButton();
+    // Due to screen dimensions in e2e tests, the Info toast overlaps
+    // with the 'Save Changes' button. To avoid this collision, we click
+    // on a neutral element to move the cursor away.
     await action.click('Navbar Button', navBar);
     await waitFor.visibilityOfInfoToast(
       `Info toast for saving ${fieldName} takes too long to appear.`
@@ -58,6 +66,10 @@ var PreferencesPage = function () {
     );
   };
 
+  var clickSaveChangesButton = async function () {
+    await action.click('Save Changes button', saveChangesButton);
+  };
+
   this.get = async function () {
     await browser.url(USER_PREFERENCES_URL);
     await waitFor.pageToFullyLoad();
@@ -78,18 +90,27 @@ var PreferencesPage = function () {
   };
 
   this.submitProfilePhoto = async function (imgPath, resetExistingImage) {
-    return await workflow.submitImage(
+    await workflow.submitImage(
       profilePhotoClickable,
       profilePhotoCropper,
       imgPath,
       resetExistingImage
     );
+    await clickSaveChangesButton();
+    await waitFor.pageToFullyLoad();
+    // Click on a neutral element.
+    await action.click('Preferences page header', pageHeader);
   };
 
   this.getProfilePhotoSource = async function () {
     return await workflow.getImageSource(customProfilePhoto);
   };
 
+  /**
+   * Appends given text to the existing user bio in the preferences page.
+   *
+   * @param {string} bio - The text to append to the existing user bio.
+   */
   this.editUserBio = async function (bio) {
     await action.addValue('User bio field', userBioElement, bio);
     await saveNewChanges('User Bio');
@@ -115,7 +136,40 @@ var PreferencesPage = function () {
 
   this.selectSystemLanguage = async function (language) {
     await action.click('system language selector', languageSelector);
+    var dropdownSelectorString = '#mat-select-0-panel';
     var dropdownOption = $(`.mat-option-text=${language}`);
+
+    /*
+      The 'click' method of WebDriverIO sometimes fails to click the elements
+      which are not 'fully' visible in the viewport. Refer http://v4.webdriver.io/api/action/click.html.
+      In our case 'English' option might be partially hidden under the 'Search'
+      input field in the mat-select component as WebdriverIO doesn't scroll the
+      required dropdown 'fully' into the viewport before clicking it.
+      Note: the 'Search' input field is sticky to the top of the dropdown.
+      Refer the Known Problems section in the link https://www.npmjs.com/package/ngx-mat-select-search#known-problems
+
+      To ensure that options like 'English' are correctly clicked at all times,
+      we scroll the dropdown  programmatically until the required option is
+      fully displayed in the viewport unlike in the other cases.
+    */
+
+    await browser.execute(selector => {
+      var element = document.querySelector(`${selector}`);
+      element.scrollTop = 0;
+    }, dropdownSelectorString);
+
+    while (true) {
+      if (await dropdownOption.isDisplayedInViewport()) {
+        break;
+      }
+      await browser.execute(selector => {
+        var element = document.querySelector(`${selector}`);
+        element.scrollTop += 100;
+      }, dropdownSelectorString);
+    }
+
+    await dropdownOption.waitForDisplayed({timeout: 5000});
+    await dropdownOption.waitForClickable({timeout: 5000});
     await action.click('clickable', dropdownOption);
     await saveNewChanges('System Language');
   };
@@ -127,10 +181,14 @@ var PreferencesPage = function () {
     await saveNewChanges('Preferred Audio Language');
   };
 
+  /**
+   * Replaces the existing user bio with the given text in the preferences page.
+   *
+   * @param {string} bio - The new bio text to set for the user.
+   */
   this.setUserBio = async function (bio) {
     var inputFieldName = 'User bio input field';
     await action.clear(inputFieldName, userBioElement);
-    await saveNewChanges('User Bio');
     await action.setValue(inputFieldName, userBioElement, bio);
     await saveNewChanges('User Bio');
   };
@@ -229,7 +287,7 @@ var PreferencesPage = function () {
       userBioElement,
       'User bio field takes too long to appear.'
     );
-    expect(await userBioElement.getAttribute('value')).toMatch(bio);
+    expect(await userBioElement.getValue()).toMatch(bio);
   };
 
   this.selectCreatorDashboard = async function () {