diff --git a/frontends/api/src/clients.ts b/frontends/api/src/clients.ts index 29e0f10880..2a992323b1 100644 --- a/frontends/api/src/clients.ts +++ b/frontends/api/src/clients.ts @@ -1,6 +1,7 @@ import { LearningResourcesApi, LearningpathsApi, + UserlistsApi, TopicsApi, ArticlesApi, ProgramLettersApi, @@ -30,6 +31,8 @@ const learningpathsApi = new LearningpathsApi( axiosInstance, ) +const userListsApi = new UserlistsApi(undefined, BASE_PATH, axiosInstance) + const topicsApi = new TopicsApi(undefined, BASE_PATH, axiosInstance) const articlesApi = new ArticlesApi(undefined, BASE_PATH, axiosInstance) @@ -46,6 +49,7 @@ const widgetListsApi = new WidgetListsApi(undefined, BASE_PATH, axiosInstance) export { learningResourcesApi, learningpathsApi, + userListsApi, topicsApi, articlesApi, programLettersApi, diff --git a/frontends/api/src/hooks/learningResources/index.ts b/frontends/api/src/hooks/learningResources/index.ts index 33f4b3910b..8661313cb6 100644 --- a/frontends/api/src/hooks/learningResources/index.ts +++ b/frontends/api/src/hooks/learningResources/index.ts @@ -18,6 +18,8 @@ import type { MicroLearningPathRelationship, LearningResource, LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest, + UserlistsApiUserlistsListRequest as ULListRequest, + UserlistsApiUserlistsItemsListRequest as ULItemsListRequest, } from "../../generated/v1" import learningResources, { invalidateResourceQueries } from "./keyFactory" @@ -202,6 +204,31 @@ const useLearningResourcesSearch = ( }) } +const useUserListList = ( + params: ULListRequest = {}, + opts: Pick = {}, +) => { + return useQuery({ + ...learningResources.userlists._ctx.list(params), + ...opts, + }) +} + +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, + }) +} + export { useLearningResourcesList, useLearningResourcesDetail, @@ -216,4 +243,6 @@ export { useLearningpathRelationshipCreate, useLearningpathRelationshipDestroy, useLearningResourcesSearch, + useUserListList, + useInfiniteUserListItems, } diff --git a/frontends/api/src/hooks/learningResources/keyFactory.ts b/frontends/api/src/hooks/learningResources/keyFactory.ts index 949044ba98..140e1adf8e 100644 --- a/frontends/api/src/hooks/learningResources/keyFactory.ts +++ b/frontends/api/src/hooks/learningResources/keyFactory.ts @@ -4,6 +4,7 @@ import { learningpathsApi, learningResourcesSearchApi, topicsApi, + userListsApi, } from "../../clients" import axiosInstance from "../../axios" import type { @@ -15,6 +16,10 @@ import type { LearningResource, PaginatedLearningPathRelationshipList, LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest, + UserlistsApiUserlistsItemsListRequest as ULResourcesListRequest, + UserlistsApiUserlistsListRequest as ULListRequest, + PaginatedUserListRelationshipList, + UserList, } from "../../generated/v1" import { createQueryKeys } from "@lukemorales/query-key-factory" @@ -79,9 +84,38 @@ 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), + }), + }, + }, }) -const listHasResource = +const learningPathHasResource = (resourceId: number) => (query: Query): boolean => { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -93,6 +127,20 @@ const listHasResource = : 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 + ? data.pages.flatMap( + (page: PaginatedLearningResourceList) => page.results, + ) + : data.results + return resources.some((res) => res.id === resourceId) + } + const invalidateResourceQueries = ( queryClient: QueryClient, resourceId: LearningResource["id"], @@ -105,6 +153,9 @@ const invalidateResourceQueries = ( 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. @@ -112,11 +163,16 @@ const invalidateResourceQueries = ( const lists = [ learningResources.list._def, learningResources.learningpaths._ctx.list._def, + learningResources.userlists._ctx.list._def, ] lists.forEach((queryKey) => { queryClient.invalidateQueries({ queryKey, - predicate: listHasResource(resourceId), + predicate: learningPathHasResource(resourceId), + }) + queryClient.invalidateQueries({ + queryKey, + predicate: userListHasResource(resourceId), }) }) } diff --git a/frontends/api/src/test-utils/factories/index.ts b/frontends/api/src/test-utils/factories/index.ts index 17b374aeba..c78dea7e31 100644 --- a/frontends/api/src/test-utils/factories/index.ts +++ b/frontends/api/src/test-utils/factories/index.ts @@ -1,5 +1,5 @@ export * as learningResources from "./learningResources" - +export * as userLists from "./userLists" export * as articles from "./articles" export * as letters from "./programLetters" export * as fields from "./fields" diff --git a/frontends/api/src/test-utils/factories/userLists.ts b/frontends/api/src/test-utils/factories/userLists.ts new file mode 100644 index 0000000000..27381c790f --- /dev/null +++ b/frontends/api/src/test-utils/factories/userLists.ts @@ -0,0 +1,18 @@ +import { Factory, makePaginatedFactory } from "ol-test-utilities" +import { UserList } from "api" +import { faker } from "@faker-js/faker/locale/en" + +const userList: Factory = (overrides = {}) => { + const list: UserList = { + id: faker.helpers.unique(faker.datatype.number), + title: faker.helpers.unique(faker.lorem.words), + item_count: 4, + image: {}, + author: faker.helpers.unique(faker.datatype.number), + ...overrides, + } + return list +} +const userLists = makePaginatedFactory(userList) + +export { userList, userLists } diff --git a/frontends/api/src/test-utils/urls.ts b/frontends/api/src/test-utils/urls.ts index 6fbc0de8da..cd7c70dc10 100644 --- a/frontends/api/src/test-utils/urls.ts +++ b/frontends/api/src/test-utils/urls.ts @@ -10,6 +10,7 @@ import type { TopicsApi, LearningpathsApi, ArticlesApi, + UserlistsApi, } from "../generated/v1" import type { BaseAPI } from "../generated/v1/base" @@ -57,6 +58,23 @@ const learningPaths = { `/api/v1/learningpaths/${params.id}/`, } +const userLists = { + list: (params?: Params) => + `/api/v1/userlists/${query(params)}`, + resources: ({ + userlist_id: parentId, + ...others + }: Params) => + `/api/v1/userlists/${parentId}/items/${query(others)}`, + resourceDetails: ({ + userlist_id: parentId, + id, + }: Params) => + `/api/v1/userlists/${parentId}/items/${id}/`, + details: (params: Params) => + `/api/v1/userlists/${params.id}/`, +} + const articles = { list: (params?: Params) => `/api/v1/articles/${query(params)}`, @@ -85,6 +103,7 @@ export { learningPaths, articles, search, + userLists, programLetters, fields, widgetLists, diff --git a/frontends/mit-open/src/common/urls.ts b/frontends/mit-open/src/common/urls.ts index 088b1078e2..6823529127 100644 --- a/frontends/mit-open/src/common/urls.ts +++ b/frontends/mit-open/src/common/urls.ts @@ -4,6 +4,7 @@ export const HOME = "/" export const LEARNINGPATH_LISTING = "/learningpaths/" export const LEARNINGPATH_VIEW = "/learningpaths/:id" +export const USERLIST_LISTING = "/userlists/" export const learningPathsView = (id: number) => generatePath(LEARNINGPATH_VIEW, { id: String(id) }) export const PROGRAMLETTER_VIEW = "/program_letter/:id/view/" diff --git a/frontends/mit-open/src/page-components/CardTemplate/CardTemplate.test.tsx b/frontends/mit-open/src/page-components/CardTemplate/CardTemplate.test.tsx new file mode 100644 index 0000000000..9c53a24c58 --- /dev/null +++ b/frontends/mit-open/src/page-components/CardTemplate/CardTemplate.test.tsx @@ -0,0 +1,12 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import CardTemplate from "./CardTemplate" + +describe("CardTemplate", () => { + it("renders title and cover image", () => { + const title = "Test Title" + render() + const heading = screen.getByRole("heading", { name: title }) + expect(heading).toHaveAccessibleName(title) + }) +}) diff --git a/frontends/mit-open/src/page-components/CardTemplate/CardTemplate.tsx b/frontends/mit-open/src/page-components/CardTemplate/CardTemplate.tsx new file mode 100644 index 0000000000..c8d970bdb1 --- /dev/null +++ b/frontends/mit-open/src/page-components/CardTemplate/CardTemplate.tsx @@ -0,0 +1,187 @@ +import React from "react" +import Dotdotdot from "react-dotdotdot" +import invariant from "tiny-invariant" +import { Card, CardContent, styled } from "ol-components" +import DragIndicatorIcon from "@mui/icons-material/DragIndicator" + +type CardVariant = "column" | "row" | "row-reverse" +type OnActivateCard = () => void +type CardTemplateProps = { + /** + * Whether the course picture and info display as a column or row. + */ + variant: CardVariant + sortable?: boolean + className?: string + handleActivate?: OnActivateCard + extraDetails?: React.ReactNode + imageSlot?: React.ReactNode + title?: string + bodySlot?: React.ReactNode + footerSlot?: React.ReactNode + footerActionSlot?: React.ReactNode +} + +const LIGHT_TEXT_COLOR = "#8c8c8c" +const SPACER = 0.75 +const SMALL_FONT_SIZE = 0.75 + +const StyledCard = styled(Card)` + display: flex; + flex-direction: column; + + /* Ensure the resource image borders match card borders */ + .MuiCardMedia-root, + > .MuiCardContent-root { + border-radius: inherit; + } +` + +const Details = styled.div` + /* Make content flexbox so that we can control which child fills remaining space. */ + flex: 1; + display: flex; + flex-direction: column; + + > * { + /* + Flexbox doesn't have collapsing margins, so we need to avoid double spacing. + The column-gap property would be a nicer solution, but it doesn't have the + best browser support yet. + */ + margin-top: ${SPACER / 2}rem; + margin-bottom: ${SPACER / 2}rem; + + &:first-of-type { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } + } +` + +const StyledCardContent = styled(CardContent, { + shouldForwardProp: (prop) => prop !== "sortable", +})<{ + variant: CardVariant + sortable: boolean +}>` + display: flex; + flex-direction: ${({ variant }) => variant}; + ${({ variant }) => (variant === "column" ? "flex: 1;" : "")} + ${({ sortable }) => (sortable ? "padding-left: 4px;" : "")} +` + +/* + Last child of ol-lrc-content will take up any extra space (flex: 1) but + with its contents at the bottom of its box. + The default is stretch, we we do not want. +*/ +const FillSpaceContentEnd = styled.div` + flex: 1; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: flex-start; +` + +const FooterRow = styled.div` + min-height: 2.5 * ${SMALL_FONT_SIZE}; /* ensure consistent spacing even if no date */ + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; +` + +const EllipsisTitle = styled(Dotdotdot)({ + fontWeight: "bold", + margin: 0, +}) + +const TitleButton = styled.button` + border: none; + background-color: white; + color: inherit; + display: block; + text-align: left; + padding: 0; + margin: 0; + + &:hover { + text-decoration: underline; + cursor: pointer; + } +` + +const DragHandle = styled.div` + display: flex; + align-items: center; + font-size: 40px; + align-self: stretch; + color: ${LIGHT_TEXT_COLOR}; + border-right: 1px solid ${LIGHT_TEXT_COLOR}; + margin-right: 16px; +` + +const CardTemplate = ({ + variant, + className, + handleActivate, + extraDetails, + imageSlot, + title, + bodySlot, + footerSlot, + footerActionSlot, + sortable = false, +}: CardTemplateProps) => { + invariant( + !sortable || variant === "row-reverse", + "sortable only supported for variant='row-reverse'", + ) + + return ( + + {variant === "column" ? imageSlot : null} + + {variant !== "column" ? imageSlot : null} +
+ {extraDetails} + {handleActivate ? ( + + + {title} + + + ) : ( + + {title} + + )} + {sortable ? null : ( + <> + {bodySlot} + + +
{footerSlot}
+ {footerActionSlot} +
+
+ + )} +
+ {sortable ? ( + + + + ) : null} +
+
+ ) +} + +export default CardTemplate +export type { CardTemplateProps } diff --git a/frontends/mit-open/src/page-components/Header/UserMenu.tsx b/frontends/mit-open/src/page-components/Header/UserMenu.tsx index c132ebb90b..d585d7ccf0 100644 --- a/frontends/mit-open/src/page-components/Header/UserMenu.tsx +++ b/frontends/mit-open/src/page-components/Header/UserMenu.tsx @@ -50,6 +50,12 @@ const getUserMenuItems = (location: Location): SimpleMenuItem[] => { allow: hasPermission(Permissions.Authenticated), href: urls.DASHBOARD, }, + { + label: "User Lists", + key: "userlists", + allow: hasPermission(Permissions.Authenticated), + href: urls.USERLIST_LISTING, + }, { label: "Learning Paths", key: "learningpaths", diff --git a/frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.test.tsx b/frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.test.tsx index 3f8b813156..1cd2240cf9 100644 --- a/frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.test.tsx +++ b/frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.test.tsx @@ -11,7 +11,7 @@ import { within, act, } from "../../test-utils" -import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import { waitForElementToBeRemoved } from "@testing-library/react" import { LearningPathRelationship, @@ -163,7 +163,7 @@ describe("AddToListDialog", () => { test("Clicking 'Create a new list' opens the create list dialog", async () => { // Don't actually open the 'Create List' modal, or we'll need to mock API responses. const createList = jest - .spyOn(manageListDialogs, "upsert") + .spyOn(manageLearningPathDialogs, "upsert") .mockImplementationOnce(jest.fn()) setup() diff --git a/frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.tsx b/frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.tsx index ea14705c3d..7555f0d101 100644 --- a/frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.tsx +++ b/frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.tsx @@ -25,7 +25,7 @@ import { useLearningpathRelationshipCreate, useLearningpathRelationshipDestroy, } from "api/hooks/learningResources" -import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" type AddToListDialogProps = { resourceId: number @@ -188,7 +188,9 @@ const AddToListDialogInner: React.FC = ({ ) })} - manageListDialogs.upsert()}> + manageLearningPathDialogs.upsert()} + > diff --git a/frontends/mit-open/src/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate.test.tsx b/frontends/mit-open/src/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate.test.tsx index e67eb5bbf0..948901a64b 100644 --- a/frontends/mit-open/src/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate.test.tsx +++ b/frontends/mit-open/src/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate.test.tsx @@ -30,7 +30,7 @@ describe("LearningResourceCard", () => { expect(coverImg.src).toBe(resourceThumbnailSrc(resource.image, imgConfig)) }) - it("does not show an image iff suppressImage is true", () => { + it("does not show an image if and only if suppressImage is true", () => { const resource = factory.course() const imgConfig = makeImgConfig() const { rerender } = render( @@ -92,7 +92,7 @@ describe("LearningResourceCard", () => { }, ) - it("Should show an item count iff the resource is a list", () => { + it("Should show an item count if the resource is a list", () => { const resource = factory.learningPath() const count = resource.learning_path?.item_count const imgConfig = makeImgConfig() @@ -109,7 +109,7 @@ describe("LearningResourceCard", () => { expect(itemCount).toBeVisible() }) - it("Should NOT show an item count iff the resource is NOT a list", () => { + it("Should NOT show an item count if the resource is NOT a list", () => { const resource = factory.resource({ title: "Not a list", resource_type: ResourceTypeEnum.Course, diff --git a/frontends/mit-open/src/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate.tsx b/frontends/mit-open/src/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate.tsx index 840771fdaa..5e057299d7 100644 --- a/frontends/mit-open/src/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate.tsx +++ b/frontends/mit-open/src/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate.tsx @@ -1,10 +1,7 @@ import React, { useCallback } from "react" -import Dotdotdot from "react-dotdotdot" -import invariant from "tiny-invariant" import { ResourceTypeEnum, type LearningResource } from "api" -import { Card, CardContent, Chip, CardMedia, styled } from "ol-components" +import { Chip, CardMedia, styled } from "ol-components" import CalendarTodayIcon from "@mui/icons-material/CalendarToday" -import DragIndicatorIcon from "@mui/icons-material/DragIndicator" import { formatDate, pluralize, @@ -13,6 +10,7 @@ import { findBestRun, } from "ol-utilities" import type { EmbedlyConfig } from "ol-utilities" +import CardTemplate from "../CardTemplate/CardTemplate" type CardVariant = "column" | "row" | "row-reverse" type OnActivateCard = (resource: R) => void @@ -39,7 +37,6 @@ type LearningResourceCardTemplateProps< } const LIGHT_TEXT_COLOR = "#8c8c8c" -const SPACER = 0.75 const SMALL_FONT_SIZE = 0.75 const CalendarChip = styled(Chip)({ @@ -114,54 +111,6 @@ const LRCImage: React.FC = ({ ) } -const StyledCard = styled(Card)` - display: flex; - flex-direction: column; - - /* Ensure the resource image borders match card borders */ - .MuiCardMedia-root, - > .MuiCardContent-root { - border-radius: inherit; - } -` - -const Details = styled.div` - /* Make content flexbox so that we can control which child fills remaining space. */ - flex: 1; - display: flex; - flex-direction: column; - - > * { - /* - Flexbox doesn't have collapsing margins, so we need to avoid double spacing. - The column-gap property would be a nicer solution, but it doesn't have the - best browser support yet. - */ - margin-top: ${SPACER / 2}rem; - margin-bottom: ${SPACER / 2}rem; - - &:first-of-type { - margin-top: 0; - } - - &:last-child { - margin-bottom: 0; - } - } -` - -const StyledCardContent = styled(CardContent, { - shouldForwardProp: (prop) => prop !== "sortable", -})<{ - variant: CardVariant - sortable: boolean -}>` - display: flex; - flex-direction: ${({ variant }) => variant}; - ${({ variant }) => (variant === "column" ? "flex: 1;" : "")} - ${({ sortable }) => (sortable ? "padding-left: 4px;" : "")} -` - const OfferedByText = styled.span` color: ${LIGHT_TEXT_COLOR}; padding-right: 0.25em; @@ -179,28 +128,6 @@ const CardBody: React.FC< ) : null } -/* - Last child of ol-lrc-content will take up any extra space (flex: 1) but - with its contents at the bottom of its box. - The default is stretch, we we do not want. -*/ -const FillSpaceContentEnd = styled.div` - flex: 1; - display: flex; - flex-direction: column; - justify-content: flex-end; - align-items: flex-start; -` - -const FooterRow = styled.div` - min-height: 2.5 * ${SMALL_FONT_SIZE}; /* ensure consistent spacing even if no date */ - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - width: 100%; -` - const TypeRow = styled.div` display: flex; flex-direction: row; @@ -209,36 +136,6 @@ const TypeRow = styled.div` min-height: 1.5em; /* ensure consistent height even if no certificate */ ` -const EllipsisTitle = styled(Dotdotdot)({ - fontWeight: "bold", - margin: 0, -}) - -const TitleButton = styled.button` - border: none; - background-color: white; - color: inherit; - display: block; - text-align: left; - padding: 0; - margin: 0; - - &:hover { - text-decoration: underline; - cursor: pointer; - } -` - -const DragHandle = styled.div` - display: flex; - align-items: center; - font-size: 40px; - align-self: stretch; - color: ${LIGHT_TEXT_COLOR}; - border-right: 1px solid ${LIGHT_TEXT_COLOR}; - margin-right: 16px; -` - const CertificateIcon = styled.img` height: 1.5em; ` @@ -255,82 +152,51 @@ const LearningResourceCardTemplate = ({ resource, imgConfig, className, - suppressImage = false, onActivate, footerActionSlot, - sortable = false, + sortable, + suppressImage = false, }: LearningResourceCardTemplateProps) => { const handleActivate = useCallback( () => onActivate?.(resource), [resource, onActivate], ) - invariant( - !sortable || variant === "row-reverse", - "sortable only supported for variant='row-reverse'", + const image = ( + + ) + const extraDetails = ( + + {getReadableResourceType(resource)} + {resource.certification && ( + + )} + ) + const body = + const footer = return ( - - {variant === "column" ? ( - - ) : null} - - {variant !== "column" ? ( - - ) : null} -
- - {getReadableResourceType(resource)} - {resource.certification && ( - - )} - - {onActivate ? ( - - - {resource.title} - - - ) : ( - - {resource.title} - - )} - {sortable ? null : ( - <> - - - -
- -
- {footerActionSlot} -
-
- - )} -
- {sortable ? ( - - - - ) : null} -
-
+ ) } diff --git a/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx b/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx index 67736815c8..8fd6a8d46c 100644 --- a/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx +++ b/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx @@ -5,7 +5,7 @@ import type { PaginatedLearningResourceTopicList, } from "api" import { allowConsoleErrors, getDescriptionFor } from "ol-test-utilities" -import { manageListDialogs } from "./ManageListDialogs" +import { manageLearningPathDialogs } from "./ManageListDialogs" import { screen, renderWithProviders, @@ -70,7 +70,7 @@ describe("manageListDialogs.upsert", () => { renderWithProviders(null, opts) act(() => { - manageListDialogs.upsert(resource) + manageLearningPathDialogs.upsert(resource) }) return { topics } @@ -216,7 +216,7 @@ describe("manageListDialogs.destroy", () => { const resource = factories.learningResources.learningPath() renderWithProviders(null) act(() => { - manageListDialogs.destroy(resource) + manageLearningPathDialogs.destroy(resource) }) return { resource } } diff --git a/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.tsx b/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.tsx index 512b28297f..09ce2512be 100644 --- a/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.tsx +++ b/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.tsx @@ -238,7 +238,7 @@ const DeleteListDialog = NiceModal.create( }, ) -const manageListDialogs = { +const manageLearningPathDialogs = { upsert: (resource?: LearningPathResource) => { const title = resource ? "Edit Learning Path" : "Create Learning Path" NiceModal.show(UpsertListDialog, { title, resource }) @@ -247,4 +247,4 @@ const manageListDialogs = { NiceModal.show(DeleteListDialog, { resource }), } -export { manageListDialogs } +export { manageLearningPathDialogs } diff --git a/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.test.tsx b/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.test.tsx new file mode 100644 index 0000000000..222898421b --- /dev/null +++ b/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.test.tsx @@ -0,0 +1,15 @@ +import React from "react" +import { render, screen } from "@testing-library/react" +import UserListCardTemplate from "./UserListCardTemplate" +import * as factories from "api/test-utils/factories" + +const userListFactory = factories.userLists + +describe("UserListCard", () => { + it("renders title and cover image", () => { + const userList = userListFactory.userList() + render() + const heading = screen.getByRole("heading", { name: userList.title }) + expect(heading).toHaveAccessibleName(userList.title) + }) +}) diff --git a/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.tsx b/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.tsx new file mode 100644 index 0000000000..015afb3c1f --- /dev/null +++ b/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.tsx @@ -0,0 +1,33 @@ +import React from "react" +import CardTemplate from "../CardTemplate/CardTemplate" +import { UserList } from "api" + +type CardVariant = "column" | "row" | "row-reverse" +type UserListCardTemplateProps = { + /** + * Whether the course picture and info display as a column or row. + */ + variant: CardVariant + userList: U + sortable?: boolean + className?: string +} + +const UserListCardTemplate = ({ + variant, + userList, + className, + sortable, +}: UserListCardTemplateProps) => { + return ( + + ) +} + +export default UserListCardTemplate +export type { UserListCardTemplateProps } diff --git a/frontends/mit-open/src/pages/LearningPathDetailsPage/LearningPathDetailsPage.test.ts b/frontends/mit-open/src/pages/LearningPathDetailsPage/LearningPathDetailsPage.test.ts index edb598b081..3fa40146d5 100644 --- a/frontends/mit-open/src/pages/LearningPathDetailsPage/LearningPathDetailsPage.test.ts +++ b/frontends/mit-open/src/pages/LearningPathDetailsPage/LearningPathDetailsPage.test.ts @@ -4,7 +4,7 @@ import type { LearningPathResource, PaginatedLearningPathRelationshipList, } from "api" -import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import ItemsListing from "./ItemsListing" import { learningPathsView } from "@/common/urls" import { @@ -141,7 +141,7 @@ describe("LearningPathDetailsPage", () => { setup({ path, userSettings: { is_learning_path_editor: true } }) const editButton = await screen.findByRole("button", { name: "Edit" }) - const editList = jest.spyOn(manageListDialogs, "upsert") + const editList = jest.spyOn(manageLearningPathDialogs, "upsert") editList.mockImplementationOnce(jest.fn()) expect(editList).not.toHaveBeenCalled() diff --git a/frontends/mit-open/src/pages/LearningPathDetailsPage/LearningPathDetailsPage.tsx b/frontends/mit-open/src/pages/LearningPathDetailsPage/LearningPathDetailsPage.tsx index 6a716db3f4..619c9e4295 100644 --- a/frontends/mit-open/src/pages/LearningPathDetailsPage/LearningPathDetailsPage.tsx +++ b/frontends/mit-open/src/pages/LearningPathDetailsPage/LearningPathDetailsPage.tsx @@ -12,7 +12,7 @@ import { import { useToggle, pluralize, MetaTags } from "ol-utilities" import { GridColumn, GridContainer } from "@/components/GridLayout/GridLayout" -import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import ItemsListing from "./ItemsListing" @@ -87,7 +87,9 @@ const LearningPathDetailsPage: React.FC = () => { diff --git a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx index c0180f714c..cc3064fcc8 100644 --- a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx +++ b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx @@ -1,7 +1,7 @@ import React from "react" import { faker } from "@faker-js/faker/locale/en" import { factories, urls } from "api/test-utils" -import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import LearningResourceCardTemplate from "@/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate" import LearningPathListingPage from "./LearningPathListingPage" import { @@ -100,7 +100,7 @@ describe("LearningPathListingPage", () => { test("Clicking edit -> Edit on opens the editing dialog", async () => { const editList = jest - .spyOn(manageListDialogs, "upsert") + .spyOn(manageLearningPathDialogs, "upsert") .mockImplementationOnce(jest.fn()) const { paths } = setup() @@ -118,7 +118,7 @@ describe("LearningPathListingPage", () => { test("Clicking edit -> Delete opens the deletion dialog", async () => { const deleteList = jest - .spyOn(manageListDialogs, "destroy") + .spyOn(manageLearningPathDialogs, "destroy") .mockImplementationOnce(jest.fn()) const { paths } = setup() @@ -138,7 +138,7 @@ describe("LearningPathListingPage", () => { test("Clicking new list opens the creation dialog", async () => { const createList = jest - .spyOn(manageListDialogs, "upsert") + .spyOn(manageLearningPathDialogs, "upsert") .mockImplementationOnce(jest.fn()) setup() const newListButton = await screen.findByRole("button", { diff --git a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx index f0d03844d6..76d0e5c497 100644 --- a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx +++ b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.tsx @@ -24,7 +24,7 @@ import { GridColumn, GridContainer } from "@/components/GridLayout/GridLayout" import LearningResourceCardTemplate from "@/page-components/LearningResourceCardTemplate/LearningResourceCardTemplate" import { imgConfigs } from "@/common/constants" -import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import CardRowList from "@/components/CardRowList/CardRowList" import * as urls from "@/common/urls" @@ -44,13 +44,13 @@ const EditListMenu: React.FC = ({ resource }) => { key: "edit", label: "Edit", icon: , - onClick: () => manageListDialogs.upsert(resource), + onClick: () => manageLearningPathDialogs.upsert(resource), }, { key: "delete", label: "Delete", icon: , - onClick: () => manageListDialogs.destroy(resource), + onClick: () => manageLearningPathDialogs.destroy(resource), }, ], [resource], @@ -96,7 +96,7 @@ const LearningPathListingPage: React.FC = () => { [navigate], ) const handleCreate = useCallback(() => { - manageListDialogs.upsert() + manageLearningPathDialogs.upsert() }, []) const canEdit = window.SETTINGS.user.is_learning_path_editor diff --git a/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.test.tsx b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.test.tsx new file mode 100644 index 0000000000..b06380f909 --- /dev/null +++ b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.test.tsx @@ -0,0 +1,73 @@ +import React from "react" +import { faker } from "@faker-js/faker/locale/en" +import { factories, urls } from "api/test-utils" +import { + screen, + renderWithProviders, + setMockResponse, + expectProps, + waitFor, +} from "../../test-utils" +import type { User } from "../../types/settings" +import UserListListingPage from "./UserListListingPage" +import UserListCardTemplate from "@/page-components/UserListCardTemplate/UserListCardTemplate" + +jest.mock( + "../../page-components/UserListCardTemplate/UserListCardTemplate", + () => { + const actual = jest.requireActual( + "../../page-components/UserListCardTemplate/UserListCardTemplate", + ) + return { + __esModule: true, + ...actual, + default: jest.fn(actual.default), + } + }, +) +const spyULCardTemplate = jest.mocked(UserListCardTemplate) + +/** + * Set up the mock API responses for lists pages. + */ +const setup = ({ + listsCount = faker.datatype.number({ min: 2, max: 5 }), + user = { is_learning_path_editor: false }, +}: { + user?: Partial + listsCount?: number +} = {}) => { + const paths = factories.userLists.userLists({ count: listsCount }) + + setMockResponse.get(urls.userLists.list(), paths) + + const { location } = renderWithProviders(, { + user, + }) + + return { paths, location } +} + +describe("UserListListingPage", () => { + it("Has title 'User Lists'", async () => { + setup() + screen.getByRole("heading", { name: "User Lists" }) + await waitFor(() => expect(document.title).toBe("User Lists")) + }) + + it("Renders a card for each user list", async () => { + const { paths } = setup() + const titles = paths.results.map((userList) => userList.title) + const headings = await screen.findAllByRole("heading", { + name: (value) => titles.includes(value), + }) + + // for sanity + expect(headings.length).toBeGreaterThan(0) + expect(titles.length).toBe(headings.length) + + paths.results.forEach((userList) => { + expectProps(spyULCardTemplate, { userList: userList }) + }) + }) +}) diff --git a/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx new file mode 100644 index 0000000000..e58ce54a6a --- /dev/null +++ b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx @@ -0,0 +1,88 @@ +import React from "react" +import { + Button, + Grid, + LoadingSpinner, + BannerPage, + Container, + styled, +} from "ol-components" + +import { MetaTags } from "ol-utilities" +import type { UserList } from "api" +import { useUserListList } from "api/hooks/learningResources" + +import { GridColumn, GridContainer } from "@/components/GridLayout/GridLayout" + +import CardRowList from "@/components/CardRowList/CardRowList" +import UserListCardTemplate from "@/page-components/UserListCardTemplate/UserListCardTemplate" + +const ListHeaderGrid = styled(Grid)` + margin-top: 1rem; + margin-bottom: 1rem; +` + +type ListCardProps = { + list: UserList + canEdit: boolean +} +const ListCard: React.FC = ({ list }) => { + return ( + + ) +} + +const UserListListingPage: React.FC = () => { + const listingQuery = useUserListList() + + return ( + + + User Lists + + + + + + +

User Lists

+
+ + + +
+
+ + {listingQuery.data && ( + + {listingQuery.data.results?.map((list) => { + return ( +
  • + +
  • + ) + })} +
    + )} +
    +
    +
    +
    +
    + ) +} + +export default UserListListingPage diff --git a/frontends/mit-open/src/routes.tsx b/frontends/mit-open/src/routes.tsx index 2b572ce694..59635181f1 100644 --- a/frontends/mit-open/src/routes.tsx +++ b/frontends/mit-open/src/routes.tsx @@ -7,6 +7,7 @@ import LearningPathDetailsPage from "@/pages/LearningPathDetailsPage/LearningPat import FieldPage from "@/pages/FieldPage/FieldPage" import EditFieldPage from "@/pages/FieldPage/EditFieldPage" +import UserListListingPage from "./pages/UserListListingPage/UserListListingPage" import ArticleDetailsPage from "@/pages/ArticleDetailsPage/ArticleDetailsPage" import { ArticleCreatePage, ArticleEditPage } from "@/pages/ArticleUpsertPages" import ProgramLetterPage from "@/pages/ProgramLetterPage/ProgramLetterPage" @@ -40,6 +41,14 @@ const routes: RouteObject[] = [ path: urls.LEARNINGPATH_VIEW, element: , }, + { + path: urls.USERLIST_LISTING, + element: ( + + + + ), + }, { path: urls.DASHBOARD, element: ( diff --git a/main/urls.py b/main/urls.py index 4ee547dd68..d0bbb69968 100644 --- a/main/urls.py +++ b/main/urls.py @@ -50,6 +50,7 @@ re_path(r"^privacy-statement/", index, name="privacy-statement"), re_path(r"^search/", index, name="site-search"), re_path(r"^learningpaths/", index, name="learningpaths"), + re_path(r"^userlists/", index, name="userlists"), re_path(r"^articles/", index, name="articles"), re_path(r"^dashboard/", index, name="dashboard"), re_path(r"^program_letter/", index, name="programletter"),