Repository: oppia/oppia

Issue:
Prevent users from losing work when they lose internet connection.
A user may be in the process of creating an exploration when they lose internet connection. This can cause frustration as attempting to refresh the browser at this point will cause the user to lose any work which they have so far done on the page (e.g if they were inputting a large amount of text, it will be gone). See [here](https://github.com/oppia/oppia/issues/3200) for more detail.

The aim of this is to solve this frustration for the user by protecting their work when they suddenly go offline. A possible approach would be to detect when the user goes offline and at that point save the work they have completed so far into localStorage. This way after they refresh we still have the work saved locally. As a result it will be possible to repopulate the page with the work which they would have otherwise lost.

To replicate fire up the dev server and create an exploration. Click on a state to and add content to the state editor.
![lotsoftext](https://cloud.githubusercontent.com/assets/16727074/24879799/e92b64b4-1e2f-11e7-88c1-ff4290aab96b.PNG)
Now disable the internet connection (check the offline radio button in the network tab in dev tools), then click save content. Oppia will prompt you to refresh.
![oppiaaskstorefresh](https://cloud.githubusercontent.com/assets/16727074/24879854/17275f9e-1e30-11e7-9fcd-c9da44a56601.PNG)
So you fix your internet connection and refresh, but then the work is gone!
![byebyework](https://cloud.githubusercontent.com/assets/16727074/24879904/45842142-1e30-11e7-9687-16d23aaa0b4d.PNG)

This would clearly be frustrating for a user so we need a way to deal with it. Note that the problem isn't so much the loss of internet connection, but the fact that the user will lose any text they have written when they refresh if it is not yet saved. It would be nice to protect the user against this such that when they refresh without saving their work is still protected, preferably without needing any direct action on the user's part so that when they refresh they get...

![whatwewant](https://cloud.githubusercontent.com/assets/16727074/24880018/a811fbcc-1e30-11e7-926e-3aa66860dbe5.PNG)




Discussion:
Hi @seanlip I've got a few questions about how to approach this. 
I'm taking the problem to be that a creator who has entered a lot of work onto an exploration card will lose this work if they refresh their browser. (I think it's sufficient to focus on the situation where creator's are creating explorations, I can't think of anywhere else a user will enter enough text for this to be a frustration but correct me if I am wrong).

Now onto the solution, rather than saving the user's work when they go offline per se, I'm thinking it may be better to save the user's work (into localStorage) at regular intervals, then when the user refreshes the webpage we can restore they're work. This may be better because it solves the more general problem of the user losing work when they refresh the webpage, as opposed to just solving the issue of the user losing work when they go offline. 
So, one thing I should mention is that we do already have functionality for autosaving drafts to the backend. I think localStorage could be part of a solution, but was wondering how the two things (drafts and localStorage) would interact with each other?
Okay I've had a look at the code and from what I can tell it works as follows: whenever the user makes a change to the exploration (e.g. adding a new state or editing a state) they have to save it (e.g save content in the case of editing the content property of a state). This is registered as a change and is autosaved to the backend. The user manually saves by clicking save draft, which essentially commits the user to the changes he has made so far and clears the list of changes. Please correct me if I'm wrong about any of that. 


Okay so onto the issue of integrating these functionalities. It seems to me that the only change which needs to saved to the frontend is the text content of a state editor (i.e the key could be the state name, the value would be the text content of the state editor), so we will not need to save the full changeList to the frontend.  Furthermore, the way the changeList auto save behaves is quite different to the behavior we need here, hence the implementation of the local autosave functionality may be somewhat separate from the back end autosaves (e.g. we do not want to populate the list of changes every time the user types something into the state editor).  The integration I think will play more of a role in terms of updating the autosaved data. That is, when the page is refreshed the autosaved data is fetched from the backend. It may be the case that we have a local change saved more recently that this backup local save and so we may need to update the state property of the autosaved change list with the more recent text content. A problem here would be in knowing exactly which is more recent (the local autosave or the backend autosave). Given how the backend autosave works however I think it will be reasonable to assume that the local autosave will be the more recent copy, so if there exists text content in the local autosave then we augment the autosave gathered from the backend to reflect the new content before returning the changelist.  This could be done by looping through the keys (i..e state names) we've saved to localStorage and modifying the text content property of the appropriate state in the draft.

I think it makes sense to include these modifications in changeListService (where the autosave functionality is handled). To summarize, we need to expose one new function and modify one existing one. The new one should be capable of taking a state name and text content from a state editor and saving it to localStorage if a matching key:value pair (matching in the sense that the text contents are the same) doesn't exist.  This could be called in intervals from StateEditor.js. Next we need to modify the loadAutoSaveData function which currently takes a draft and populates the list of changes in changeListService with this draft list. Instead it needs to first update the text contents of each state as necessary, and also return this newly updated draft to be used instead of the original draft fetched from the backend. 

I may need to investigate a little more but does this seem like a viable approach to you?
Thanks, @Anthony-Alridge! Let's step back one second, first; I want to fully understand the exact "success criteria" for what solving this issue entails.

In the issue description at the top, would you mind providing some steps that one could take on a local server to simulate this, a description of what happens, and a description of what should instead happen? Note that there's a checkbox in the chrome devtools (Network tab) that you can use to simulate an offline connection.

In particular I want to understand the intended behaviour from the user perspective, and then we can drill down (based on your discussion above) to see what measures we need to take in order to bring about that behaviour.

Thanks!
Thanks @seanlip I've amended it, how's that?
The new description is excellent. Thanks @Anthony-Alridge!

So, looking at your notes: I agree with your first paragraph. I think the components you are looking at are the right ones and I think the approach looks promising, but I would suggest thinking about two things:
- how are you going to keep localStorage and the autosaved drafts in sync (eventually)? It seems to me that if you end up restoring from localStorage we should perhaps update the autosaved drafts as well?
- would it make sense to store timestamps so that you can resolve which changes happened earlier/later?

Perhaps it might make sense to start a design doc in Google Docs so that you have more room to lay out your approach and so that it can be commented on.

Thanks!
Hi @seanlip I've opened up a doc [here](https://docs.google.com/document/d/1hLoDAJldmMz5_bT_aQ4G_YFnTkX2ghI2gIx9HyC9F0g/edit?usp=sharing).  I need to add more detail into the exact changes (hopefully will do tomorrow) but it provides an overview similar to above, please tell me what you think. Thanks.

I've tried to address your points in the doc but indirectly so I'll comment here:

- When loadAutoSaveData is called the changes will be amended by using the addChange helper method within ChangeListService, which will add these changes to the backend. This should keep them in sync.

- I'm not sure if the timestamps will be necessary for this. I've explained it in the design doc but I think that the implementation will be such that the locally saved text content will always be more recent than any text content saved in the backend (provided that when the user clicks save content to save their draft of the text content the code is amended such that any local saves related to that state are deleted). I'm not against storing the timestamps of course but i'm not sure if it's necessary (and may it be worth avoiding having to change the models etc to reflect the change if not needed)?

Thanks!

Hi @Anthony-Alridge, I'm going to pass this to @wxyxinyu since I'm a bit strapped for bandwidth at the moment. She has a lot of experience with the saving system so she will be able to give you good feedback, probably this weekend. Sorry for the delay!
Hi @Anthony-Alridge -- thanks for the doc! I left a few comments. I'm slightly confused by what exactly you plan to save in local storage, so some clarification would be nice.
@Anthony-Alridge ping?
Hi sorry about the late response something came up last week, hey @wxyxinyu thanks for that. I've added some comments to the doc, does that clarify things?
@Anthony-Alridge @wxyxinyu A friendly ping. Any updates on this?
@Anthony-Alridge @wxyxinyu  ping

Resolving patch:
diff --git a/core/controllers/editor.py b/core/controllers/editor.py
--- a/core/controllers/editor.py
+++ b/core/controllers/editor.py
@@ -288,11 +288,14 @@ def _get_exploration_data(
                 exploration_id, exp_user_data.draft_change_list_exp_version)
             if exp_user_data and exp_user_data.draft_change_list_exp_version
             else None)
+        draft_change_list_id = (exp_user_data.draft_change_list_id
+                                if exp_user_data else 0)
         exploration_email_preferences = (
             user_services.get_email_preferences_for_exploration(
                 self.user_id, exploration_id))
         editor_dict = {
             'category': exploration.category,
+            'draft_change_list_id': draft_change_list_id,
             'exploration_id': exploration_id,
             'init_state_name': exploration.init_state_name,
             'language_code': exploration.language_code,
@@ -953,11 +956,14 @@ def put(self, exploration_id):
         except utils.ValidationError as e:
             # We leave any pre-existing draft changes in the datastore.
             raise self.InvalidInputException(e)
-
-        # If the value passed here is False, have the user discard the draft
+        exp_user_data = user_models.ExplorationUserDataModel.get(
+            self.user_id, exploration_id)
+        draft_change_list_id = exp_user_data.draft_change_list_id
+        # If the draft_change_list_id is False, have the user discard the draft
         # changes. We save the draft to the datastore even if the version is
         # invalid, so that it is available for recovery later.
         self.render_json({
+            'draft_change_list_id': draft_change_list_id,
             'is_version_of_draft_valid': exp_services.is_version_of_draft_valid(
                 exploration_id, version)})
 
diff --git a/core/domain/exp_services.py b/core/domain/exp_services.py
--- a/core/domain/exp_services.py
+++ b/core/domain/exp_services.py
@@ -1965,14 +1965,12 @@ def create_or_update_draft(
         exp_user_data = user_models.ExplorationUserDataModel.create(
             user_id, exp_id)
 
-    draft_id = exp_user_data.draft_change_list_id
-    if draft_id is None:
-        draft_id = 0
-    draft_id += 1
+    draft_change_list_id = exp_user_data.draft_change_list_id
+    draft_change_list_id += 1
     exp_user_data.draft_change_list = change_list
     exp_user_data.draft_change_list_last_updated = current_datetime
     exp_user_data.draft_change_list_exp_version = exp_version
-    exp_user_data.draft_change_list_id = draft_id
+    exp_user_data.draft_change_list_id = draft_change_list_id
     exp_user_data.put()
 
 
diff --git a/core/storage/user/gae_models.py b/core/storage/user/gae_models.py
--- a/core/storage/user/gae_models.py
+++ b/core/storage/user/gae_models.py
@@ -355,8 +355,11 @@ class ExplorationUserDataModel(base_models.BaseModel):
     draft_change_list_last_updated = ndb.DateTimeProperty(default=None)
     # The exploration version that this change list applied to.
     draft_change_list_exp_version = ndb.IntegerProperty(default=None)
-    #The exploration draft version of this change list.
-    draft_change_list_id = ndb.IntegerProperty(default=None)
+    # The version of the draft change list which was last saved by the user.
+    # Can be zero if the draft is None or if the user has not committed
+    # draft changes to this exploration since the draft_change_list_id property
+    # was introduced.
+    draft_change_list_id = ndb.IntegerProperty(default=0)
     # The user's preference for receiving suggestion emails for this exploration
     mute_suggestion_notifications = ndb.BooleanProperty(
         default=feconf.DEFAULT_SUGGESTION_NOTIFICATIONS_MUTED_PREFERENCE)


Test patch:
diff --git a/core/controllers/editor_test.py b/core/controllers/editor_test.py
--- a/core/controllers/editor_test.py
+++ b/core/controllers/editor_test.py
@@ -1413,13 +1413,15 @@ def _create_exp_user_data_model_objects_for_tests(self):
             exploration_id=self.EXP_ID1,
             draft_change_list=self.DRAFT_CHANGELIST,
             draft_change_list_last_updated=self.NEWER_DATETIME,
-            draft_change_list_exp_version=1).put()
+            draft_change_list_exp_version=1,
+            draft_change_list_id=1).put()
         user_models.ExplorationUserDataModel(
             id='%s.%s' % (self.owner_id, self.EXP_ID2), user_id=self.owner_id,
             exploration_id=self.EXP_ID2,
             draft_change_list=self.DRAFT_CHANGELIST,
             draft_change_list_last_updated=self.OLDER_DATETIME,
-            draft_change_list_exp_version=1).put()
+            draft_change_list_exp_version=1,
+            draft_change_list_id=1).put()
 
         # Exploration with no draft.
         user_models.ExplorationUserDataModel(
@@ -1440,9 +1442,10 @@ def setUp(self):
     def test_exploration_loaded_with_draft_applied(self):
         response = self.get_json(
             '/createhandler/data/%s' % self.EXP_ID2, {'apply_draft': True})
-        # Title updated because chanhe list was applied.
+        # Title updated because change list was applied.
         self.assertEqual(response['title'], 'Updated title')
         self.assertTrue(response['is_version_of_draft_valid'])
+        self.assertEqual(response['draft_change_list_id'], 1)
         # Draft changes passed to UI.
         self.assertEqual(response['draft_changes'], self.DRAFT_CHANGELIST)
 
@@ -1456,6 +1459,7 @@ def test_exploration_loaded_without_draft_when_draft_version_invalid(self):
         # Title not updated because change list not applied.
         self.assertEqual(response['title'], 'A title')
         self.assertFalse(response['is_version_of_draft_valid'])
+        self.assertEqual(response['draft_change_list_id'], 1)
         # Draft changes passed to UI even when version is invalid.
         self.assertEqual(response['draft_changes'], self.DRAFT_CHANGELIST)
 
@@ -1465,6 +1469,7 @@ def test_exploration_loaded_without_draft_as_draft_does_not_exist(self):
         # Title not updated because change list not applied.
         self.assertEqual(response['title'], 'A title')
         self.assertIsNone(response['is_version_of_draft_valid'])
+        self.assertEqual(response['draft_change_list_id'], 0)
         # Draft changes None.
         self.assertIsNone(response['draft_changes'])
 
@@ -1482,6 +1487,7 @@ def test_draft_not_updated_because_newer_draft_exists(self):
         self.assertEqual(
             exp_user_data.draft_change_list, self.DRAFT_CHANGELIST)
         self.assertTrue(response['is_version_of_draft_valid'])
+        self.assertEqual(response['draft_change_list_id'], 1)
 
     def test_draft_not_updated_validation_error(self):
         self.put_json(
@@ -1498,6 +1504,8 @@ def test_draft_not_updated_validation_error(self):
             '%s.%s' % (self.owner_id, self.EXP_ID2))
         self.assertEqual(
             exp_user_data.draft_change_list, self.DRAFT_CHANGELIST)
+            #id is incremented the first time but not the second
+        self.assertEqual(exp_user_data.draft_change_list_id, 2)
         self.assertEqual(
             response, {'code': 400,
                        'error': 'Expected title to be a string, received 1'})
@@ -1515,6 +1523,7 @@ def test_draft_updated_version_valid(self):
         self.assertEqual(exp_user_data.draft_change_list, self.NEW_CHANGELIST)
         self.assertEqual(exp_user_data.draft_change_list_exp_version, 1)
         self.assertTrue(response['is_version_of_draft_valid'])
+        self.assertEqual(response['draft_change_list_id'], 2)
 
     def test_draft_updated_version_invalid(self):
         payload = {
@@ -1529,6 +1538,7 @@ def test_draft_updated_version_invalid(self):
         self.assertEqual(exp_user_data.draft_change_list, self.NEW_CHANGELIST)
         self.assertEqual(exp_user_data.draft_change_list_exp_version, 10)
         self.assertFalse(response['is_version_of_draft_valid'])
+        self.assertEqual(response['draft_change_list_id'], 2)
 
     def test_discard_draft(self):
         self.post_json(