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
UX for learner and coach/admin single user syncing #8202
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can use filters to prevent reimplementing the same endpoint multiple times!
kolibri/core/device/api.py
Outdated
@@ -164,3 +168,37 @@ def patch(self, request): | |||
settings.name = request.data["name"] | |||
settings.save() | |||
return Response({"name": settings.name}) | |||
|
|||
|
|||
class UserSyncStatusViewSet(ReadOnlyValuesViewset): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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 = ( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some clean up and questions.
@@ -82,6 +82,15 @@ | |||
:userType="getUserKind" | |||
/> | |||
</div> | |||
<div v-if="getUserKind === 'learner'"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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: [], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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...
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll have a look
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
:
According to the new designs for user syncing
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:
I feel fine about using the new layout everywhere for the sake of consistency. Maybe let's see what designers think.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In any case, it's not a blocker. I checked all places where we use CoreMenu
and haven't noticed any other issues.
kolibri/plugins/learn/viewsets.py
Outdated
@@ -186,3 +186,40 @@ def get_queryset(self): | |||
lesson_assignments__collection__membership__user=self.request.user, | |||
is_active=True, | |||
) | |||
|
|||
|
|||
class LearnerSyncStatusViewset(ReadOnlyValuesViewset): |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I assume this will be deleted in favour of the general viewset above?
@@ -55,7 +55,6 @@ | |||
}, | |||
}, | |||
computed: { | |||
...mapState(['classList']), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These feel right, we can double check after merging to see if doing this or an explicit subquery produces a more performant query.
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')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as above
@@ -65,10 +76,16 @@ | |||
return this.$router.getRoute('CoachClassListPage', {}, { facility_id }); | |||
}, | |||
}, | |||
methods: { | |||
viewLearners() { | |||
this.$router.push(this.$router.getRoute('ClassLearnersListPage')); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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"> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
<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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as above
if (this.isPolling) { | ||
setTimeout(() => { | ||
this.pollClassListSyncStatuses(); | ||
}, 10000); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would not be simpler something like:
if (TimeDifference < 5184000000) // 5184000000 = 60 days in milliseconds
}, | ||
}, | ||
computed: { | ||
syncTextDisplayMap() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
same look and feel with the switch as above
@@ -64,6 +65,14 @@ export default [ | |||
titleParts: ['activityLabel', 'CLASS_NAME'], | |||
}, | |||
}, | |||
|
|||
{ | |||
path: '/:classId/learners', |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd add
name: 'CLASSLEARNERSLIST'
so future references to this route are easier
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
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.
mapSyncStatusOptionToLearner() { | ||
if (this.userSyncStatus) { | ||
if (this.userSyncStatus.active) { | ||
return SyncStatus.SYNCINGSYNCING; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Syncing syncing
still syncing
stiiill syncing and syncing... :)
SyncStatus.SYNCING
beforeDestroy() { | ||
window.removeEventListener('click', this.handleWindowClick); | ||
this.isPolling = false; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(Can 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):
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); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(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: { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(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
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This 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 }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pollClassListSyncStatuses
method doesn't accept any parameters
kolibri/plugins/learn/api_urls.py
Outdated
@@ -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" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I haven't noticed this URL being used
kolibri/plugins/coach/assets/src/views/ClassLearnersListPage.vue
Outdated
Show resolved
Hide resolved
kolibri/plugins/coach/assets/src/views/ClassLearnersListPage.vue
Outdated
Show resolved
Hide resolved
@@ -897,3 +901,16 @@ export function notLoading(store) { | |||
}); | |||
}); | |||
} | |||
|
|||
export function fetchUserSyncStatus(store, id) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This 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.
@marcellamaki @rtibbles I've briefly skimmed through the latest frontend updates and haven't noticed any issues |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two critical changes needed.
kolibri/core/device/test/test_api.py
Outdated
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}, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This 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.
getParams: { member_of: params.member_of }, | ||
}).then( | ||
syncData => { | ||
store.commit('SET_CORE_CHANNEL_LIST', _userSyncStatusState(syncData)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
0daf73b
to
dcf3976
Compare
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")
(Figma: "Coach POV")
Outstanding TO DOs:
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
)ClassLearnersListPage.vue (add class list data, an array of user sync objects)
Each of these user objects should have the following keys
"id", "queued", "sync_session", "last_synced", "active", "user", "user_id"
Testing checklist
PR process
Reviewer checklist
yarn
andpip
)