Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 263 additions & 68 deletions frontends/api/src/hooks/learningResources/index.test.ts

Large diffs are not rendered by default.

84 changes: 44 additions & 40 deletions frontends/api/src/hooks/learningResources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import type {
LearningPathResource,
LearningPathRelationshipRequest,
MicroLearningPathRelationship,
LearningResource,
LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest,
UserlistsApiUserlistsListRequest as ULListRequest,
UserlistsApiUserlistsCreateRequest as ULCreateRequest,
Expand All @@ -28,10 +27,13 @@ import type {
MicroUserListRelationship,
PlatformsApiPlatformsListRequest,
FeaturedApiFeaturedListRequest as FeaturedListParams,
PaginatedLearningResourceList,
} from "../../generated/v1"
import learningResources, {
invalidateResourceQueries,
invalidateUserListQueries,
updateListParentsOnAdd,
updateListParentsOnDestroy,
} from "./keyFactory"
import { ListType } from "../../common/constants"

Expand Down Expand Up @@ -168,14 +170,15 @@ const useLearningpathRelationshipCreate = () => {
learning_resource_id: params.parent,
LearningPathRelationshipRequest: params,
}),
onSuccess: (response, _vars) => {
queryClient.setQueryData(
learningResources.detail(response.data.child).queryKey,
response.data.resource,
onSettled: (_response, _err, vars) => {
invalidateResourceQueries(
queryClient,
vars.child,
// do NOT skip invalidating the /featured/ lists,
// Changing a learning path might change the members of the featured
// lists.
{ skipFeatured: false },
)
},
onSettled: (response, _err, vars) => {
invalidateResourceQueries(queryClient, vars.child)
invalidateResourceQueries(queryClient, vars.parent)
},
})
Expand All @@ -189,22 +192,15 @@ const useLearningpathRelationshipDestroy = () => {
id: params.id,
learning_resource_id: params.parent,
}),
onSuccess: (_response, vars) => {
queryClient.setQueryData(
learningResources.detail(vars.child).queryKey,
(old: LearningResource | undefined) => {
if (!old) return
const parents =
old.learning_path_parents?.filter(({ id }) => vars.id !== id) ?? []
return {
...old,
learning_path_parents: parents,
}
},
)
},
onSettled: (_response, _err, vars) => {
invalidateResourceQueries(queryClient, vars.child)
invalidateResourceQueries(
queryClient,
vars.child,
// do NOT skip invalidating the /featured/ lists,
// Changing a learning path might change the members of the featured
// lists.
{ skipFeatured: false },
)
invalidateResourceQueries(queryClient, vars.parent)
},
})
Expand Down Expand Up @@ -298,13 +294,21 @@ const useUserListRelationshipCreate = () => {
UserListRelationshipRequest: params,
}),
onSuccess: (response, _vars) => {
queryClient.setQueryData(
learningResources.detail(response.data.child).queryKey,
response.data.resource,
queryClient.setQueriesData<PaginatedLearningResourceList>(
learningResources.featured({}).queryKey,
(old) => updateListParentsOnAdd(response.data, old),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Regarding updateListParentsOnAdd and updateListParentsOnDestroy :

  1. The featured carousel on the homepage is populated by an API that has an intentionally randomized order.
  2. In general throughout the app, when data might change, we tell react query "invalidate the cached data" and it re-fetches.
  3. Adding the featured courses to a userlist changes their data (it affects `resource.user_list_parents). However, if we invalidate the cache and refetch new, updated data, then the carousel order will change, which is strange.
    • you can see this behavior on RC / Prod, but the bookmarks won't fill / unfill, so it's less noticeable that position has changed.
  4. So instead of invalidating the cache and refetching the data, we manually update the data on the frontend.

We only do this for userlists, not for learnignpaths, because...

  • changing a resource's learningpath membershould can change whether it is a featured course or not, so we really do need to refetch the data.

)
},
onSettled: (response, _err, vars) => {
invalidateResourceQueries(queryClient, vars.child)
onSettled: (_response, _err, vars) => {
invalidateResourceQueries(
queryClient,
vars.child,
// Do NOT invalidate the featured lists. Re-fetching the featured list
// data will cause the order to change, since the /featured API returns
// at random order.
// Instead, `onSuccess` hook will manually update the data.
{ skipFeatured: true },
)
invalidateUserListQueries(queryClient, vars.parent)
},
})
Expand All @@ -319,21 +323,21 @@ const useUserListRelationshipDestroy = () => {
userlist_id: params.parent,
}),
onSuccess: (_response, vars) => {
queryClient.setQueryData(
learningResources.detail(vars.child).queryKey,
(old: LearningResource | undefined) => {
if (!old) return
const parents =
old.user_list_parents?.filter(({ id }) => vars.id !== id) ?? []
return {
...old,
user_list_parents: parents,
}
},
queryClient.setQueriesData<PaginatedLearningResourceList>(
learningResources.featured({}).queryKey,
(old) => updateListParentsOnDestroy(vars, old),
)
},
onSettled: (_response, _err, vars) => {
invalidateResourceQueries(queryClient, vars.child)
invalidateResourceQueries(
queryClient,
vars.child,
// Do NOT invalidate the featured lists. Re-fetching the featured list
// data will cause the order to change, since the /featured API returns
// at random order.
// Instead, `onSuccess` hook will manually update the data.
{ skipFeatured: true },
)
invalidateUserListQueries(queryClient, vars.parent)
},
})
Expand Down
109 changes: 85 additions & 24 deletions frontends/api/src/hooks/learningResources/keyFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import type {
OfferorsApiOfferorsListRequest,
PlatformsApiPlatformsListRequest,
FeaturedApiFeaturedListRequest as FeaturedListParams,
UserListRelationship,
MicroUserListRelationship,
} from "../../generated/v1"
import { createQueryKeys } from "@lukemorales/query-key-factory"

Expand All @@ -45,7 +47,7 @@ const learningResources = createQueryKeys("learningResources", {
.learningResourcesList(params)
.then((res) => res.data),
}),
featured: (params: FeaturedListParams) => ({
featured: (params: FeaturedListParams = {}) => ({
queryKey: [params],
queryFn: () => featuredApi.featuredList(params).then((res) => res.data),
}),
Expand Down Expand Up @@ -143,35 +145,33 @@ const learningResources = createQueryKeys("learningResources", {
},
})

const learningPathHasResource =
const listHasResource =
(resourceId: number) =>
(query: Query): boolean => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = query.state.data as any
const resources: LearningResource[] = data.pages
? data.pages.flatMap(
(page: PaginatedLearningResourceList) => page.results,
)
: data.results
return resources.some((res) => res.id === resourceId)
}

const userListHasResource =
(resourceId: number) =>
(query: Query): boolean => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data = query.state.data as any
const resources: UserList[] = data.pages
const resources: LearningResource[] | UserList[] = data.pages
? data.pages.flatMap(
(page: PaginatedLearningResourceList) => page.results,
)
: data.results
return resources.some((res) => res.id === resourceId)
}

/**
* Invalidate Resource queries that a specific resource appears in.
*
* By default, this will invalidate featured list queries. This can result in
* odd behavior because the featured list item order is randomized: when the
* featured list cache is invalidated, the newly fetched data may be in a
* different order. To maintain the order, use skipFeatured to skip invalidation
* of featured lists and instead manually update the cached data via
* `updateListParentsOnAdd`.
*/
const invalidateResourceQueries = (
queryClient: QueryClient,
resourceId: LearningResource["id"],
{ skipFeatured = false } = {},
) => {
/**
* Invalidate details queries.
Expand All @@ -191,17 +191,13 @@ const invalidateResourceQueries = (
const lists = [
learningResources.list._def,
learningResources.learningpaths._ctx.list._def,
learningResources.userlists._ctx.list._def,
learningResources.featured._def,
learningResources.search._def,
...(skipFeatured ? [] : [learningResources.featured._def]),
]
lists.forEach((queryKey) => {
queryClient.invalidateQueries({
queryKey,
predicate: learningPathHasResource(resourceId),
})
queryClient.invalidateQueries({
queryKey,
predicate: userListHasResource(resourceId),
predicate: listHasResource(resourceId),
})
})
}
Expand All @@ -213,7 +209,72 @@ const invalidateUserListQueries = (
queryClient.invalidateQueries(
learningResources.userlists._ctx.detail(userListId).queryKey,
)
const lists = [learningResources.userlists._ctx.list._def]
lists.forEach((queryKey) => {
queryClient.invalidateQueries({
queryKey,
predicate: listHasResource(userListId),
})
})
}

/**
* Given
* - a list of learning resources L
* - a new relationship between learningpath/userlist and a resource R
* Update the list L so that it includes the updated resource R. (If the list
* did not contain R to begin with, no change is made)
*/
const updateListParentsOnAdd = (
relationship: UserListRelationship,
oldList?: PaginatedLearningResourceList,
) => {
if (!oldList) return oldList
const matchIndex = oldList.results.findIndex(
(res) => res.id === relationship.child,
)
if (matchIndex === -1) return oldList
const updatesResults = [...oldList.results]
updatesResults[matchIndex] = relationship.resource
return {
...oldList,
results: updatesResults,
}
}

/**
* Given
* - a list of learning resources L
* - a destroyed relationship between learningpath/userlist and a resource R
* Update the list L so that it includes the updated resource R. (If the list
* did not contain R to begin with, no change is made)
*/
const updateListParentsOnDestroy = (
relationship: MicroUserListRelationship,
list?: PaginatedLearningResourceList,
) => {
if (!list) return list
if (!relationship) return list
const matchIndex = list.results.findIndex(
(res) => res.id === relationship.child,
)
if (matchIndex === -1) return list
const updatedResults = [...list.results]
const newResource = { ...updatedResults[matchIndex] }
newResource.user_list_parents =
newResource.user_list_parents?.filter((m) => m.id !== relationship.id) ??
null
updatedResults[matchIndex] = newResource
return {
...list,
results: updatedResults,
}
}

export default learningResources
export { invalidateResourceQueries, invalidateUserListQueries }
export {
invalidateResourceQueries,
invalidateUserListQueries,
updateListParentsOnAdd,
updateListParentsOnDestroy,
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import React from "react"
import * as NiceModal from "@ebay/nice-modal-react"
import { renderWithProviders, user, screen } from "../../test-utils"
import {
renderWithProviders,
user,
screen,
expectProps,
} from "../../test-utils"
import type { User } from "../../test-utils"
import { ResourceCard, ResourceListCard } from "./ResourceCard"
import {
Expand All @@ -11,6 +16,17 @@ import type { ResourceCardProps } from "./ResourceCard"
import { urls, factories, setMockResponse } from "api/test-utils"
import { RESOURCE_DRAWER_QUERY_PARAM } from "@/common/urls"
import invariant from "tiny-invariant"
import { LearningResourceCard, LearningResourceListCard } from "ol-components"

jest.mock("ol-components", () => {
const actual = jest.requireActual("ol-components")
return {
__esModule: true,
...actual,
LearningResourceCard: jest.fn(actual.LearningResourceCard),
LearningResourceListCard: jest.fn(actual.LearningResourceListCard),
}
})

jest.mock("@ebay/nice-modal-react", () => {
const actual = jest.requireActual("@ebay/nice-modal-react")
Expand All @@ -24,11 +40,13 @@ jest.mock("@ebay/nice-modal-react", () => {
describe.each([
{
CardComponent: ResourceCard,
BaseComponent: LearningResourceCard,
},
{
CardComponent: ResourceListCard,
BaseComponent: LearningResourceListCard,
},
])("$CardComponent", ({ CardComponent }) => {
])("$CardComponent", ({ CardComponent, BaseComponent }) => {
const makeResource = factories.learningResources.resource
type SetupOptions = {
user?: Partial<User>
Expand Down Expand Up @@ -84,6 +102,47 @@ describe.each([
},
)

test.each([
{
userlist: { count: 1, inList: true },
learningpath: { count: 1, inList: true },
},
{
userlist: { count: 0, inList: false },
learningpath: { count: 1, inList: true },
},
{
userlist: { count: 1, inList: true },
learningpath: { count: 0, inList: false },
},
{
userlist: { count: 0, inList: false },
learningpath: { count: 0, inList: false },
},
])(
"'Add to ...' buttons are filled based on membership in list",
({ userlist, learningpath }) => {
const resource = makeResource()
const { microLearningPathRelationship } = factories.learningResources
const { microUserListRelationship } = factories.userLists
resource.learning_path_parents = Array.from(
{ length: learningpath.count },
() => microLearningPathRelationship({ child: resource.id }),
)
resource.user_list_parents = Array.from({ length: userlist.count }, () =>
microUserListRelationship({ child: resource.id }),
)

setup({ user: { is_authenticated: true }, props: { resource } })

expectProps(BaseComponent, {
resource,
inLearningPath: learningpath.inList,
inUserList: userlist.inList,
})
},
)

test("Clicking add to list button opens AddToListDialog when authenticated", async () => {
const showModal = jest.mocked(NiceModal.show)

Expand Down
Loading