diff --git a/frontends/api/src/clients.ts b/frontends/api/src/clients.ts index 9d776b395e..eb9c3687ce 100644 --- a/frontends/api/src/clients.ts +++ b/frontends/api/src/clients.ts @@ -52,7 +52,7 @@ const learningResourcesSearchAdminParamsApi = const featuredApi = new FeaturedApi(undefined, BASE_PATH, axiosInstance) -const learningpathsApi = new LearningpathsApi( +const learningPathsApi = new LearningpathsApi( undefined, BASE_PATH, axiosInstance, @@ -95,7 +95,7 @@ const testimonialsApi = new TestimonialsApi(undefined, BASE_PATH, axiosInstance) export { learningResourcesApi, - learningpathsApi, + learningPathsApi, userListsApi, topicsApi, articlesApi, diff --git a/frontends/api/src/hooks/learningPaths/index.test.ts b/frontends/api/src/hooks/learningPaths/index.test.ts new file mode 100644 index 0000000000..e2b5041824 --- /dev/null +++ b/frontends/api/src/hooks/learningPaths/index.test.ts @@ -0,0 +1,319 @@ +import { renderHook, waitFor } from "@testing-library/react" +import { faker } from "@faker-js/faker/locale/en" +import { UseQueryResult } from "@tanstack/react-query" +import { LearningResource } from "../../generated/v1" +import * as factories from "../../test-utils/factories" +import { setupReactQueryTest } from "../test-utils" +import { setMockResponse, urls, makeRequest } from "../../test-utils" +import { useFeaturedLearningResourcesList } from "../learningResources" +import { invalidateResourceQueries } from "../learningResources/invalidation" +import keyFactory from "../learningResources/keyFactory" +import { + useLearningPathsDetail, + useLearningPathsList, + useInfiniteLearningPathItems, + useLearningPathCreate, + useLearningPathDestroy, + useLearningPathUpdate, + useLearningPathRelationshipMove, + useLearningPathRelationshipCreate, + useLearningPathRelationshipDestroy, +} from "./index" +import learningPathKeyFactory from "./keyFactory" + +const factory = factories.learningResources + +jest.mock("../learningResources/invalidation", () => { + const actual = jest.requireActual("../learningResources/invalidation") + return { + __esModule: true, + ...actual, + invalidateResourceQueries: jest.fn(), + invalidateUserListQueries: jest.fn(), + } +}) + +/** + * Assert that `hook` queries the API with the correct `url`, `method`, and + * exposes the API's data. + */ +const assertApiCalled = async ( + result: { current: UseQueryResult }, + url: string, + method: string, + data: unknown, +) => { + await waitFor(() => expect(result.current.isLoading).toBe(false)) + expect( + makeRequest.mock.calls.some((args) => { + // Don't use toHaveBeenCalledWith. It doesn't handle undefined 3rd arg. + return args[0].toUpperCase() === method && args[1] === url + }), + ).toBe(true) + expect(result.current.data).toEqual(data) +} + +describe("useLearningPathsList", () => { + it.each([undefined, { limit: 5 }, { limit: 5, offset: 10 }])( + "Calls the correct API", + async (params) => { + const data = factory.learningPaths({ count: 3 }) + const url = urls.learningPaths.list(params) + + const { wrapper } = setupReactQueryTest() + setMockResponse.get(url, data) + const useTestHook = () => useLearningPathsList(params) + const { result } = renderHook(useTestHook, { wrapper }) + + await assertApiCalled(result, url, "GET", data) + }, + ) +}) + +describe("useInfiniteLearningPathItems", () => { + it("Calls the correct API and can fetch next page", async () => { + const parentId = faker.number.int() + const url1 = urls.learningPaths.resources({ + learning_resource_id: parentId, + }) + const url2 = urls.learningPaths.resources({ + learning_resource_id: parentId, + offset: 5, + }) + const response1 = factory.learningPathRelationships({ + count: 7, + parent: parentId, + next: url2, + pageSize: 5, + }) + const response2 = factory.learningPathRelationships({ + count: 7, + pageSize: 2, + parent: parentId, + }) + setMockResponse.get(url1, response1) + setMockResponse.get(url2, response2) + const useTestHook = () => + useInfiniteLearningPathItems({ learning_resource_id: parentId }) + + const { wrapper } = setupReactQueryTest() + + // First page + const { result } = renderHook(useTestHook, { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(makeRequest).toHaveBeenCalledWith("get", url1, undefined) + + // Second page + result.current.fetchNextPage() + await waitFor(() => expect(result.current.isFetching).toBe(false)) + expect(makeRequest).toHaveBeenCalledWith("get", url2, undefined) + }) +}) + +describe("useLearningPathsRetrieve", () => { + it("Calls the correct API", async () => { + const data = factory.learningPath() + const params = { id: data.id } + const url = urls.learningPaths.details(params) + + const { wrapper } = setupReactQueryTest() + setMockResponse.get(url, data) + const useTestHook = () => useLearningPathsDetail(params.id) + const { result } = renderHook(useTestHook, { wrapper }) + + await assertApiCalled(result, url, "GET", data) + }) +}) + +describe("LearningPath CRUD", () => { + const makeData = () => { + const path = factory.learningPath() + const relationship = factory.learningPathRelationship({ parent: path.id }) + const keys = { + learningResources: keyFactory._def, + relationshipListing: learningPathKeyFactory.detail(path.id)._ctx + .infiniteItems._def, + } + const pathUrls = { + list: urls.learningPaths.list(), + details: urls.learningPaths.details({ id: path.id }), + relationshipList: urls.learningPaths.resources({ + learning_resource_id: path.id, + }), + relationshipDetails: urls.learningPaths.resourceDetails({ + id: relationship.id, + learning_resource_id: path.id, + }), + } + + const resourceWithoutList: LearningResource = { + ...relationship.resource, + learning_path_parents: + relationship.resource.learning_path_parents?.filter( + (m) => m.id !== relationship.id, + ) ?? null, + } + return { path, relationship, pathUrls, keys, resourceWithoutList } + } + + test("useLearningPathCreate calls correct API", async () => { + const { path, pathUrls } = makeData() + const url = pathUrls.list + + const requestData = { title: path.title } + setMockResponse.post(pathUrls.list, path) + + const { wrapper, queryClient } = setupReactQueryTest() + jest.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(useLearningPathCreate, { + wrapper, + }) + result.current.mutate(requestData) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(makeRequest).toHaveBeenCalledWith("post", url, requestData) + expect(queryClient.invalidateQueries).toHaveBeenCalledWith([ + "learningPaths", + "list", + ]) + }) + + test("useLearningPathDestroy calls correct API", async () => { + const { path, pathUrls } = makeData() + const url = pathUrls.details + setMockResponse.delete(url, null) + + const { wrapper, queryClient } = setupReactQueryTest() + const { result } = renderHook(useLearningPathDestroy, { + wrapper, + }) + result.current.mutate({ id: path.id }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined) + expect(invalidateResourceQueries).toHaveBeenCalledWith(queryClient, path.id) + }) + + test("useLearningPathUpdate calls correct API", async () => { + const { path, pathUrls } = makeData() + const url = pathUrls.details + const patch = { id: path.id, title: path.title } + setMockResponse.patch(url, path) + + const { wrapper, queryClient } = setupReactQueryTest() + const { result } = renderHook(useLearningPathUpdate, { wrapper }) + result.current.mutate(patch) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(makeRequest).toHaveBeenCalledWith("patch", url, patch) + + expect(invalidateResourceQueries).toHaveBeenCalledWith(queryClient, path.id) + }) + + test("useLearningPathRelationshipMove calls correct API", async () => { + const { relationship, pathUrls, keys } = makeData() + const url = pathUrls.relationshipDetails + setMockResponse.patch(url, null) + + const { wrapper, queryClient } = setupReactQueryTest() + jest.spyOn(queryClient, "invalidateQueries") + const { result } = renderHook(useLearningPathRelationshipMove, { wrapper }) + result.current.mutate(relationship) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(makeRequest).toHaveBeenCalledWith( + "patch", + url, + expect.objectContaining({ position: relationship.position }), + ) + + expect(queryClient.invalidateQueries).toHaveBeenCalledWith( + keys.relationshipListing, + ) + }) + + test.each([{ isChildFeatured: false }, { isChildFeatured: true }])( + "useLearningPathRelationshipCreate calls correct API and patches featured resources", + async ({ isChildFeatured }) => { + const { relationship, pathUrls, resourceWithoutList } = makeData() + + const featured = factory.resources({ count: 3 }) + if (isChildFeatured) { + featured.results[0] = resourceWithoutList + } + setMockResponse.get(urls.learningResources.featured(), featured) + + const url = pathUrls.relationshipList + const requestData = { + child: relationship.child, + parent: relationship.parent, + position: relationship.position, + } + setMockResponse.post(url, relationship) + + const { wrapper, queryClient } = setupReactQueryTest() + const { result } = renderHook(useLearningPathRelationshipCreate, { + wrapper, + }) + const { result: featuredResult } = renderHook( + useFeaturedLearningResourcesList, + { wrapper }, + ) + await waitFor(() => expect(featuredResult.current.data).toBe(featured)) + + result.current.mutate(requestData) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(makeRequest).toHaveBeenCalledWith("post", url, requestData) + + expect(invalidateResourceQueries).toHaveBeenCalledWith( + queryClient, + relationship.child, + { skipFeatured: false }, + ) + expect(invalidateResourceQueries).toHaveBeenCalledWith( + queryClient, + relationship.parent, + ) + }, + ) + + test.each([{ isChildFeatured: false }, { isChildFeatured: true }])( + "useLearningPathRelationshipDestroy calls correct API and patches child resource cache (isChildFeatured=$isChildFeatured)", + async ({ isChildFeatured }) => { + const { relationship, pathUrls } = makeData() + const url = pathUrls.relationshipDetails + + const featured = factory.resources({ count: 3 }) + if (isChildFeatured) { + featured.results[0] = relationship.resource + } + setMockResponse.get(urls.learningResources.featured(), featured) + + setMockResponse.delete(url, null) + const { wrapper, queryClient } = setupReactQueryTest() + + const { result } = renderHook(useLearningPathRelationshipDestroy, { + wrapper, + }) + const { result: featuredResult } = renderHook( + useFeaturedLearningResourcesList, + { wrapper }, + ) + + await waitFor(() => expect(featuredResult.current.data).toBe(featured)) + result.current.mutate(relationship) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined) + expect(invalidateResourceQueries).toHaveBeenCalledWith( + queryClient, + relationship.child, + { skipFeatured: false }, + ) + expect(invalidateResourceQueries).toHaveBeenCalledWith( + queryClient, + relationship.parent, + ) + }, + ) +}) diff --git a/frontends/api/src/hooks/learningPaths/index.ts b/frontends/api/src/hooks/learningPaths/index.ts new file mode 100644 index 0000000000..4f7475a7e5 --- /dev/null +++ b/frontends/api/src/hooks/learningPaths/index.ts @@ -0,0 +1,189 @@ +import { + UseQueryOptions, + useQuery, + useInfiniteQuery, + useQueryClient, + useMutation, +} from "@tanstack/react-query" +import type { + LearningpathsApiLearningpathsListRequest as ListRequest, + LearningpathsApiLearningpathsItemsListRequest as ItemsListRequest, + LearningpathsApiLearningpathsCreateRequest as CreateRequest, + LearningpathsApiLearningpathsDestroyRequest as DestroyRequest, + LearningPathRelationshipRequest, + MicroLearningPathRelationship, + LearningPathResource, +} from "../../generated/v1" +import { learningPathsApi } from "../../clients" +import learningPaths from "./keyFactory" +import { invalidateResourceQueries } from "../learningResources/invalidation" + +const useLearningPathsList = ( + params: ListRequest = {}, + opts: Pick = {}, +) => { + return useQuery({ + ...learningPaths.list(params), + ...opts, + }) +} + +const useInfiniteLearningPathItems = ( + params: ItemsListRequest, + options: Pick = {}, +) => { + return useInfiniteQuery({ + ...learningPaths + .detail(params.learning_resource_id) + ._ctx.infiniteItems(params), + getNextPageParam: (lastPage) => { + return lastPage.next ?? undefined + }, + ...options, + }) +} + +const useLearningPathsDetail = (id: number) => { + return useQuery(learningPaths.detail(id)) +} + +type LearningPathCreateRequest = Omit< + CreateRequest["LearningPathResourceRequest"], + "readable_id" | "resource_type" +> +const useLearningPathCreate = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: LearningPathCreateRequest) => + learningPathsApi.learningpathsCreate({ + LearningPathResourceRequest: params, + }), + onSettled: () => { + queryClient.invalidateQueries(learningPaths.list._def) + }, + }) +} + +const useLearningPathUpdate = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ( + params: Pick & Partial, + ) => + learningPathsApi.learningpathsPartialUpdate({ + id: params.id, + PatchedLearningPathResourceRequest: params, + }), + onSettled: (_data, _err, vars) => { + invalidateResourceQueries(queryClient, vars.id) + }, + }) +} + +const useLearningPathDestroy = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: DestroyRequest) => + learningPathsApi.learningpathsDestroy(params), + onSettled: (_data, _err, vars) => { + invalidateResourceQueries(queryClient, vars.id) + }, + }) +} + +interface ListItemMoveRequest { + parent: number + id: number + position?: number +} +const useLearningPathRelationshipMove = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ parent, id, position }: ListItemMoveRequest) => + learningPathsApi.learningpathsItemsPartialUpdate({ + learning_resource_id: parent, + id, + PatchedLearningPathRelationshipRequest: { position }, + }), + onSettled: (_data, _err, vars) => { + queryClient.invalidateQueries( + learningPaths.detail(vars.parent)._ctx.infiniteItems._def, + ) + }, + }) +} + +const useLearningPathListItemMove = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ parent, id, position }: ListItemMoveRequest) => { + await learningPathsApi.learningpathsItemsPartialUpdate({ + learning_resource_id: parent, + id, + PatchedLearningPathRelationshipRequest: { position }, + }) + }, + onSettled: (_data, _err, vars) => { + queryClient.invalidateQueries( + learningPaths.detail(vars.parent)._ctx.infiniteItems._def, + ) + }, + }) +} + +const useLearningPathRelationshipCreate = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: LearningPathRelationshipRequest) => + learningPathsApi.learningpathsItemsCreate({ + learning_resource_id: params.parent, + LearningPathRelationshipRequest: params, + }), + 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 }, + ) + invalidateResourceQueries(queryClient, vars.parent) + }, + }) +} + +const useLearningPathRelationshipDestroy = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: MicroLearningPathRelationship) => + learningPathsApi.learningpathsItemsDestroy({ + id: params.id, + learning_resource_id: params.parent, + }), + 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 }, + ) + invalidateResourceQueries(queryClient, vars.parent) + }, + }) +} + +export { + useLearningPathsList, + useInfiniteLearningPathItems, + useLearningPathsDetail, + useLearningPathCreate, + useLearningPathUpdate, + useLearningPathDestroy, + useLearningPathRelationshipMove, + useLearningPathListItemMove, + useLearningPathRelationshipCreate, + useLearningPathRelationshipDestroy, +} diff --git a/frontends/api/src/hooks/learningPaths/keyFactory.ts b/frontends/api/src/hooks/learningPaths/keyFactory.ts new file mode 100644 index 0000000000..aa5dd886ce --- /dev/null +++ b/frontends/api/src/hooks/learningPaths/keyFactory.ts @@ -0,0 +1,42 @@ +import { createQueryKeys } from "@lukemorales/query-key-factory" +import { learningPathsApi } from "../../clients" +import axiosInstance from "../../axios" +import type { + LearningpathsApiLearningpathsItemsListRequest as ItemsListRequest, + LearningpathsApiLearningpathsListRequest as ListRequest, + PaginatedLearningPathRelationshipList, +} from "../../generated/v1" + +const learningPaths = createQueryKeys("learningPaths", { + detail: (id: number) => ({ + queryKey: [id], + queryFn: () => { + return learningPathsApi + .learningpathsRetrieve({ id }) + .then((res) => res.data) + }, + contextQueries: { + infiniteItems: (itemsP: ItemsListRequest) => ({ + queryKey: [itemsP], + queryFn: ({ pageParam }: { pageParam?: string } = {}) => { + // Use generated API for first request, then use next parameter + const request = pageParam + ? axiosInstance.request({ + method: "get", + url: pageParam, + }) + : learningPathsApi.learningpathsItemsList(itemsP) + return request.then((res) => res.data) + }, + }), + }, + }), + list: (params: ListRequest) => ({ + queryKey: [params], + queryFn: () => { + return learningPathsApi.learningpathsList(params).then((res) => res.data) + }, + }), +}) + +export default learningPaths diff --git a/frontends/api/src/hooks/learningResources/index.test.ts b/frontends/api/src/hooks/learningResources/index.test.ts index 2b96653247..34e6068938 100644 --- a/frontends/api/src/hooks/learningResources/index.test.ts +++ b/frontends/api/src/hooks/learningResources/index.test.ts @@ -1,37 +1,18 @@ import { renderHook, waitFor } from "@testing-library/react" -import { faker } from "@faker-js/faker/locale/en" - import { setupReactQueryTest } from "../test-utils" -import keyFactory, { - invalidateResourceQueries, - invalidateUserListQueries, -} from "./keyFactory" import { useLearningResourcesDetail, useLearningResourcesList, - useLearningPathsDetail, - useLearningPathsList, useLearningResourceTopics, - useInfiniteLearningPathItems, - useLearningpathCreate, - useLearningpathDestroy, - useLearningpathUpdate, - useLearningpathRelationshipMove, - useLearningpathRelationshipCreate, - useLearningpathRelationshipDestroy, - useFeaturedLearningResourcesList, - useUserListRelationshipCreate, - useUserListRelationshipDestroy, } from "./index" import { setMockResponse, urls, makeRequest } from "../../test-utils" import * as factories from "../../test-utils/factories" import { UseQueryResult } from "@tanstack/react-query" -import { LearningResource } from "../../generated/v1" const factory = factories.learningResources -jest.mock("./keyFactory", () => { - const actual = jest.requireActual("./keyFactory") +jest.mock("./invalidation", () => { + const actual = jest.requireActual("./invalidation") return { __esModule: true, ...actual, @@ -90,38 +71,6 @@ describe("useLearningResourcesRetrieve", () => { }) }) -describe("useLearningPathsList", () => { - it.each([undefined, { limit: 5 }, { limit: 5, offset: 10 }])( - "Calls the correct API", - async (params) => { - const data = factory.learningPaths({ count: 3 }) - const url = urls.learningPaths.list(params) - - const { wrapper } = setupReactQueryTest() - setMockResponse.get(url, data) - const useTestHook = () => useLearningPathsList(params) - const { result } = renderHook(useTestHook, { wrapper }) - - await assertApiCalled(result, url, "GET", data) - }, - ) -}) - -describe("useLearningPathsRetrieve", () => { - it("Calls the correct API", async () => { - const data = factory.learningPath() - const params = { id: data.id } - const url = urls.learningPaths.details(params) - - const { wrapper } = setupReactQueryTest() - setMockResponse.get(url, data) - const useTestHook = () => useLearningPathsDetail(params.id) - const { result } = renderHook(useTestHook, { wrapper }) - - await assertApiCalled(result, url, "GET", data) - }) -}) - describe("useLearningResourceTopics", () => { it.each([undefined, { limit: 5 }, { limit: 5, offset: 10 }])( "Calls the correct API", @@ -138,390 +87,3 @@ describe("useLearningResourceTopics", () => { }, ) }) - -describe("useInfiniteLearningPathItems", () => { - it("Calls the correct API and can fetch next page", async () => { - const parentId = faker.number.int() - const url1 = urls.learningPaths.resources({ - learning_resource_id: parentId, - }) - const url2 = urls.learningPaths.resources({ - learning_resource_id: parentId, - offset: 5, - }) - const response1 = factory.learningPathRelationships({ - count: 7, - parent: parentId, - next: url2, - pageSize: 5, - }) - const response2 = factory.learningPathRelationships({ - count: 7, - pageSize: 2, - parent: parentId, - }) - setMockResponse.get(url1, response1) - setMockResponse.get(url2, response2) - const useTestHook = () => - useInfiniteLearningPathItems({ learning_resource_id: parentId }) - - const { wrapper } = setupReactQueryTest() - - // First page - const { result } = renderHook(useTestHook, { wrapper }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(makeRequest).toHaveBeenCalledWith("get", url1, undefined) - - // Second page - result.current.fetchNextPage() - await waitFor(() => expect(result.current.isFetching).toBe(false)) - expect(makeRequest).toHaveBeenCalledWith("get", url2, undefined) - }) -}) - -describe("LearningPath CRUD", () => { - const makeData = () => { - const path = factory.learningPath() - const relationship = factory.learningPathRelationship({ parent: path.id }) - const keys = { - learningResources: keyFactory._def, - relationshipListing: keyFactory.learningpaths._ctx.detail(path.id)._ctx - .infiniteItems._def, - } - const pathUrls = { - list: urls.learningPaths.list(), - details: urls.learningPaths.details({ id: path.id }), - relationshipList: urls.learningPaths.resources({ - learning_resource_id: path.id, - }), - relationshipDetails: urls.learningPaths.resourceDetails({ - id: relationship.id, - learning_resource_id: path.id, - }), - } - - const resourceWithoutList: LearningResource = { - ...relationship.resource, - learning_path_parents: - relationship.resource.learning_path_parents?.filter( - (m) => m.id !== relationship.id, - ) ?? null, - } - return { path, relationship, pathUrls, keys, resourceWithoutList } - } - - test("useLearningpathCreate calls correct API", async () => { - const { path, pathUrls } = makeData() - const url = pathUrls.list - - const requestData = { title: path.title } - setMockResponse.post(pathUrls.list, path) - - const { wrapper, queryClient } = setupReactQueryTest() - jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useLearningpathCreate, { - wrapper, - }) - result.current.mutate(requestData) - - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(makeRequest).toHaveBeenCalledWith("post", url, requestData) - expect(queryClient.invalidateQueries).toHaveBeenCalledWith([ - "learningResources", - "learningpaths", - "learning_paths", - "list", - ]) - }) - - test("useLearningpathDestroy calls correct API", async () => { - const { path, pathUrls } = makeData() - const url = pathUrls.details - setMockResponse.delete(url, null) - - const { wrapper, queryClient } = setupReactQueryTest() - const { result } = renderHook(useLearningpathDestroy, { - wrapper, - }) - result.current.mutate({ id: path.id }) - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined) - expect(invalidateResourceQueries).toHaveBeenCalledWith(queryClient, path.id) - }) - - test("useLearningpathUpdate calls correct API", async () => { - const { path, pathUrls } = makeData() - const url = pathUrls.details - const patch = { id: path.id, title: path.title } - setMockResponse.patch(url, path) - - const { wrapper, queryClient } = setupReactQueryTest() - const { result } = renderHook(useLearningpathUpdate, { wrapper }) - result.current.mutate(patch) - - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(makeRequest).toHaveBeenCalledWith("patch", url, patch) - - expect(invalidateResourceQueries).toHaveBeenCalledWith(queryClient, path.id) - }) - - test("useLearningpathRelationshipMove calls correct API", async () => { - const { relationship, pathUrls, keys } = makeData() - const url = pathUrls.relationshipDetails - setMockResponse.patch(url, null) - - const { wrapper, queryClient } = setupReactQueryTest() - jest.spyOn(queryClient, "invalidateQueries") - const { result } = renderHook(useLearningpathRelationshipMove, { wrapper }) - result.current.mutate(relationship) - - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(makeRequest).toHaveBeenCalledWith( - "patch", - url, - expect.objectContaining({ position: relationship.position }), - ) - - expect(queryClient.invalidateQueries).toHaveBeenCalledWith( - keys.relationshipListing, - ) - }) - - test.each([{ isChildFeatured: false }, { isChildFeatured: true }])( - "useLearningpathRelationshipCreate calls correct API and patches featured resources", - async ({ isChildFeatured }) => { - const { relationship, pathUrls, resourceWithoutList } = makeData() - - const featured = factory.resources({ count: 3 }) - if (isChildFeatured) { - featured.results[0] = resourceWithoutList - } - setMockResponse.get(urls.learningResources.featured(), featured) - - const url = pathUrls.relationshipList - const requestData = { - child: relationship.child, - parent: relationship.parent, - position: relationship.position, - } - setMockResponse.post(url, relationship) - - const { wrapper, queryClient } = setupReactQueryTest() - const { result } = renderHook(useLearningpathRelationshipCreate, { - wrapper, - }) - const { result: featuredResult } = renderHook( - useFeaturedLearningResourcesList, - { wrapper }, - ) - await waitFor(() => expect(featuredResult.current.data).toBe(featured)) - - result.current.mutate(requestData) - - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(makeRequest).toHaveBeenCalledWith("post", url, requestData) - - expect(invalidateResourceQueries).toHaveBeenCalledWith( - queryClient, - relationship.child, - { skipFeatured: false }, - ) - expect(invalidateResourceQueries).toHaveBeenCalledWith( - queryClient, - relationship.parent, - ) - }, - ) - - test.each([{ isChildFeatured: false }, { isChildFeatured: true }])( - "useLearningpathRelationshipDestroy calls correct API and patches child resource cache (isChildFeatured=$isChildFeatured)", - async ({ isChildFeatured }) => { - const { relationship, pathUrls } = makeData() - const url = pathUrls.relationshipDetails - - const featured = factory.resources({ count: 3 }) - if (isChildFeatured) { - featured.results[0] = relationship.resource - } - setMockResponse.get(urls.learningResources.featured(), featured) - - setMockResponse.delete(url, null) - const { wrapper, queryClient } = setupReactQueryTest() - - const { result } = renderHook(useLearningpathRelationshipDestroy, { - wrapper, - }) - const { result: featuredResult } = renderHook( - useFeaturedLearningResourcesList, - { wrapper }, - ) - - await waitFor(() => expect(featuredResult.current.data).toBe(featured)) - result.current.mutate(relationship) - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - - expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined) - expect(invalidateResourceQueries).toHaveBeenCalledWith( - queryClient, - relationship.child, - { skipFeatured: false }, - ) - expect(invalidateResourceQueries).toHaveBeenCalledWith( - queryClient, - relationship.parent, - ) - }, - ) -}) - -describe("userlist CRUD", () => { - const makeData = () => { - const list = factories.userLists.userList() - const relationship = factories.userLists.userListRelationship({ - parent: list.id, - }) - const keys = { - learningResources: keyFactory._def, - relationshipListing: keyFactory.userlists._ctx.detail(list.id)._ctx - .infiniteItems._def, - } - const listUrls = { - list: urls.userLists.list(), - details: urls.userLists.details({ id: list.id }), - relationshipList: urls.userLists.resources({ - userlist_id: list.id, - }), - relationshipDetails: urls.userLists.resourceDetails({ - id: relationship.id, - userlist_id: list.id, - }), - } - - const resourceWithoutList: LearningResource = { - ...relationship.resource, - user_list_parents: - relationship.resource.user_list_parents?.filter( - (m) => m.id !== relationship.id, - ) ?? null, - } - return { path: list, relationship, listUrls, keys, resourceWithoutList } - } - - test.each([{ isChildFeatured: false }, { isChildFeatured: true }])( - "useUserListRelationshipCreate calls correct API and patches featured resources", - async ({ isChildFeatured }) => { - const { relationship, listUrls, resourceWithoutList } = makeData() - - const featured = factory.resources({ count: 3 }) - if (isChildFeatured) { - featured.results[0] = resourceWithoutList - } - setMockResponse.get(urls.learningResources.featured(), featured) - - const url = listUrls.relationshipList - const requestData = { - child: relationship.child, - parent: relationship.parent, - position: relationship.position, - } - setMockResponse.post(url, relationship) - - const { wrapper, queryClient } = setupReactQueryTest() - const { result } = renderHook(useUserListRelationshipCreate, { - wrapper, - }) - const { result: featuredResult } = renderHook( - useFeaturedLearningResourcesList, - { wrapper }, - ) - await waitFor(() => expect(featuredResult.current.data).toBe(featured)) - - result.current.mutate(requestData) - - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(makeRequest).toHaveBeenCalledWith("post", url, requestData) - - expect(invalidateResourceQueries).toHaveBeenCalledWith( - queryClient, - relationship.child, - { skipFeatured: true }, - ) - expect(invalidateUserListQueries).toHaveBeenCalledWith( - queryClient, - relationship.parent, - ) - - // Assert featured API called only once and that the result has been - // patched correctly. When the child is featured, we do NOT want to make - // a new API call to /featured, because the results of that API are randomly - // ordered. - expect( - makeRequest.mock.calls.filter((call) => call[0] === "get").length, - ).toEqual(1) - if (isChildFeatured) { - const firstId = featuredResult.current.data?.results.sort()[0].id - const filtered = featured.results.filter((item) => item.id === firstId) - - expect(filtered[0]).not.toBeNull() - } else { - expect(featuredResult.current.data).toEqual(featured) - } - }, - ) - - test.each([{ isChildFeatured: false }, { isChildFeatured: true }])( - "useUserListRelationshipDestroy calls correct API and patches child resource cache (isChildFeatured=$isChildFeatured)", - async ({ isChildFeatured }) => { - const { relationship, listUrls } = makeData() - const url = listUrls.relationshipDetails - - const featured = factory.resources({ count: 3 }) - if (isChildFeatured) { - featured.results[0] = relationship.resource - } - setMockResponse.get(urls.learningResources.featured(), featured) - - setMockResponse.delete(url, null) - const { wrapper, queryClient } = setupReactQueryTest() - - const { result } = renderHook(useUserListRelationshipDestroy, { - wrapper, - }) - const { result: featuredResult } = renderHook( - useFeaturedLearningResourcesList, - { wrapper }, - ) - - await waitFor(() => expect(featuredResult.current.data).toBe(featured)) - result.current.mutate(relationship) - await waitFor(() => expect(result.current.isSuccess).toBe(true)) - - expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined) - expect(invalidateResourceQueries).toHaveBeenCalledWith( - queryClient, - relationship.child, - { skipFeatured: true }, - ) - expect(invalidateUserListQueries).toHaveBeenCalledWith( - queryClient, - relationship.parent, - ) - - // Assert featured API called only once and that the result has been - // patched correctly. When the child is featured, we do NOT want to make - // a new API call to /featured, because the results of that API are randomly - // ordered. - expect( - makeRequest.mock.calls.filter((call) => call[0] === "get").length, - ).toEqual(1) - if (isChildFeatured) { - const firstId = featuredResult.current.data?.results.sort()[0].id - const filtered = featured.results.filter((item) => item.id === firstId) - - expect(filtered[0]).not.toBeNull() - } else { - expect(featuredResult.current.data).toEqual(featured) - } - }, - ) -}) diff --git a/frontends/api/src/hooks/learningResources/index.ts b/frontends/api/src/hooks/learningResources/index.ts index 6c2ea60ef3..7bd45ac9b0 100644 --- a/frontends/api/src/hooks/learningResources/index.ts +++ b/frontends/api/src/hooks/learningResources/index.ts @@ -1,49 +1,25 @@ import { UseQueryOptions, - useInfiniteQuery, useMutation, useQuery, useQueryClient, } from "@tanstack/react-query" -import { - learningpathsApi, - learningResourcesApi, - userListsApi, -} from "../../clients" +import { learningResourcesApi } from "../../clients" import type { LearningResourcesApiLearningResourcesListRequest as LRListRequest, TopicsApiTopicsListRequest as TopicsListRequest, - LearningpathsApiLearningpathsItemsListRequest as LPResourcesListRequest, - LearningpathsApiLearningpathsListRequest as LPListRequest, - LearningpathsApiLearningpathsCreateRequest as LPCreateRequest, - LearningpathsApiLearningpathsDestroyRequest as LPDestroyRequest, - LearningPathResource, - LearningPathRelationshipRequest, - MicroLearningPathRelationship, LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest, - UserlistsApiUserlistsListRequest as ULListRequest, - UserlistsApiUserlistsCreateRequest as ULCreateRequest, - UserlistsApiUserlistsDestroyRequest as ULDestroyRequest, - UserlistsApiUserlistsItemsListRequest as ULItemsListRequest, OfferorsApiOfferorsListRequest, - UserList, - UserListRelationshipRequest, - MicroUserListRelationship, PlatformsApiPlatformsListRequest, FeaturedApiFeaturedListRequest as FeaturedListParams, PaginatedLearningResourceList, LearningResourcesApiLearningResourcesUserlistsPartialUpdateRequest, LearningResourcesApiLearningResourcesLearningPathsPartialUpdateRequest, + MicroUserListRelationship, } from "../../generated/v1" -import learningResources, { - invalidateResourceQueries, - invalidateUserListQueries, - invalidateResourceWithUserListQueries, - updateListParentsOnAdd, - updateListParentsOnDestroy, - updateListParents, -} from "./keyFactory" -import { ListType } from "../../common/constants" +import learningResources from "./keyFactory" +import { invalidateResourceQueries } from "./invalidation" +import { invalidateUserListQueries } from "../userLists/invalidation" const useLearningResourcesList = ( params: LRListRequest = {}, @@ -83,147 +59,6 @@ const useLearningResourceTopics = ( }) } -const useLearningPathsList = ( - params: LPListRequest = {}, - opts: Pick = {}, -) => { - return useQuery({ - ...learningResources.learningpaths._ctx.list(params), - ...opts, - }) -} - -const useLearningPathsDetail = (id: number) => { - return useQuery(learningResources.learningpaths._ctx.detail(id)) -} - -const useInfiniteLearningPathItems = ( - params: LPResourcesListRequest, - options: Pick = {}, -) => { - return useInfiniteQuery({ - ...learningResources.learningpaths._ctx - .detail(params.learning_resource_id) - ._ctx.infiniteItems(params), - getNextPageParam: (lastPage) => { - return lastPage.next ?? undefined - }, - ...options, - }) -} - -type LearningPathCreateRequest = Omit< - LPCreateRequest["LearningPathResourceRequest"], - "readable_id" | "resource_type" -> -const useLearningpathCreate = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (params: LearningPathCreateRequest) => - learningpathsApi.learningpathsCreate({ - LearningPathResourceRequest: params, - }), - onSettled: () => { - queryClient.invalidateQueries( - learningResources.learningpaths._ctx.list._def, - ) - }, - }) -} -const useLearningpathUpdate = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: ( - params: Pick & Partial, - ) => - learningpathsApi.learningpathsPartialUpdate({ - id: params.id, - PatchedLearningPathResourceRequest: params, - }), - onSettled: (_data, _err, vars) => { - invalidateResourceQueries(queryClient, vars.id) - }, - }) -} - -const useLearningpathDestroy = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (params: LPDestroyRequest) => - learningpathsApi.learningpathsDestroy(params), - onSettled: (_data, _err, vars) => { - invalidateResourceQueries(queryClient, vars.id) - }, - }) -} - -interface ListMoveRequest { - parent: number - id: number - position?: number -} -const useLearningpathRelationshipMove = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: ({ parent, id, position }: ListMoveRequest) => - learningpathsApi.learningpathsItemsPartialUpdate({ - learning_resource_id: parent, - id, - PatchedLearningPathRelationshipRequest: { position }, - }), - onSettled: (_data, _err, vars) => { - queryClient.invalidateQueries( - learningResources.learningpaths._ctx.detail(vars.parent)._ctx - .infiniteItems._def, - ) - }, - }) -} - -const useLearningpathRelationshipCreate = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (params: LearningPathRelationshipRequest) => - learningpathsApi.learningpathsItemsCreate({ - learning_resource_id: params.parent, - LearningPathRelationshipRequest: params, - }), - 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 }, - ) - invalidateResourceQueries(queryClient, vars.parent) - }, - }) -} - -const useLearningpathRelationshipDestroy = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (params: MicroLearningPathRelationship) => - learningpathsApi.learningpathsItemsDestroy({ - id: params.id, - learning_resource_id: params.parent, - }), - 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 }, - ) - invalidateResourceQueries(queryClient, vars.parent) - }, - }) -} - const useLearningResourcesSearch = ( params: LRSearchRequest, opts?: Pick, @@ -234,108 +69,6 @@ const useLearningResourcesSearch = ( }) } -const useUserListList = ( - params: ULListRequest = {}, - opts: Pick = {}, -) => { - return useQuery({ - ...learningResources.userlists._ctx.list(params), - ...opts, - }) -} - -const useUserListsDetail = (id: number) => { - return useQuery(learningResources.userlists._ctx.detail(id)) -} - -const useUserListCreate = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (params: ULCreateRequest["UserListRequest"]) => - userListsApi.userlistsCreate({ - UserListRequest: params, - }), - onSettled: () => { - queryClient.invalidateQueries(learningResources.userlists._ctx.list._def) - }, - }) -} -const useUserListUpdate = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (params: Pick & Partial) => - userListsApi.userlistsPartialUpdate({ - id: params.id, - PatchedUserListRequest: params, - }), - onSettled: (_data, _err, vars) => { - queryClient.invalidateQueries(learningResources.userlists._ctx.list._def) - queryClient.invalidateQueries( - learningResources.userlists._ctx.detail(vars.id).queryKey, - ) - }, - }) -} - -const useUserListDestroy = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (params: ULDestroyRequest) => - userListsApi.userlistsDestroy(params), - onSettled: (_data, _err, vars) => { - invalidateUserListQueries(queryClient, vars.id) - invalidateResourceWithUserListQueries(queryClient, vars.id) - }, - }) -} - -const useUserListRelationshipMove = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: ({ parent, id, position }: ListMoveRequest) => - userListsApi.userlistsItemsPartialUpdate({ - userlist_id: parent, - id, - PatchedUserListRelationshipRequest: { position }, - }), - onSettled: (_data, _err, vars) => { - queryClient.invalidateQueries( - learningResources.userlists._ctx.detail(vars.parent)._ctx.infiniteItems - ._def, - ) - }, - }) -} - -const useUserListRelationshipCreate = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (params: UserListRelationshipRequest) => - userListsApi.userlistsItemsCreate({ - userlist_id: params.parent, - UserListRelationshipRequest: params, - }), - onSuccess: (response, _vars) => { - queryClient.setQueriesData( - learningResources.featured({}).queryKey, - (old) => updateListParentsOnAdd(response.data, old), - ) - }, - 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) - }, - }) -} - const useLearningResourceSetUserListRelationships = () => { const queryClient = useQueryClient() return useMutation({ @@ -387,50 +120,6 @@ const useLearningResourceSetLearningPathRelationships = () => { }) } -const useUserListRelationshipDestroy = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: (params: MicroUserListRelationship) => - userListsApi.userlistsItemsDestroy({ - id: params.id, - userlist_id: params.parent, - }), - onSuccess: (_response, vars) => { - queryClient.setQueriesData( - learningResources.featured({}).queryKey, - (old) => updateListParentsOnDestroy(vars, old), - ) - }, - 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) - }, - }) -} - -const useInfiniteUserListItems = ( - params: ULItemsListRequest, - options: Pick = {}, -) => { - return useInfiniteQuery({ - ...learningResources.userlists._ctx - .detail(params.userlist_id) - ._ctx.infiniteItems(params), - getNextPageParam: (lastPage) => { - return lastPage.next ?? undefined - }, - ...options, - }) -} - const useOfferorsList = ( params: OfferorsApiOfferorsListRequest = {}, opts: Pick = {}, @@ -441,51 +130,6 @@ const useOfferorsList = ( }) } -interface ListItemMoveRequest { - listType: string - parent: number - id: number - position?: number -} -const useListItemMove = () => { - const queryClient = useQueryClient() - return useMutation({ - mutationFn: async ({ - listType, - parent, - id, - position, - }: ListItemMoveRequest) => { - if (listType === ListType.LearningPath) { - await learningpathsApi.learningpathsItemsPartialUpdate({ - learning_resource_id: parent, - id, - PatchedLearningPathRelationshipRequest: { position }, - }) - } else if (listType === ListType.UserList) { - await userListsApi.userlistsItemsPartialUpdate({ - userlist_id: parent, - id, - PatchedUserListRelationshipRequest: { position }, - }) - } - }, - onSettled: (_data, _err, vars) => { - if (vars.listType === ListType.LearningPath) { - queryClient.invalidateQueries( - learningResources.learningpaths._ctx.detail(vars.parent)._ctx - .infiniteItems._def, - ) - } else if (vars.listType === ListType.UserList) { - queryClient.invalidateQueries( - learningResources.userlists._ctx.detail(vars.parent)._ctx - .infiniteItems._def, - ) - } - }, - }) -} - const usePlatformsList = ( params: PlatformsApiPlatformsListRequest = {}, opts: Pick = {}, @@ -500,35 +144,51 @@ const useSchoolsList = () => { return useQuery(learningResources.schools()) } +/** + * Given + * - a LearningResource ID + * - a paginated list of current resources + * - a list of new relationships + * - the type of list + * Update the resources' user_list_parents field to include the new relationships + */ +const updateListParents = ( + resourceId: number, + staleResources?: PaginatedLearningResourceList, + newRelationships?: MicroUserListRelationship[], + listType?: "userlist" | "learningpath", +) => { + if (!resourceId || !staleResources || !newRelationships || !listType) + return staleResources + const matchIndex = staleResources.results.findIndex( + (res) => res.id === resourceId, + ) + if (matchIndex === -1) return staleResources + const updatedResults = [...staleResources.results] + const newResource = { ...updatedResults[matchIndex] } + if (listType === "userlist") { + newResource.user_list_parents = newRelationships + } + if (listType === "learningpath") { + newResource.learning_path_parents = newRelationships + } + updatedResults[matchIndex] = newResource + return { + ...staleResources, + results: updatedResults, + } +} + export { useLearningResourcesList, useFeaturedLearningResourcesList, useLearningResourcesDetail, useLearningResourceTopic, useLearningResourceTopics, - useLearningPathsList, - useLearningPathsDetail, - useInfiniteLearningPathItems, - useLearningpathCreate, - useLearningpathUpdate, - useLearningpathDestroy, - useLearningpathRelationshipMove, - useLearningpathRelationshipCreate, - useLearningpathRelationshipDestroy, useLearningResourcesSearch, useLearningResourceSetUserListRelationships, useLearningResourceSetLearningPathRelationships, - useUserListList, - useUserListsDetail, - useUserListCreate, - useUserListUpdate, - useUserListDestroy, - useUserListRelationshipMove, - useUserListRelationshipCreate, - useUserListRelationshipDestroy, - useInfiniteUserListItems, useOfferorsList, - useListItemMove, usePlatformsList, useSchoolsList, learningResources as learningResourcesKeyFactory, diff --git a/frontends/api/src/hooks/learningResources/invalidation.ts b/frontends/api/src/hooks/learningResources/invalidation.ts new file mode 100644 index 0000000000..58f120a361 --- /dev/null +++ b/frontends/api/src/hooks/learningResources/invalidation.ts @@ -0,0 +1,109 @@ +import type { QueryClient, Query } from "@tanstack/react-query" +import type { + PaginatedLearningResourceList, + LearningResource, +} from "../../generated/v1" +import learningResources from "./keyFactory" +import learningPaths from "../learningPaths/keyFactory" +import userLists from "../userLists/keyFactory" +import { listHasResource } from "../userLists/invalidation" + +/** + * 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. + * In this case, looking up queries by key is easy. + */ + queryClient.invalidateQueries(learningResources.detail(resourceId).queryKey) + queryClient.invalidateQueries(learningPaths.detail(resourceId).queryKey) + queryClient.invalidateQueries(userLists.detail(resourceId).queryKey) + /** + * Invalidate lists that the resource belongs to. + * Check for actual membership. + */ + const lists = [ + learningResources.list._def, + learningPaths.list._def, + learningResources.search._def, + ...(skipFeatured ? [] : [learningResources.featured._def]), + ] + lists.forEach((queryKey) => { + queryClient.invalidateQueries({ + queryKey, + predicate: listHasResource(resourceId), + }) + }) +} + +/** + * Invalidate Resource queries that a resource that belongs to user list appears in. + */ +const invalidateResourceWithUserListQueries = ( + queryClient: QueryClient, + userListId: LearningResource["id"], +) => { + /** + * Invalidate resource detail query for resource that is in the user list. + */ + queryClient.invalidateQueries({ + queryKey: learningResources.detail._def, + predicate: resourceHasUserList(userListId), + }) + + /** + * Invalidate lists with a resource that is in the user list. + */ + const lists = [ + learningResources.list._def, + learningPaths.list._def, + learningResources.search._def, + learningResources.featured._def, + ] + + lists.forEach((queryKey) => { + queryClient.invalidateQueries({ + queryKey, + predicate: resourcesHaveUserList(userListId), + }) + }) +} + +const resourcesHaveUserList = + (userListId: 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.user_list_parents?.some((userList) => userList.parent === userListId), + ) + } + +const resourceHasUserList = + (userListId: number) => + (query: Query): boolean => { + const data = query.state.data as LearningResource + return !!data.user_list_parents?.some( + (userList) => userList.parent === userListId, + ) + } + +export { invalidateResourceQueries, invalidateResourceWithUserListQueries } diff --git a/frontends/api/src/hooks/learningResources/keyFactory.ts b/frontends/api/src/hooks/learningResources/keyFactory.ts index 82a81cbf23..a5518e361c 100644 --- a/frontends/api/src/hooks/learningResources/keyFactory.ts +++ b/frontends/api/src/hooks/learningResources/keyFactory.ts @@ -1,38 +1,23 @@ -import type { QueryClient, Query } from "@tanstack/react-query" +import { createQueryKeys } from "@lukemorales/query-key-factory" import { learningResourcesApi, - learningpathsApi, learningResourcesSearchApi, topicsApi, - userListsApi, offerorsApi, platformsApi, schoolsApi, featuredApi, } from "../../clients" -import axiosInstance from "../../axios" import type { - LearningResourcesApiLearningResourcesListRequest as LRListRequest, - TopicsApiTopicsListRequest as TopicsListRequest, - LearningpathsApiLearningpathsItemsListRequest as LPResourcesListRequest, - LearningpathsApiLearningpathsListRequest as LPListRequest, - PaginatedLearningResourceList, LearningResource, - PaginatedLearningPathRelationshipList, - LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest, - UserlistsApiUserlistsItemsListRequest as ULResourcesListRequest, - UserlistsApiUserlistsListRequest as ULListRequest, - PaginatedUserListRelationshipList, - UserList, + LearningResourcesApiLearningResourcesListRequest as LearningResourcesListRequest, + TopicsApiTopicsListRequest as TopicsListRequest, + LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LearningResourcesSearchRetrieveRequest, OfferorsApiOfferorsListRequest, PlatformsApiPlatformsListRequest, FeaturedApiFeaturedListRequest as FeaturedListParams, - UserListRelationship, - MicroUserListRelationship, } from "../../generated/v1" -import { createQueryKeys } from "@lukemorales/query-key-factory" - const shuffle = ([...arr]) => { let m = arr.length while (m) { @@ -70,7 +55,7 @@ const learningResources = createQueryKeys("learningResources", { .learningResourcesRetrieve({ id }) .then((res) => res.data), }), - list: (params: LRListRequest) => ({ + list: (params: LearningResourcesListRequest) => ({ queryKey: [params], queryFn: () => learningResourcesApi @@ -95,39 +80,7 @@ const learningResources = createQueryKeys("learningResources", { queryKey: [params], queryFn: () => topicsApi.topicsList(params).then((res) => res.data), }), - learningpaths: { - queryKey: ["learning_paths"], - contextQueries: { - detail: (id: number) => ({ - queryKey: [id], - queryFn: () => - learningpathsApi - .learningpathsRetrieve({ id }) - .then((res) => res.data), - contextQueries: { - infiniteItems: (itemsP: LPResourcesListRequest) => ({ - queryKey: [itemsP], - queryFn: ({ pageParam }: { pageParam?: string } = {}) => { - // Use generated API for first request, then use next parameter - const request = pageParam - ? axiosInstance.request({ - method: "get", - url: pageParam, - }) - : learningpathsApi.learningpathsItemsList(itemsP) - return request.then((res) => res.data) - }, - }), - }, - }), - list: (params: LPListRequest) => ({ - queryKey: [params], - queryFn: () => - learningpathsApi.learningpathsList(params).then((res) => res.data), - }), - }, - }, - search: (params: LRSearchRequest) => { + search: (params: LearningResourcesSearchRetrieveRequest) => { return { queryKey: [params], queryFn: () => @@ -136,35 +89,6 @@ const learningResources = createQueryKeys("learningResources", { .then((res) => res.data), } }, - userlists: { - queryKey: ["user_lists"], - contextQueries: { - detail: (id: number) => ({ - queryKey: [id], - queryFn: () => - userListsApi.userlistsRetrieve({ id }).then((res) => res.data), - contextQueries: { - infiniteItems: (itemsP: ULResourcesListRequest) => ({ - queryKey: [itemsP], - queryFn: ({ pageParam }: { pageParam?: string } = {}) => { - const request = pageParam - ? axiosInstance.request({ - method: "get", - url: pageParam, - }) - : userListsApi.userlistsItemsList(itemsP) - return request.then((res) => res.data) - }, - }), - }, - }), - list: (params: ULListRequest) => ({ - queryKey: [params], - queryFn: () => - userListsApi.userlistsList(params).then((res) => res.data), - }), - }, - }, offerors: (params: OfferorsApiOfferorsListRequest) => { return { queryKey: [params], @@ -185,233 +109,4 @@ const learningResources = createQueryKeys("learningResources", { }, }) -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[] | UserList[] = data.pages - ? data.pages.flatMap( - (page: PaginatedLearningResourceList) => page.results, - ) - : data.results - - return resources.some((res) => res.id === resourceId) - } - -const resourcesHaveUserList = - (userListId: 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.user_list_parents?.some((userList) => userList.parent === userListId), - ) - } - -const resourceHasUserList = - (userListId: number) => - (query: Query): boolean => { - const data = query.state.data as LearningResource - return !!data.user_list_parents?.some( - (userList) => userList.parent === userListId, - ) - } - -/** - * 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. - * In this case, looking up queries by key is easy. - */ - queryClient.invalidateQueries(learningResources.detail(resourceId).queryKey) - queryClient.invalidateQueries( - learningResources.learningpaths._ctx.detail(resourceId).queryKey, - ) - queryClient.invalidateQueries( - learningResources.userlists._ctx.detail(resourceId).queryKey, - ) - /** - * Invalidate lists that the resource belongs to. - * Check for actual membership. - */ - const lists = [ - learningResources.list._def, - learningResources.learningpaths._ctx.list._def, - learningResources.search._def, - ...(skipFeatured ? [] : [learningResources.featured._def]), - ] - lists.forEach((queryKey) => { - queryClient.invalidateQueries({ - queryKey, - predicate: listHasResource(resourceId), - }) - }) -} - -/** - * Invalidate Resource queries that a resource that belongs to user list appears in. - */ -const invalidateResourceWithUserListQueries = ( - queryClient: QueryClient, - userListId: LearningResource["id"], -) => { - /** - * Invalidate resource detail query for resource that is in the user list. - */ - queryClient.invalidateQueries({ - queryKey: learningResources.detail._def, - predicate: resourceHasUserList(userListId), - }) - - /** - * Invalidate lists with a resource that is in the user list. - */ - const lists = [ - learningResources.list._def, - learningResources.learningpaths._ctx.list._def, - learningResources.search._def, - learningResources.featured._def, - ] - - lists.forEach((queryKey) => { - queryClient.invalidateQueries({ - queryKey, - predicate: resourcesHaveUserList(userListId), - }) - }) -} - -const invalidateUserListQueries = ( - queryClient: QueryClient, - userListId: UserList["id"], -) => { - 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, - } -} - -/** - * Given - * - a LearningResource ID - * - a paginated list of current resources - * - a list of new relationships - * - the type of list - * Update the resources' user_list_parents field to include the new relationships - */ -const updateListParents = ( - resourceId: number, - staleResources?: PaginatedLearningResourceList, - newRelationships?: MicroUserListRelationship[], - listType?: "userlist" | "learningpath", -) => { - if (!resourceId || !staleResources || !newRelationships || !listType) - return staleResources - const matchIndex = staleResources.results.findIndex( - (res) => res.id === resourceId, - ) - if (matchIndex === -1) return staleResources - const updatedResults = [...staleResources.results] - const newResource = { ...updatedResults[matchIndex] } - if (listType === "userlist") { - newResource.user_list_parents = newRelationships - } - if (listType === "learningpath") { - newResource.learning_path_parents = newRelationships - } - updatedResults[matchIndex] = newResource - return { - ...staleResources, - results: updatedResults, - } -} - export default learningResources -export { - invalidateResourceQueries, - invalidateUserListQueries, - invalidateResourceWithUserListQueries, - updateListParentsOnAdd, - updateListParentsOnDestroy, - updateListParents, -} diff --git a/frontends/api/src/hooks/programLetters/index.ts b/frontends/api/src/hooks/programLetters/index.ts index a9de8224cc..5f52a00208 100644 --- a/frontends/api/src/hooks/programLetters/index.ts +++ b/frontends/api/src/hooks/programLetters/index.ts @@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query" import programLetters from "./keyFactory" /** - * Query is diabled if id is undefined. + * Query is disabled if id is undefined. */ const useProgramLettersDetail = (id: string | undefined) => { return useQuery({ diff --git a/frontends/api/src/hooks/searchSubscription/keyFactory.ts b/frontends/api/src/hooks/searchSubscription/keyFactory.ts index 443aed754e..c99c23afd9 100644 --- a/frontends/api/src/hooks/searchSubscription/keyFactory.ts +++ b/frontends/api/src/hooks/searchSubscription/keyFactory.ts @@ -1,6 +1,7 @@ import { searchSubscriptionApi } from "../../clients" import { createQueryKeys } from "@lukemorales/query-key-factory" import type { LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionCheckListRequest as subscriptionCheckListRequest } from "../../generated/v1" + const searchSubscriptions = createQueryKeys("searchSubscriptions", { list: (params: subscriptionCheckListRequest) => ({ queryKey: [params], diff --git a/frontends/api/src/hooks/userLists/index.ts b/frontends/api/src/hooks/userLists/index.ts new file mode 100644 index 0000000000..6aacd99fff --- /dev/null +++ b/frontends/api/src/hooks/userLists/index.ts @@ -0,0 +1,117 @@ +import { + UseQueryOptions, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from "@tanstack/react-query" +import { userListsApi } from "../../clients" +import type { + UserlistsApiUserlistsListRequest as ListRequest, + UserlistsApiUserlistsCreateRequest as CreateRequest, + UserlistsApiUserlistsDestroyRequest as DestroyRequest, + UserlistsApiUserlistsItemsListRequest as ItemsListRequest, + UserList, +} from "../../generated/v1" +import userLists from "./keyFactory" +import { invalidateResourceWithUserListQueries } from "../learningResources/invalidation" +import { invalidateUserListQueries } from "./invalidation" + +const useUserListList = ( + params: ListRequest = {}, + opts: Pick = {}, +) => { + return useQuery({ + ...userLists.list(params), + ...opts, + }) +} + +const useUserListsDetail = (id: number) => { + return useQuery(userLists.detail(id)) +} + +const useUserListCreate = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: CreateRequest["UserListRequest"]) => + userListsApi.userlistsCreate({ + UserListRequest: params, + }), + onSettled: () => { + queryClient.invalidateQueries(userLists.list._def) + }, + }) +} +const useUserListUpdate = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: Pick & Partial) => + userListsApi.userlistsPartialUpdate({ + id: params.id, + PatchedUserListRequest: params, + }), + onSettled: (_data, _err, vars) => { + queryClient.invalidateQueries(userLists.list._def) + queryClient.invalidateQueries(userLists.detail(vars.id).queryKey) + }, + }) +} + +const useUserListDestroy = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: DestroyRequest) => + userListsApi.userlistsDestroy(params), + onSettled: (_data, _err, vars) => { + invalidateUserListQueries(queryClient, vars.id) + invalidateResourceWithUserListQueries(queryClient, vars.id) + }, + }) +} + +const useInfiniteUserListItems = ( + params: ItemsListRequest, + options: Pick = {}, +) => { + return useInfiniteQuery({ + ...userLists.detail(params.userlist_id)._ctx.infiniteItems(params), + getNextPageParam: (lastPage) => { + return lastPage.next ?? undefined + }, + ...options, + }) +} + +interface ListItemMoveRequest { + parent: number + id: number + position?: number +} +const useUserListListItemMove = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async ({ parent, id, position }: ListItemMoveRequest) => { + await userListsApi.userlistsItemsPartialUpdate({ + userlist_id: parent, + id, + PatchedUserListRelationshipRequest: { position }, + }) + }, + onSettled: (_data, _err, vars) => { + queryClient.invalidateQueries( + userLists.detail(vars.parent)._ctx.infiniteItems._def, + ) + }, + }) +} + +export { + useUserListList, + useUserListsDetail, + useUserListCreate, + useUserListUpdate, + useUserListDestroy, + useInfiniteUserListItems, + useUserListListItemMove, +} diff --git a/frontends/api/src/hooks/userLists/invalidation.ts b/frontends/api/src/hooks/userLists/invalidation.ts new file mode 100644 index 0000000000..3a05383f36 --- /dev/null +++ b/frontends/api/src/hooks/userLists/invalidation.ts @@ -0,0 +1,38 @@ +import { QueryClient, Query } from "@tanstack/react-query" +import userLists from "./keyFactory" +import { + UserList, + LearningResource, + PaginatedLearningResourceList, +} from "../../generated/v1" + +const invalidateUserListQueries = ( + queryClient: QueryClient, + userListId: UserList["id"], +) => { + queryClient.invalidateQueries(userLists.detail(userListId).queryKey) + const lists = [userLists.list._def] + + lists.forEach((queryKey) => { + queryClient.invalidateQueries({ + queryKey, + predicate: listHasResource(userListId), + }) + }) +} + +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[] | UserList[] = data.pages + ? data.pages.flatMap( + (page: PaginatedLearningResourceList) => page.results, + ) + : data.results + + return resources.some((res) => res.id === resourceId) + } + +export { invalidateUserListQueries, listHasResource } diff --git a/frontends/api/src/hooks/userLists/keyFactory.ts b/frontends/api/src/hooks/userLists/keyFactory.ts new file mode 100644 index 0000000000..90793e38a5 --- /dev/null +++ b/frontends/api/src/hooks/userLists/keyFactory.ts @@ -0,0 +1,36 @@ +import { createQueryKeys } from "@lukemorales/query-key-factory" +import axiosInstance from "../../axios" +import type { + UserlistsApiUserlistsItemsListRequest as ItemsListRequest, + UserlistsApiUserlistsListRequest as ListRequest, + PaginatedUserListRelationshipList, +} from "../../generated/v1" +import { userListsApi } from "../../clients" + +const userLists = createQueryKeys("userLists", { + detail: (id: number) => ({ + queryKey: [id], + queryFn: () => + userListsApi.userlistsRetrieve({ id }).then((res) => res.data), + contextQueries: { + infiniteItems: (itemsP: ItemsListRequest) => ({ + queryKey: [itemsP], + queryFn: ({ pageParam }: { pageParam?: string } = {}) => { + const request = pageParam + ? axiosInstance.request({ + method: "get", + url: pageParam, + }) + : userListsApi.userlistsItemsList(itemsP) + return request.then((res) => res.data) + }, + }), + }, + }), + list: (params: ListRequest) => ({ + queryKey: [params], + queryFn: () => userListsApi.userlistsList(params).then((res) => res.data), + }), +}) + +export default userLists diff --git a/frontends/main/src/app-pages/DashboardPage/UserListDetailsTab.tsx b/frontends/main/src/app-pages/DashboardPage/UserListDetailsTab.tsx index 0ec2ab4ff9..6c7533b1e9 100644 --- a/frontends/main/src/app-pages/DashboardPage/UserListDetailsTab.tsx +++ b/frontends/main/src/app-pages/DashboardPage/UserListDetailsTab.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from "react" import { useInfiniteUserListItems, useUserListsDetail, -} from "api/hooks/learningResources" +} from "api/hooks/userLists" import { useRouter } from "next/navigation" import { ListType } from "api/constants" import { useUserMe } from "api/hooks/user" diff --git a/frontends/main/src/app-pages/LearningPathDetailsPage/LearningPathDetailsPage.tsx b/frontends/main/src/app-pages/LearningPathDetailsPage/LearningPathDetailsPage.tsx index d4db1727ef..c2b7f75e49 100644 --- a/frontends/main/src/app-pages/LearningPathDetailsPage/LearningPathDetailsPage.tsx +++ b/frontends/main/src/app-pages/LearningPathDetailsPage/LearningPathDetailsPage.tsx @@ -6,7 +6,7 @@ import { useUserMe } from "api/hooks/user" import { useInfiniteLearningPathItems, useLearningPathsDetail, -} from "api/hooks/learningResources" +} from "api/hooks/learningPaths" import { ListType } from "api/constants" import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import LearningResourceDrawer from "@/page-components/LearningResourceDrawer/LearningResourceDrawer" diff --git a/frontends/main/src/app-pages/LearningPathListingPage/LearningPathListingPage.tsx b/frontends/main/src/app-pages/LearningPathListingPage/LearningPathListingPage.tsx index 8c0066287d..0c3ad672bd 100644 --- a/frontends/main/src/app-pages/LearningPathListingPage/LearningPathListingPage.tsx +++ b/frontends/main/src/app-pages/LearningPathListingPage/LearningPathListingPage.tsx @@ -18,7 +18,7 @@ import type { SimpleMenuItem } from "ol-components" import { RiPencilFill, RiMore2Line, RiDeleteBinLine } from "@remixicon/react" import type { LearningPathResource } from "api" -import { useLearningPathsList } from "api/hooks/learningResources" +import { useLearningPathsList } from "api/hooks/learningPaths" import { GridColumn, GridContainer } from "@/components/GridLayout/GridLayout" diff --git a/frontends/main/src/page-components/Dialogs/AddToListDialog.tsx b/frontends/main/src/page-components/Dialogs/AddToListDialog.tsx index 8d26f98175..b47d112c3f 100644 --- a/frontends/main/src/page-components/Dialogs/AddToListDialog.tsx +++ b/frontends/main/src/page-components/Dialogs/AddToListDialog.tsx @@ -18,11 +18,11 @@ import type { LearningPathResource, LearningResource, UserList } from "api" import { useLearningResourceSetUserListRelationships, - useLearningPathsList, useLearningResourcesDetail, - useUserListList, useLearningResourceSetLearningPathRelationships, } from "api/hooks/learningResources" +import { useUserListList } from "api/hooks/userLists" +import { useLearningPathsList } from "api/hooks/learningPaths" import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import { ListType } from "api/constants" import { useFormik } from "formik" diff --git a/frontends/main/src/page-components/ItemsListing/ItemsListing.tsx b/frontends/main/src/page-components/ItemsListing/ItemsListing.tsx index 7f4b03e1da..1e0b8f26a0 100644 --- a/frontends/main/src/page-components/ItemsListing/ItemsListing.tsx +++ b/frontends/main/src/page-components/ItemsListing/ItemsListing.tsx @@ -13,7 +13,9 @@ import { LearningResourceListCardCondensed, } from "ol-components" import { ResourceCard } from "@/page-components/ResourceCard/ResourceCard" -import { useListItemMove } from "api/hooks/learningResources" +import { useLearningPathListItemMove } from "api/hooks/learningPaths" +import { useUserListListItemMove } from "api/hooks/userLists" +import { ListType } from "api/constants" const EmptyMessage = styled.p({ fontStyle: "italic", @@ -51,7 +53,9 @@ const ItemsListingSortable: React.FC<{ isRefetching?: boolean condensed: boolean }> = ({ listType, items, isRefetching, condensed }) => { - const move = useListItemMove() + const moveLearningPathListItem = useLearningPathListItemMove() + const moveUserListListItem = useUserListListItemMove() + const [sorted, setSorted] = React.useState([]) const ListCardComponent = condensed @@ -84,17 +88,28 @@ const ItemsListingSortable: React.FC<{ const newOrder = arrayMove(current, e.activeIndex, e.overIndex) return newOrder }) - move.mutate({ - listType: listType, - id: active.id, - parent: active.parent, - position: over.position, - }) + if (listType === ListType.LearningPath) { + moveLearningPathListItem.mutate({ + id: active.id, + parent: active.parent, + position: over.position, + }) + } + if (listType === ListType.UserList) { + moveUserListListItem.mutate({ + id: active.id, + parent: active.parent, + position: over.position, + }) + } }, - [listType, move], + [listType, moveLearningPathListItem, moveUserListListItem], ) - const disabled = isRefetching || move.isLoading + const disabled = + isRefetching || + moveLearningPathListItem.isLoading || + moveUserListListItem.isLoading return ( diff --git a/frontends/main/src/page-components/ManageListDialogs/ManageListDialogs.tsx b/frontends/main/src/page-components/ManageListDialogs/ManageListDialogs.tsx index 8523e37027..550f416270 100644 --- a/frontends/main/src/page-components/ManageListDialogs/ManageListDialogs.tsx +++ b/frontends/main/src/page-components/ManageListDialogs/ManageListDialogs.tsx @@ -16,14 +16,16 @@ import * as Yup from "yup" import { PrivacyLevelEnum, type LearningPathResource, UserList } from "api" import { - useLearningpathCreate, - useLearningpathUpdate, - useLearningpathDestroy, - useLearningResourceTopics, + useLearningPathCreate, + useLearningPathUpdate, + useLearningPathDestroy, +} from "api/hooks/learningPaths" +import { useLearningResourceTopics } from "api/hooks/learningResources" +import { useUserListCreate, useUserListUpdate, useUserListDestroy, -} from "api/hooks/learningResources" +} from "api/hooks/userLists" const learningPathFormSchema = Yup.object().shape({ published: Yup.boolean() @@ -82,8 +84,8 @@ const UpsertLearningPathDialog = NiceModal.create( const topicsQuery = useLearningResourceTopics(undefined, { enabled: modal.visible, }) - const createList = useLearningpathCreate() - const updateList = useLearningpathUpdate() + const createList = useLearningPathCreate() + const updateList = useLearningPathUpdate() const mutation = resource?.id ? updateList : createList const handleSubmit: FormikConfig< LearningPathResource | LearningPathFormValues @@ -301,7 +303,7 @@ const DeleteLearningPathDialog = NiceModal.create( ({ resource }: DeleteLearningPathDialogProps) => { const modal = NiceModal.useModal() const hideModal = modal.hide - const destroyList = useLearningpathDestroy() + const destroyList = useLearningPathDestroy() const handleConfirm = useCallback(async () => { await destroyList.mutateAsync({ diff --git a/frontends/main/src/page-components/UserListListing/UserListListing.tsx b/frontends/main/src/page-components/UserListListing/UserListListing.tsx index 6ed4f8c84f..4e846fba9e 100644 --- a/frontends/main/src/page-components/UserListListing/UserListListing.tsx +++ b/frontends/main/src/page-components/UserListListing/UserListListing.tsx @@ -10,7 +10,7 @@ import { TypographyProps, } from "ol-components" import { RiListCheck3 } from "@remixicon/react" -import { useUserListList } from "api/hooks/learningResources" +import { useUserListList } from "api/hooks/userLists" import { GridColumn, GridContainer } from "@/components/GridLayout/GridLayout" import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import { userListView } from "@/common/urls"