Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

UX for learner and coach/admin single user syncing #8202

Conversation

marcellamaki
Copy link
Member

@marcellamaki marcellamaki commented Jul 13, 2021

Summary

Fixes: #8187, closes #8178, resolves #8179

Front end feature for coach/admin view of sync statuses and learner dropdown. (Figma references)
Creates corresponding endpoints and adds to resource layer

What is covered in this PR:

  • Create new API endpoint for User Sync Status

  • The data returned from this endpoint should include the user, the last time the user synced (if they have synced before), if the user is actively syncing, or if the user is enqueued.

  • On the UX for learners, the sync status should be displayed for the learner in the dropdown menu (Figma: "Learner POV/changes to user dropdown")

Screen Shot 2021-07-16 at 7 07 31 PM

  • On the UX for coaches, the coach should be able to open a page that contains a table of learners and see their statuses. If the coach has questions about the status options, they can view the informational modal. Coaches should also be able to access this table from the quiz report view
    (Figma: "Coach POV")

Screen Shot 2021-07-16 at 7 00 13 PM

Screen Shot 2021-07-16 at 7 00 21 PM

Screen Shot 2021-07-16 at 7 00 28 PM

Screen Shot 2021-07-16 at 7 21 28 PM

Outstanding TO DOs:

  • Add in new icons after Misha's PR is merged
  • Finish new python tests and ensure all tests are passing

Follow up issues for: user name overflow, limited device messaging/"learn-only mode"

Reviewer guidance

To test these changes end to end, use kolibri shell to create some UserSyncStatus objects in the database. Alternatively, you can use some fake data values in just the front end for reviewing only the UX/front-end changes (which for a front-end only review, would probably be simpler). To do this, you can add objects for:

AppBar.vue (add user sync data for single learner to userSyncStatus)

   data() {
      return {
        userMenuDropdownIsOpen: false,
        userSyncStatus: null,
        isPolling: false,
      };
    },

ClassLearnersListPage.vue (add class list data, an array of user sync objects)

data: function() {
      return {
        displayTroubleshootModal: false,
        classSyncStatusList: [],
      };
    },

Each of these user objects should have the following keys "id", "queued", "sync_session", "last_synced", "active", "user", "user_id"


Testing checklist

  • Contributor has fully tested the PR manually
  • If there are any front-end changes, before/after screenshots are included
  • Critical user journeys are covered by Gherkin stories
  • Critical and brittle code paths are covered by unit tests

PR process

  • PR has the correct target branch and milestone
  • PR has 'needs review' or 'work-in-progress' label
  • If PR is ready for review, a reviewer has been added. (Don't use 'Assignees')
  • If this is an important user-facing change, PR or related issue has a 'changelog' label
  • If this includes an internal dependency change, a link to the diff is provided

Reviewer checklist

  • Automated test coverage is satisfactory
  • PR is fully functional
  • PR has been tested for accessibility regressions
  • External dependency files were updated if necessary (yarn and pip)
  • Documentation is updated
  • Contributor is in AUTHORS.md

Copy link
Member

@rtibbles rtibbles left a comment

Choose a reason for hiding this comment

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

Can use filters to prevent reimplementing the same endpoint multiple times!

@@ -164,3 +168,37 @@ def patch(self, request):
settings.name = request.data["name"]
settings.save()
return Response({"name": settings.name})


class UserSyncStatusViewSet(ReadOnlyValuesViewset):
Copy link
Member

Choose a reason for hiding this comment

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

These are confusingly named, my inference is that they are named the wrong way around, looking at the filtering going on in the get_queryset method.



class ClassListSyncStatusViewSet(ReadOnlyValuesViewset):
values = (
Copy link
Member

Choose a reason for hiding this comment

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

As the only difference between these two Viewsets is filtering that is done, I'd recommend consolidating these and using a FilterSet to return different sets of data.

class SyncStatusFilter(FilterSet):

    member_of = ModelChoiceFilter(
        method="filter_member_of", queryset=Collection.objects.all()
    )

    def filter_member_of(self, queryset, name, value):
        return queryset.filter(Q(user__memberships__collection=value) | Q(user__facility=value))

    class Meta:
        model = UserSyncStatus
        fields = ["user", "member_of"]

Can then add to the viewset as:

filter_backends = (KolibriAuthPermissionsFilter, DjangoFilterBackend)
filter_class = SyncStatusFilter

We don't actually need KolibriAuthPermissionsFilter on there, but we should also add the permission based filtering to prevent unauthorized access.

Copy link
Member

@rtibbles rtibbles left a comment

Choose a reason for hiding this comment

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

Some clean up and questions.

@@ -82,6 +82,15 @@
:userType="getUserKind"
/>
</div>
<div v-if="getUserKind === 'learner'">
Copy link
Member

Choose a reason for hiding this comment

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

At some point (but not necessarily now) we should refactor the name of this getter to userKind - calling it getUserKind is more in line with our practice of getters that are actually functions (I had to double check that this was not expecting the id of a user as an argument).

@@ -208,11 +240,31 @@
this.$emit('showLanguageModal');
this.userMenuDropdownIsOpen = false;
},
mapSyncStatusOptionToLearner() {
Copy link
Member

Choose a reason for hiding this comment

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

Any reason for this not to be a computed prop, rather than an invoked method?

@@ -208,11 +240,31 @@
this.$emit('showLanguageModal');
this.userMenuDropdownIsOpen = false;
},
mapSyncStatusOptionToLearner() {
if (this.userSyncStatus) {
Copy link
Member

Choose a reason for hiding this comment

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

Empty arrays are not falsey in Javascript, so I don't know when this would not be true - unless the API endpoint can return null?

@@ -153,13 +164,16 @@
data() {
return {
userMenuDropdownIsOpen: false,
userSyncStatus: [],
Copy link
Member

Choose a reason for hiding this comment

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

Given that this is expected to be a plain object, using an empty array seems like an error here. Maybe null as you are doing a falsey check elsewhere?

@@ -127,7 +127,7 @@

@import '~kolibri-design-system/lib/styles/definitions';
.ui-menu-header {
padding: 1rem 1rem 1rem 50px;
padding: 1rem 1rem 1rem 1.2rem;
Copy link
Member

Choose a reason for hiding this comment

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

I feel like there might be a reason for this fixed pixel padding here, or does 1.2rem give the desired padding at all screen resolutions?

Copy link
Member Author

Choose a reason for hiding this comment

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

I also suspect there might be a reason, but keeping it at 50px now causes some very wonky alignment issues in the learner view. I've tests it in several places with different screen sizes and so far I don't see any issues with this change, but I definitely do not know the ins and outs of manual QA to know if there are no problems. Also open to suggestions about how else I might set up the nested component here in a way that would keep me from making an update in index.vue - maybe @MisRob would have an idea...

Copy link
Member

Choose a reason for hiding this comment

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

I'll have a look

Copy link
Member

Choose a reason for hiding this comment

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

I assume that there indeed was a specific reason to have 50px and that it was to make header text aligned with options. See the screenshot from develop:

Screenshot from 2021-07-22 10-27-05

According to the new designs for user syncing

Screenshot from 2021-07-22 10-35-43

this is to be deprecated, at least in some cases, so this change looks fine to me.

However, I am not sure if we're supposed to keep the previous layout when no sync information is visible in the menu, like for the admin. If yes, this change will cause the following regression:

Screenshot from 2021-07-22 10-33-43

I feel fine about using the new layout everywhere for the sake of consistency. Maybe let's see what designers think.

Copy link
Member

Choose a reason for hiding this comment

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

In any case, it's not a blocker. I checked all places where we use CoreMenu and haven't noticed any other issues.

@@ -186,3 +186,40 @@ def get_queryset(self):
lesson_assignments__collection__membership__user=self.request.user,
is_active=True,
)


class LearnerSyncStatusViewset(ReadOnlyValuesViewset):
Copy link
Member

Choose a reason for hiding this comment

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

I assume this will be deleted in favour of the general viewset above?

@@ -55,7 +55,6 @@
},
},
computed: {
...mapState(['classList']),
Copy link
Member

Choose a reason for hiding this comment

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

Is this state not required?

import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings';
import { SyncStatus } from 'kolibri.coreVue.vuex.constants';
import { mapState, mapActions } from 'vuex';
import SyncStatusDisplay from '../../../../../core/assets/src/views/SyncStatusDisplay';
Copy link
Member

Choose a reason for hiding this comment

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

One slight issue with doing this import is that we will either duplicate the strings across the core and the coach plugin or the strings will not be included in the coach plugin (I have completely forgotten at this point which way the i18n machinery goes in this regard).

It might be simpler (for a certain value of simple) to add these components to the core API and reference them in the same way as CoreBase and CoreTable.


def annotate_queryset(self, queryset):

queryset = queryset.annotate(
Copy link
Member

Choose a reason for hiding this comment

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

These feel right, we can double check after merging to see if doing this or an explicit subquery produces a more performant query.

@marcellamaki marcellamaki added changelog Important user-facing changes TODO: needs review Waiting for review labels Jul 20, 2021
@marcellamaki marcellamaki requested a review from MisRob July 20, 2021 14:47
@marcellamaki marcellamaki marked this pull request as ready for review July 20, 2021 14:47
@marcellamaki
Copy link
Member Author

I believe this is as ready for review as I am able to make it right now. I am having an issue with some of my tests not passing, and I would be happy to chat with either @MisRob or @rtibbles if you have any suggestions. I am 90% sure it has to do with async issues but....I can't quite figure out where I'm going wrong.

@@ -56,6 +61,14 @@
return isEmbeddedWebView || this.disableExport;
},
},
methods: {
viewLearners() {
this.$router.push(this.$router.getRoute('ClassLearnersListPage'));
Copy link
Member

Choose a reason for hiding this comment

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

Same as above

@@ -65,10 +76,16 @@
return this.$router.getRoute('CoachClassListPage', {}, { facility_id });
},
},
methods: {
viewLearners() {
this.$router.push(this.$router.getRoute('ClassLearnersListPage'));
Copy link
Member

Choose a reason for hiding this comment

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

if you give a name to the route in kolibri/plugins/coach/assets/src/routes/index.js it would be simpler, this.$router.push('CLASSLEARNERSLIST'),

@@ -35,6 +35,17 @@
{{ $formatNumber(learnerNames.length) }}
</template>
</HeaderTableRow>
<HeaderTableRow v-if="learnerNames.length > 0">
Copy link
Member

Choose a reason for hiding this comment

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

<HeaderTableRow v-if="learnerNames.length"> is enough if you're sure learnerNames is defined, is you need to check if learnerNames is not undefined:
<HeaderTableRow v-if="!learnerNames || !learnerNames.length">

} else if (learnerSyncData.last_synced) {
const currentDateTime = new Date();
const TimeDifference = learnerSyncData.last_synced - currentDateTime;
const diffMins = Math.round(((TimeDifference % 86400000) % 3600000) / 60000);
Copy link
Member

Choose a reason for hiding this comment

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

Same as above

if (this.isPolling) {
setTimeout(() => {
this.pollClassListSyncStatuses();
}, 10000);
Copy link
Member

Choose a reason for hiding this comment

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

maybe this polling interval should be a property instead, so a) it's changeable and b) it's more readable

const currentDateTime = new Date();
const TimeDifference = this.userSyncStatus.last_synced - currentDateTime;
const diffMins = Math.round(((TimeDifference % 86400000) % 3600000) / 60000);
if (diffMins < 60) {
Copy link
Member

Choose a reason for hiding this comment

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

would not be simpler something like:
if (TimeDifference < 5184000000) // 5184000000 = 60 days in milliseconds

},
},
computed: {
syncTextDisplayMap() {
Copy link
Member

@jredrejo jredrejo Jul 20, 2021

Choose a reason for hiding this comment

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

Maybe I'm too nitpicky, but seeing a switch that can be so easily replaced by an object looks ugly to me:

const statusTranslations= {SyncStatus.RECENTLY_SYNCED:this.$tr('syncedDescription') , 
SyncStatus.QUEUED:this.$tr('queuedDescription'), ....}

return statusTranslations[this.syncStatus] || ''

looks better than a switch for me, but I can be wrong

}
},
syncIconDisplayMap() {
switch (this.syncStatus) {
Copy link
Member

Choose a reason for hiding this comment

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

same look and feel with the switch as above

@@ -64,6 +65,14 @@ export default [
titleParts: ['activityLabel', 'CLASS_NAME'],
},
},

{
path: '/:classId/learners',
Copy link
Member

@jredrejo jredrejo Jul 20, 2021

Choose a reason for hiding this comment

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

I'd add
name: 'CLASSLEARNERSLIST'
so future references to this route are easier

Copy link
Member

@MisRob MisRob left a comment

Choose a reason for hiding this comment

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

Awesome work @marcellamaki :) Regarding frontend, I appreciate components architecture. I've pointed out several issues in code comments. Feel absolutely free to skip or open follow-ups for those that are marked correspondingly. I only skimmed through backend briefly. Thanks for the backend test suite - it helped me to understand the whole feature and also some frontend parts better. It was my first encounter with syncing so it was a lot of studying - thanks for the detailed PR description and all references. I haven't gotten around to testing with kolibri shell.

Regarding problems with tests, I'll have a look.

Please also see the following:

1. Sync statutes are being polled even when I visit "Learn" while not logged in. However, there is no information about the sync status in the user menu for a guest user. I assume that the only purpose of polling from AppBar is to fetch this information for UI or are there any more reasons? If this assumption is correct, I'd suggest removing it for guests.

Peek 2021-07-21 16-33

2. When I navigate to the learners page from "Reports" tab in "Coach" and then click the back button, I expect to be returned to the "Reports" tab but I am taken to "Class Home" tab instead.

Peek 2021-07-21 17-06

mapSyncStatusOptionToLearner() {
if (this.userSyncStatus) {
if (this.userSyncStatus.active) {
return SyncStatus.SYNCINGSYNCING;
Copy link
Member

Choose a reason for hiding this comment

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

Syncing syncing
still syncing
stiiill syncing and syncing... :)

SyncStatus.SYNCING

beforeDestroy() {
window.removeEventListener('click', this.handleWindowClick);
this.isPolling = false;
Copy link
Member

Choose a reason for hiding this comment

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

(Can be follow-up) I'd suggest using clearTimeout to stop polling similarly to https://github.com/learningequality/kolibri/blob/develop/kolibri/plugins/learn/assets/src/views/classes/ClassAssignmentsPage.vue#L49.

Depending on timing, the current implementation can cause one unnecessary call that seems to be unintentional. You can see it here, for example (please wait a while for the second call in the console):

peek

Generally speaking, such behavior has potential to cause unexpected errors - for example when a request would be made after a user logs out to an endpoint that requires authentication. In this case, it's doesn't break anything in this way. Maybe due to our multi-apps architecture (I assume that completely another bundle is loaded after being redirected to login)? So it's not a blocker but would be good to address at some point.

if (this.isPolling) {
setTimeout(() => {
this.pollUserSyncStatusTask();
}, 10000);
Copy link
Member

Choose a reason for hiding this comment

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

(Would be nice but no action needed) Might be good to save polling interval value to constants so we can locate it easily when we need to tweak it

type: String,
default: '',
},
displaySize: {
Copy link
Member

Choose a reason for hiding this comment

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

When choosing between more predefined values, we usually add a validator to document it and also to check on those values during development. Here's one example https://github.com/learningequality/kolibri/blob/develop/kolibri/plugins/epub_viewer/assets/src/views/PreviousButton.vue#L25.

Copy link
Member

Choose a reason for hiding this comment

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

(Would be nice but no action needed) Is there any reason for not using simpler names for displaySize values? For example, small instead of sync-status-small.

Copy link
Member Author

Choose a reason for hiding this comment

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

No, that is a good suggestion! I have still not adjusted to file-scoped CSS and am used to having very long, descriptive class names needed to keep track of things in giant css files 😂 Here simpler is definitely better!

<template #key>
</template>
<template #value>
<KButton
Copy link
Member

Choose a reason for hiding this comment

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

This element is navigating users to another page so I think that using KRouterLink would be more appropriate (https://design-system.learningequality.org/buttons/)

},
mounted() {
this.isPolling = true;
this.pollClassListSyncStatuses({ classroom_id: this.$route.params.classId });
Copy link
Member

Choose a reason for hiding this comment

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

pollClassListSyncStatuses method doesn't accept any parameters

@@ -10,5 +10,8 @@
r"learnerclassroom", LearnerClassroomViewset, base_name="learnerclassroom"
)
router.register(r"learnerlesson", LearnerLessonViewset, base_name="learnerlesson")
router.register(
r"learnersyncstatus", LearnerLessonViewset, base_name="learnersyncstatus"
Copy link
Member

Choose a reason for hiding this comment

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

I haven't noticed this URL being used

@@ -897,3 +901,16 @@ export function notLoading(store) {
});
});
}

export function fetchUserSyncStatus(store, id) {
Copy link
Member

Choose a reason for hiding this comment

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

(Would be nice but no action needed) When reviewing, I was a bit confused that id can be both a user ID and a class ID. Might be helpful to document it here.

Copy link
Member

Choose a reason for hiding this comment

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

This comment is not true.

The backend expects either user to filter by a user_id or member_of to filter by a collection_id - this flexible use of id is doing no filtering at all.

@MisRob
Copy link
Member

MisRob commented Jul 26, 2021

@marcellamaki @rtibbles I've briefly skimmed through the latest frontend updates and haven't noticed any issues

@marcellamaki marcellamaki changed the title (WIP) UX for learner and coach/admin single user syncing UX for learner and coach/admin single user syncing Jul 26, 2021
Copy link
Member

@rtibbles rtibbles left a comment

Choose a reason for hiding this comment

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

Two critical changes needed.

def test_user_sync_status_class_single_user_for_filter(self):
response = self.client.get(
reverse("kolibri:core:usersyncstatus-list"),
data={"user_id": self.user1.id},
Copy link
Member

Choose a reason for hiding this comment

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

Here you are filtering by user_id but the FilterSet for this endpoint specifies user as the parameter.

You should create another UserSyncStatus during your test setup for this test to be meaningful.

@@ -897,3 +901,16 @@ export function notLoading(store) {
});
});
}

export function fetchUserSyncStatus(store, id) {
Copy link
Member

Choose a reason for hiding this comment

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

This comment is not true.

The backend expects either user to filter by a user_id or member_of to filter by a collection_id - this flexible use of id is doing no filtering at all.

@rtibbles rtibbles changed the base branch from develop to release-v0.15.x July 27, 2021 17:59
@indirectlylit indirectlylit added this to the 0.15.0 milestone Jul 27, 2021
getParams: { member_of: params.member_of },
}).then(
syncData => {
store.commit('SET_CORE_CHANNEL_LIST', _userSyncStatusState(syncData));
Copy link
Member

@rtibbles rtibbles Jul 27, 2021

Choose a reason for hiding this comment

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

One last question here - why is this being stored using the SET_CORE_CHANNEL_LIST mutation? This seems like the wrong place to store this data.

I think we'd be better off not setting any store state in here, and just returning the data from the function.

Should also return the same data as we are storing if the _userSyncStatusState mapping function is important.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
changelog Important user-facing changes TODO: needs review Waiting for review
Projects
None yet
5 participants