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

Lesson resources selection #12895

Open
wants to merge 17 commits into
base: develop
Choose a base branch
from

Conversation

AlexVelezLl
Copy link
Member

@AlexVelezLl AlexVelezLl commented Nov 28, 2024

Summary

This PR:

  • Proposes a new strategy to handling views and navigation in side panels:
    • Change the LESSON_SELECT_RESOURCES route from select-resources/:topicId? to select-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.
    • Make each "subpage" of the side panel (each snapshot that the user sees) a component that logically groups several working pieces. So if we see the index of the select resources, then we render the SelectionIndex component, if we want to see the bookmarks page we render the SelectFromBookmarks 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.
  • Implements a new general purpose composable useFetch to encapsulate and reuse the logic for loading data and "loading more".
    • This fixes a bug we have in quizzes' resource selection where if you have more than 25 bookmarks, the component will just display "25 bookmarks" and during resource selection you won't be able to load more.
  • Fixes a bug we have in quizzes' resource selection where if we had to "load more" resources in the topic tree view, it wouldn't pass the more params correctly and would reload the same resources infinitely.
  • Fixes scrollbar styles that appears when you need to scroll in the side panel.

Screencast

Compartir.pantalla.-.2024-12-12.12_05_41.mp4

References

Closes #12790.

Reviewer guidance

How does this new strategy look?

@github-actions github-actions bot added APP: Coach Re: Coach App (lessons, quizzes, groups, reports, etc.) DEV: frontend labels Nov 28, 2024
:text="coreString('viewMoreAction')"
:primary="false"
@click="$emit('moreresults')"
/>
<KCircularLoader
v-if="(viewMoreButtonState === ViewMoreButtonStates.LOADING) & loadingMoreState"
Copy link
Member Author

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

Copy link
Member

@nucleogenesis nucleogenesis left a 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!

Comment on lines 86 to 100
[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,
},
Copy link
Member

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.

Copy link
Member Author

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) {
Copy link
Member

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) {
Copy link
Member

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?

Copy link
Member Author

@AlexVelezLl AlexVelezLl Dec 3, 2024

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!

Comment on lines 130 to 135
closeSidePanel() {
this.$router.push({
name: PageNames.LESSON_SUMMARY_BETTER,
});
},
},
Copy link
Member

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.

@marcellamaki marcellamaki self-assigned this Dec 3, 2024
Copy link
Member

@marcellamaki marcellamaki left a 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';
Copy link
Member

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"

Copy link
Member

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.

Copy link
Member Author

@AlexVelezLl AlexVelezLl Dec 4, 2024

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

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?.();
}
. That instead of mapping each data object, loading object, fetchMoreMethod, we just map one fetch object and then we can in an abstract way access all these variables that are common to data fetching.

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.

Copy link
Member

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);
Copy link
Member

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

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 can write a version without the provide/inject, so we can weight better the implications of each pattern :)

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 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.

Copy link
Member

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 :)

Copy link
Member

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

Copy link
Member

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;
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 adding a loading button state? i can't quite figure out what this change is connected to, since it's updating quizzes.

Copy link
Member Author

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

@AlexVelezLl AlexVelezLl force-pushed the new-UpdatedResourceSelection branch from 560bedb to e738619 Compare December 12, 2024 14:08
@AlexVelezLl AlexVelezLl force-pushed the new-UpdatedResourceSelection branch from e738619 to 3f698f5 Compare December 12, 2024 14:39
@AlexVelezLl AlexVelezLl marked this pull request as ready for review December 12, 2024 14:41
@AlexVelezLl AlexVelezLl changed the title [Proposal] Lesson resources selection Lesson resources selection Dec 12, 2024
Copy link
Member

@marcellamaki marcellamaki left a 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
Copy link
Member

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,
Copy link
Member

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" }
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/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.
Copy link
Member

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`
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 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.
Copy link
Member

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 },
Copy link
Member

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

Copy link
Member

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 :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
APP: Coach Re: Coach App (lessons, quizzes, groups, reports, etc.) DEV: frontend
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Update <ResourceSelection /> so that can it can be used in lessons, without breaking the current quiz workflow
4 participants