Repository: learningequality/kolibri

Issue:
Exit link at Select Content Page should be different if arriving from the New Channel Version Page
In #7387, we redirect users to the Select Content (from Studio) Page (SCP) after the user confirms they want to update a channel.

With this new navigation, the immersive toolbar's back button at the SCP (possibly unexpectedly) goes to Available Channels Page after use is redirected there.

Steps to reproduce:

1. click "update" button on new version page
1. get redirected to the "Select content page" for the channel
1. click immersive "<-" button 
1. get sent to do Kolibri Studio available channels page. (Expected behavior: ?)

Rel #6468

_Originally posted by @jonboiser in https://github.com/learningequality/kolibri/pull/7387#issuecomment-663755775_


Discussion:

Resolving patch:
diff --git a/kolibri/core/tasks/api.py b/kolibri/core/tasks/api.py
--- a/kolibri/core/tasks/api.py
+++ b/kolibri/core/tasks/api.py
@@ -1229,6 +1229,7 @@ def _job_to_response(job):
             "progress": [],
             "id": None,
             "cancellable": False,
+            "clearable": False,
         }
     else:
         output = {
@@ -1238,6 +1239,7 @@ def _job_to_response(job):
             "percentage": job.percentage_progress,
             "id": job.job_id,
             "cancellable": job.cancellable,
+            "clearable": job.state in [State.FAILED, State.CANCELED, State.COMPLETED],
         }
         output.update(job.extra_metadata)
         return output


Test patch:
diff --git a/kolibri/core/assets/test/views/auth-message.spec.js b/kolibri/core/assets/test/views/auth-message.spec.js
--- a/kolibri/core/assets/test/views/auth-message.spec.js
+++ b/kolibri/core/assets/test/views/auth-message.spec.js
@@ -1,10 +1,19 @@
 import urls from 'kolibri.urls';
 import Vuex from 'vuex';
-import { shallowMount } from '@vue/test-utils';
+import VueRouter from 'vue-router';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
 import AuthMessage from '../../src/views/AuthMessage';
+import { stubWindowLocation } from 'testUtils'; // eslint-disable-line
 
 jest.mock('urls', () => ({}));
 
+const localVue = createLocalVue();
+
+localVue.use(Vuex);
+localVue.use(VueRouter);
+
+const router = new VueRouter();
+
 function makeWrapper(options) {
   const store = new Vuex.Store({
     getters: {
@@ -13,7 +22,7 @@ function makeWrapper(options) {
       },
     },
   });
-  return shallowMount(AuthMessage, { store, ...options });
+  return shallowMount(AuthMessage, { store, localVue, router, ...options });
 }
 
 // prettier-ignore
@@ -25,6 +34,12 @@ function getElements(wrapper) {
 }
 
 describe('auth message component', () => {
+  stubWindowLocation(beforeAll, afterAll);
+
+  beforeEach(() => {
+    window.location.href = 'http://localhost:8000/#/test_url';
+  });
+
   it('shows the correct details when there are no props', () => {
     const wrapper = makeWrapper({ propsData: {} });
     const { headerText, detailsText } = getElements(wrapper);
@@ -69,17 +84,37 @@ describe('auth message component', () => {
     expect(detailsText()).toEqual('Must be device owner to manage resources');
   });
 
-  it('shows correct link text if there is a user plugin', () => {
-    const userUrl = jest.fn();
-    urls['kolibri:kolibri.plugins.user:user'] = userUrl;
-    userUrl.mockReturnValue('http://localhost:8000/en/user/');
-    const wrapper = makeWrapper();
-    const link = wrapper.find('kexternallink-stub');
-    expect(link.attributes()).toMatchObject({
-      href: 'http://localhost:8000/en/user/#/signin?next=http%3A%2F%2Fkolibri.time%2F',
-      text: 'Sign in to Kolibri',
+  describe('tests for sign-in page link when user plugin exists', () => {
+    beforeAll(() => {
+      const userUrl = jest.fn();
+      urls['kolibri:kolibri.plugins.user:user'] = userUrl;
+      userUrl.mockReturnValue('http://localhost:8000/en/user/');
+    });
+
+    afterAll(() => {
+      delete urls['kolibri:kolibri.plugins.user:user'];
+    });
+
+    it('shows correct link text if there is a user plugin', () => {
+      const wrapper = makeWrapper();
+      const link = wrapper.find('kexternallink-stub');
+      expect(link.attributes()).toMatchObject({
+        href:
+          'http://localhost:8000/en/user/#/signin?next=http%3A%2F%2Flocalhost%3A8000%2F%23%2Ftest_url',
+        text: 'Sign in to Kolibri',
+      });
+    });
+
+    it('if the next param is in URL, it is what is used in the sign-in page link', () => {
+      window.location.href = 'http://localhost:8000/#/some_other_url';
+      const wrapper = makeWrapper();
+      const link = wrapper.find('kexternallink-stub');
+      expect(link.attributes()).toMatchObject({
+        href:
+          'http://localhost:8000/en/user/#/signin?next=http%3A%2F%2Flocalhost%3A8000%2F%23%2Fsome_other_url',
+        text: 'Sign in to Kolibri',
+      });
     });
-    delete urls['kolibri:kolibri.plugins.user:user'];
   });
 
   it('shows correct link text if there is not a user plugin', () => {
diff --git a/kolibri/core/tasks/test/test_api.py b/kolibri/core/tasks/test/test_api.py
--- a/kolibri/core/tasks/test/test_api.py
+++ b/kolibri/core/tasks/test/test_api.py
@@ -6,6 +6,7 @@
 from mock import call
 from mock import Mock
 from mock import patch
+from mock import PropertyMock
 from rest_framework.exceptions import AuthenticationFailed
 from rest_framework.exceptions import ParseError
 from rest_framework.exceptions import PermissionDenied
@@ -89,6 +90,34 @@ def test_task_get_no_task(self, queue_mock):
         )
         self.assertEqual(response.status_code, 404)
 
+    def test_tasks_clearable_flag(self, queue_mock):
+        with patch(
+            "kolibri.core.tasks.queue.Queue.jobs", new_callable=PropertyMock
+        ) as jobs_mock:
+            jobs_mock.return_value = [
+                fake_job(state=state)
+                for state in [
+                    # not clearable
+                    State.SCHEDULED,
+                    State.QUEUED,
+                    State.RUNNING,
+                    State.CANCELING,
+                    # clearable
+                    State.FAILED,
+                    State.CANCELED,
+                    State.COMPLETED,
+                ]
+            ]
+            response = self.client.get(reverse("kolibri:core:task-list"))
+
+            def assert_clearable(index, expected):
+                self.assertEqual(response.data[index]["clearable"], expected)
+
+            for i in [0, 1, 2, 3]:
+                assert_clearable(i, False)
+            for i in [4, 5, 6]:
+                assert_clearable(i, True)
+
 
 class TaskAPIPermissionsTestCase(APITestCase):
     def setUp(self):
diff --git a/kolibri/plugins/device/assets/src/views/ManageTasksPage/__test__/TaskPanel.spec.js b/kolibri/plugins/device/assets/src/views/ManageTasksPage/__test__/TaskPanel.spec.js
--- a/kolibri/plugins/device/assets/src/views/ManageTasksPage/__test__/TaskPanel.spec.js
+++ b/kolibri/plugins/device/assets/src/views/ManageTasksPage/__test__/TaskPanel.spec.js
@@ -10,6 +10,7 @@ describe('TaskPanel', () => {
   const exportTask = {
     type: 'DISKCONTENTEXPORT',
     status: 'CANCELED',
+    clearable: true,
     channel_name: 'Canceled disk export channel test',
     started_by_username: 'Tester',
     file_size: 5000,
diff --git a/kolibri/plugins/device/assets/src/views/__test__/syncTaskUtils.spec.js b/kolibri/plugins/device/assets/src/views/__test__/syncTaskUtils.spec.js
--- a/kolibri/plugins/device/assets/src/views/__test__/syncTaskUtils.spec.js
+++ b/kolibri/plugins/device/assets/src/views/__test__/syncTaskUtils.spec.js
@@ -6,6 +6,8 @@ import {
   SyncTaskStatuses,
 } from '../syncTaskUtils';
 
+const CLEARABLE_STATUSES = ['COMPLETED', 'CANCELED', 'FAILED'];
+
 describe('syncTaskUtils.syncFacilityTaskDisplayInfo', () => {
   const CANCELLABLE_STATUSES = [
     SyncTaskStatuses.SESSION_CREATION,
@@ -28,6 +30,7 @@ describe('syncTaskUtils.syncFacilityTaskDisplayInfo', () => {
       bytes_sent: 1000000,
       bytes_received: 500000000,
       cancellable: CANCELLABLE_STATUSES.indexOf(status) >= 0,
+      clearable: CLEARABLE_STATUSES.indexOf(status) >= 0,
     };
   }
 
@@ -134,6 +137,7 @@ describe('syncTaskUtils.removeFacilityTaskDisplayInfo', () => {
     return {
       type: 'DELETEFACILITY',
       status,
+      clearable: CLEARABLE_STATUSES.indexOf(status) >= 0,
       facility_name: 'removed facility',
       facility: 'fac123',
       started_by_username: 'removing user',
diff --git a/packages/kolibri-tools/test/fixtures/TestComponent.vue b/packages/kolibri-tools/test/fixtures/TestComponent.vue
--- a/packages/kolibri-tools/test/fixtures/TestComponent.vue
+++ b/packages/kolibri-tools/test/fixtures/TestComponent.vue
@@ -36,31 +36,31 @@
       </p>
 
       <CoreTable v-else>
-        <thead slot="thead">
-          <tr>
-            <th>{{ $tr('classNameLabel') }}</th>
-            <th>{{ coachString('coachesLabel') }}</th>
-            <th>{{ coachString('learnersLabel') }}</th>
-          </tr>
-        </thead>
-        <transition-group slot="tbody" tag="tbody" name="list">
-          <tr v-for="classObj in classList" :key="classObj.id">
-            <td>
-              <KLabeledIcon icon="classes">
-                <KRouterLink
-                  :text="classObj.name"
-                  :to="$router.getRoute('HomePage', { classId: classObj.id })"
-                />
-              </KLabeledIcon>
-            </td>
-            <td>
-              <TruncatedItemList :items="classObj.coaches.map(c => c.full_name)" />
-            </td>
-            <td>
-              {{ coachString('integer', { value: classObj.learner_count }) }}
-            </td>
-          </tr>
-        </transition-group>
+        <template #headers>
+          <th>{{ $tr('classNameLabel') }}</th>
+          <th>{{ coachString('coachesLabel') }}</th>
+          <th>{{ coachString('learnersLabel') }}</th>
+        </template>
+        <template #tbody>
+          <transition-group tag="tbody" name="list">
+            <tr v-for="classObj in classList" :key="classObj.id">
+              <td>
+                <KLabeledIcon icon="classes">
+                  <KRouterLink
+                    :text="classObj.name"
+                    :to="$router.getRoute('HomePage', { classId: classObj.id })"
+                  />
+                </KLabeledIcon>
+              </td>
+              <td>
+                <TruncatedItemList :items="classObj.coaches.map(c => c.full_name)" />
+              </td>
+              <td>
+                {{ coachString('integer', { value: classObj.learner_count }) }}
+              </td>
+            </tr>
+          </transition-group>
+        </template>
       </CoreTable>
     </KPageContainer>