-
Notifications
You must be signed in to change notification settings - Fork 699
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
Lesson resources selection #12895
base: develop
Are you sure you want to change the base?
Lesson resources selection #12895
Conversation
:text="coreString('viewMoreAction')" | ||
:primary="false" | ||
@click="$emit('moreresults')" | ||
/> | ||
<KCircularLoader | ||
v-if="(viewMoreButtonState === ViewMoreButtonStates.LOADING) & loadingMoreState" |
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 loadingMoreState is a redundant prop, as we can manage this with the loading state in viewMoreButtonState
...h/assets/src/views/lessons/LessonSummaryPage/sidePanels/LessonResourceSelection/constants.js
Outdated
Show resolved
Hide resolved
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'm loving the architecture here - things read really cleanly. I know this is in Draft so I can come back for a more thorough review once it's ready. Great work Alex!
[ResourceSelectionView.SELECTION_INDEX]: { | ||
title: this.$tr('manageLessonResourcesTitle'), | ||
component: SelectionIndex, | ||
}, | ||
[ResourceSelectionView.SELECT_FROM_BOOKMARKS]: { | ||
title: this.selectFromBookmarks$(), | ||
component: SelectFromBookmarks, | ||
back: ResourceSelectionView.SELECTION_INDEX, | ||
}, | ||
[ResourceSelectionView.SELECT_FROM_CHANNELS]: { | ||
title: this.$tr('manageLessonResourcesTitle'), | ||
component: SelectFromChannels, | ||
back: ResourceSelectionView.SELECTION_INDEX, | ||
guard: () => !!this.topicId, | ||
}, |
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.
Not a blocking comment, just want to share a thought I've been having around this kind of pattern that is relatively common in our code.
These objects are structured very much alike to a VueRouter route which makes me wonder if we should consider using VueRouter to handle this behavior instead. Mostly this comes from my sense that this is a job that the VueRouter is purpose-built to handle.
That all said, my meager attempts at doing this myself have run into issues so just something to think about, particularly as we move toward redesigning the SidePanelModal itself.
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.
Yees, thats true, I also thought about that, my first thought was that it was gonna be easier to have the business logic to manage the guards or the back page inside the component rather than doing it in the routes array. But I am gonna give it a try and actually see the implications of moving this as route children.
ResourceSelectionBreadcrumbs, | ||
}, | ||
mixins: [commonCoreStrings], | ||
setup(props) { |
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 is a really clean component overall - I really appreciate the simplicity in how the composable modules are implemented <3
|
||
window.get = get; | ||
|
||
export default function useFetch(options) { |
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.
Wondering if this should go in the coach/src/composables directory w/ it's cousin useFetchTree?
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.
Definetely, that seems like a good place! Thanks!
closeSidePanel() { | ||
this.$router.push({ | ||
name: PageNames.LESSON_SUMMARY_BETTER, | ||
}); | ||
}, | ||
}, |
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 the user has to explicitly save their changes then we'll need to handle the "Are you sure?" KModal to confirm losing changes.
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.
Hi Alex, I have added some feedback which is more about discussion than requesting specific changes. I look forward to your thoughts and chatting more! I am still thinking through the provide/inject stuff and will see if I have more coherent ideas to add tomorrow
@@ -0,0 +1,92 @@ | |||
import get from 'lodash/get'; |
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 isn't a criticism of the code, which seems well organized and nicely written. I'm just wondering what are the pros and cons of including the composable refactor here. What is made easier/possible by doing this? How much harder would it be if we didn't refactor this?
I mean this more about chatting things through a bit more, rather than a simple "yes do this" or "don't do this"
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 think a real pro of using composables is that it can clean up files, and make these helper functions reusable. And for things we are doing repeatedly, that makes a lot of sense and is valuable. Overall, I think this work does that well, and it's a pattern that we've been trying to move towards over time, so that's great.
one of the cons from my perspective is that sometimes by making the code very neat, it can (in a sort of unexpected way) be a little bit harder to follow what exactly might be happening and "run the code in your head" when trying to read through and understand (and potentially debug a problem in a specific scenario). This can be extra challenging when it is an abstracted helper function that is not in the context of say, a particular vue file.
if (additionalDataKeys) {
additionalData.value = Object.entries(additionalDataKeys).reduce((agg, [key, value]) => {
agg[key] = value === '' ? response : get(response, value);
return agg;
}, {});
}
};
I don't think there's an easy answer, I think for each case, it's a bit of a balancing act between abstraction, readability, "friendliness", and context.
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 havent got into documenting this yet as it was still a proposal, but I will need to do this to add context of what this is doing here and why.
What is made easier/possible by doing this? How much harder would it be if we didn't refactor this?
The main benefit of doing this is encapsulation and code reusability. Fetching data is a very common thing we need to do. And in the case we also need to handle the "load more" pattern, we will end up repeteating a lot of code.
For example, for lessons resource selection we need to load data from bookmarks, from channels and from the topic tree (and in the future from search results). So for each of them we will need to write in our composable something like:
const loadingBookmarks = ref(false);
const bookmarks = ref(null);
const bookmarksCount = ref(0);
const bookmarksError = ref(null);
const loadingMoreBookmarks = ref(false) // I will explain better why of this lodingMore.
const loadMoreBookmarksParams = ref(null);
const fetchBookmarks = async () => {
try {
loadingBookmarks.value = true;
const response = await ContentNodeResource.fetchBookmarks({
params: { limit: 25, available: true },
}),
bookmarks.value = response.data;
bookmarksCount.value = response.count;
loadMoreBookmarksParams.value = response.more;
} catch (e) {
error.value = e;
}
loadingBookmarks.value = false
}
const fetchMoreBookmarks = async () => {
try {
loadingMoreBookmarks.value = true;
const response = await ContentNodeResource.fetchBookmarks({
params: loadMoreBookmarksParams.value,
}),
bookmarks.value = response.data;
bookmarksCount.value = response.count;
loadingMoreBookmarksParams.value = response.more;
} catch (e) {
error.value = e;
}
loadingMoreBookmarks.value = false
};
And then we will need to repeat these 25 lines of code to fetch from topic tree, and many of them to fetch the channels, that at the end is a lot of repeated code for a common patern: fetching data, that usually do pretty much the same: starts loading, fetch data, set response, handle errors, finish loading. And at the very end we will end up returning something like this from the composable (just an example of how big it can be, in this particular case we dont need the loading and error states in separated variables).
return {
loadingBookmarks,
bookmarks,
bookmarksCount,
bookmarksError,
loadingMoreBookmarks,
fetchBookmarks,
fetchMoreBookmarks,
loadingResources,
resources,
topic,
resourcesError,
loadingMoreResources,
fetchResources,
fetchMoreResources,
loadingChannels,
channels,
channelsError,
fetchChannels
};
So its not a lot of repeated code, but a lot of variables, thats why encapsulation is another benefit of using this useFetch, so if we use useFetch to load bookmarks, topic tree and channels we will need just this to load all of them:
const bookmarksFetch = useFetch({
fetchMethod: () =>
ContentNodeResource.fetchBookmarks({
params: { limit: 25, available: true },
}),
fetchMoreMethod: more =>
ContentNodeResource.fetchBookmarks({
params: more,
}),
dataKey: 'results',
moreKey: 'more',
additionalDataKeys: {
count: 'count',
},
});
const channelsFetch = useFetch({
fetchMethod: () =>
ChannelResource.fetchCollection({
getParams: {
available: true,
},
}),
});
const treeFetch = useFetch({
fetchMethod: () =>
ContentNodeResource.fetchTree({ id: topicId.value, params: { include_coach_content: true } }),
fetchMoreMethod: more => ContentNodeResource.fetchTree({ id: topicId.value, params: more }),
dataKey: 'children.results',
moreKey: 'children.more.params',
additionalDataKeys: {
topic: '', // return the whole response as topic
},
});
Which is much less repeated code. And if we want to access bookmarks data we need to just access bookmarksFetch.data.
And why prefer these separated loading states instead of having just one general loading? Because as we are working with serveral models to fetch, we can get into race conditions if we need to load several things at the same time.
And another aspect, I have chosen to use these "fetch objects" (treeFetch, channelsFetch, bookmarksFetch) instead of destructuring the results to avoid the big return I showed before. I know this particular object is something we will need to document well as its a new pattern, and its not that obvious that these object contains the loading data objects and methods, but the thing is that for each model there are around 7 related objects, and I think encapsulating them is a good way to manage them without having to write a lot of code. Long retun objects are also hard to read. And also this standarize the variables names of this pattern and make it easier to handle dynamic data. For example allows something like
Lines 64 to 86 in 560bedb
const contentFetch = computed(() => { | |
const contentSources = { | |
[ResourceContentSource.BOOKMARKS]: bookmarksFetch, | |
[ResourceContentSource.TOPIC_TREE]: treeFetch, | |
}; | |
return contentSources[props.source]; | |
}); | |
const contentList = computed(() => { | |
const { data } = contentFetch.value; | |
return data.value; | |
}); | |
const viewMoreButtonState = computed(() => { | |
const { moreState } = contentFetch.value; | |
return moreState.value; | |
}); | |
function fetchMoreResources() { | |
const { fetchMore } = contentFetch.value; | |
fetchMore?.(); | |
} |
Now in specific why do we need these "moreKey" and "dataKey"? Because the response objects from the api are not standarized. And for channels the response itself is the channels array, for bookmarks the bookmarks array resides in response.results and for resources we need to look at response.children.results, the same happen with the more object. And why have I added this additionalDataKeys
, its because in some cases the response can contain additional data like for example the bookmarks that also returns the bookmarks count, and for the resources that also returns the topic data.
I know that these specific lines of code are a little bit complex to read, and apologies for forgetting to add some comments here 😅. What we do here is to map this additionalDataKeys
to an additionalData
object that contains the data requested in theadditionalDataKeys
.
if (additionalDataKeys) {
additionalData.value = Object.entries(additionalDataKeys).reduce((agg, [key, value]) => {
agg[key] = value === '' ? response : get(response, value);
return agg;
}, {});
}
};
Anyways I can rewrite this mapping, as there are ways to make it more easy to understand avoiding the reduce method. And after documenting some of this, it would be easier o follow. Because yes at the end abstractions are a little bit harder to follow as we need to think in terms of the abstracted concept.
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 think, ultimately, something like this would be useful to integrate into the APIResource layer itself, rather than having to wrap further around that.
The concerns that @marcellamaki describes also apply to a lot of the APIResource layer in general as well - because it becomes hard to reason about what is actually happening when we do a fetch from the backend, because quite a lot happens in the intervening layer - so allowing for the APIResource layer to directly be consumed as a composable in some way might make this feel neater, as you don't have to do the injection into the conmposable - you just get it back from it.
provide('selectedResources', selectedResources); | ||
provide('selectResources', selectResources); | ||
provide('deselectResources', deselectResources); | ||
provide('setSelectedResources', setSelectedResources); |
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 want to think a little bit more about the benefits (and challenges) if using provide/inject. We do use it in other places, so it isn't that we can't, but to Richard's comment in our chat, it's probably worth making sure this is not easily achieved another way. So. I'm going to reflect and come back to this
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 can write a version without the provide/inject, so we can weight better the implications of each pattern :)
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 just pushed a commit here in another branch removing the provide/inject: AlexVelezLl@5a94a59. Its not that bad as I first enviosioned, there are just 13 more lines of code, and although this difference will grow while we add more subPages, I'm leaning towards thinking that it is worth removing the provide/inject in favor of props passing. We can discuss more about that.
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.
great - thank you Alex! I'll take a look at that :)
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've only done a quick code read through (more about overall approach of props rather than provide/inject, and not reading each line for comprehension/making sure it works) but I think this might be the way to go for now. I do think that you are right, the diff will grow, but we can figure out if and when it becomes too complex to manage. Perhaps you can stash your provide inject setup away somewhere just in case :D
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.
Part of the reason I'm saying this is in connection to the composable changes that we were discussing above, and weighing the pros/cons of making various changes, and which to make now vs. later. I think you lay out a good reason for using a composable for the fetching logic (although I do want to do another read and make sure I'm really understanding it) and the values of that being modular and re-usable. It's not a huge refactor, and it's aligned with this work.
For me, that seems like the "new" pattern/code to prioritize, over also introducing provide inject here. I think that approach will allow us to have the best balance of benefits of the refactoring which we really want as a core part of this project, without introducing so many changes this goes from refactor to rewrite, which I think is a different thing (and always really tempting for me.... 😸 ). What you're moving toward based on your own reflections as well as this feedback and conversation seems like a good middle ground :)
// TODO let's not use text for this | ||
const viewMoreButtonState = computed(() => { | ||
if (loadingMore.value) { | ||
return ViewMoreButtonStates.LOADING; |
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 adding a loading button state? i can't quite figure out what this change is connected to, since it's updating quizzes.
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.
Oh I did this to remove the loadingMoreState
prop from the ContentCardList
, because this loadingMoreState
can be reflected through this viewMoreButtonState, and we dont need another variable for this. So its not adding a new loading state per se, just relocating the loadingMoreState
prop that was removed from the ContentCardList
560bedb
to
e738619
Compare
e738619
to
3f698f5
Compare
Build Artifacts
|
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.
a few comprehension questions as I am digging into the code more deeply now than when it was in the proposal stage!
In the UI the changes look great, just want to be sure the code review is really comprehensive :)
dataKey: 'children.results', | ||
moreKey: 'children.more.params', | ||
additionalDataKeys: { | ||
topic: '', // return the whole response as topic |
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.
// return the whole response as topic
i'm not sure what this comment means
@@ -71,6 +71,9 @@ class CoachToolsModule extends KolibriApp { | |||
PageNames.LESSON_EDIT_DETAILS_BETTER, | |||
PageNames.LESSON_PREVIEW_SELECTED_RESOURCES, | |||
PageNames.LESSON_PREVIEW_RESOURCE, | |||
PageNames.LESSON_SELECT_RESOURCES_INDEX, | |||
PageNames.LESSON_SELECT_RESOURCES_BOOKMARKS, | |||
PageNames.LESSON_SELECT_RESOURCES_CHANNELS, |
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.
So here is INDEX
the page a user would see when they are in a "selectable" state (i.e. in the topic tree, with checkboxes), the bookmarks is of course that, and the CHANNELS
is the initial "landing" page when the side panel opens with the link to the bookmarks as well as the channel cards there?
* | ||
* // By specifying `dataKey`, you tell the composable where to find the main data: | ||
* const { data } = useFetch({ dataKey: "payload" }); | ||
* console.log(data); // Outputs: { id: 42, name: "Jane Doe" } |
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/example is so helpful, thank you for adding it
* | ||
* @param {(more, ...args) => Promise<any>} [options.fetchMoreMethod] Function to fetch more data. | ||
* * This function receives a "more" object as its first argument. This "more" object is specified | ||
* by the `moreKey` param. |
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'm not fully understanding the distinction between the example above, where it seems to be passing the moreKey
object directly into useFetch()
useFetch({ moreKey: "more" })
and below:
* const fetchMoreMethod = (more) => ContentNodeResource.fetchBookmarks({
* params: more
* })
where there is a distinct method.
it seems like there's also a fetchMore
which is using the fetchMoreMethod
, and I can see from the documentation
fetchMore A method to manually trigger fetch more data.
So presumably this would be for something that would be triggered through the UI, i.e. a button click "View more"
this isn't really a question I guess, just trying to wrap my mind around the connections between each of these.
* ```js | ||
* const additionalDataKeys = { | ||
* userId: "user_id", // The `userId` property in `additionalData` will map to `response.user_id` | ||
* userName: "name" // The `userName` property in `additionalData` will map to `response.name` |
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 intention here to help the response already be somewhat "pre-processed" if needed, without having to potentially again transform the object, or remap responses to different names (i.e. managing variable name differences between the front and back end, theDifferencesBetween cases_on_front_and_back_end, etc.)?
* @property {FetchObject} bookmarksFetch Bookmarks fetch object to manage the process of | ||
* fetching bookmarks. Fetching more bookmarks is supported. | ||
* @property {FetchObject} treeFetch Topic tree fetch object to manage the process of | ||
* fetching topic trees and their resources. Fetching more resources is supported. |
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.
Fetching more
In these cases, we are talking about pagination of fetching resources (at least, currently) is that right? Or do you mean fetching more resources as we are descending down the topic tree?
const bookmarksFetch = useFetch({ | ||
fetchMethod: () => | ||
ContentNodeResource.fetchBookmarks({ | ||
params: { limit: 25, available: true }, |
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.
limit: 25
I think I've answered my own question and that you're talking about pagination
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.
From an API consumption perspective, is it confusing that the bookmarks fetching uses the limit
parameter, whereas all the other contentnode APIs use max_results
?
Under the hood, this is a result of the Bookmarks contentnode API using Limit/Offset pagination, whereas all the others use a more cursor style pagination, I am just not sure this is helpful to the consumer :)
Summary
This PR:
LESSON_SELECT_RESOURCES
route fromselect-resources/:topicId?
toselect-resources/index
,select-resources/bookmarks
,select-resources/channels
to handle the views that will be displayed during resource selection. Make the topicId be sent via query instead of params.SelectionIndex
component, if we want to see the bookmarks page we render theSelectFromBookmarks
component, etc. This way we remove the complexity and poor maintainability that comes with having a bunch of conditions like!showSearch && !showBookmarks && ... etc
to render each piece of the side panel.useFetch
to encapsulate and reuse the logic for loading data and "loading more".Screencast
Compartir.pantalla.-.2024-12-12.12_05_41.mp4
References
Closes #12790.
Reviewer guidance
How does this new strategy look?