diff --git a/frontends/api/src/hooks/learningResources/index.ts b/frontends/api/src/hooks/learningResources/index.ts index 7e314ac59d..e7d0e6e4e6 100644 --- a/frontends/api/src/hooks/learningResources/index.ts +++ b/frontends/api/src/hooks/learningResources/index.ts @@ -19,8 +19,11 @@ import type { LearningResource, LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest, UserlistsApiUserlistsListRequest as ULListRequest, + UserlistsApiUserlistsCreateRequest as ULCreateRequest, + UserlistsApiUserlistsDestroyRequest as ULDestroyRequest, UserlistsApiUserlistsItemsListRequest as ULItemsListRequest, OfferorsApiOfferorsListRequest, + UserList, } from "../../generated/v1" import learningResources, { invalidateResourceQueries } from "./keyFactory" import { ListType } from "../../common/constants" @@ -220,6 +223,43 @@ 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) => { + invalidateResourceQueries(queryClient, vars.id) + }, + }) +} + +const useUserListDestroy = () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: (params: ULDestroyRequest) => + userListsApi.userlistsDestroy(params), + onSettled: (_data, _err, vars) => { + invalidateResourceQueries(queryClient, vars.id) + }, + }) +} + const useInfiniteUserListItems = ( params: ULItemsListRequest, options: Pick = {}, @@ -306,6 +346,9 @@ export { useLearningResourcesSearch, useUserListList, useUserListsDetail, + useUserListCreate, + useUserListUpdate, + useUserListDestroy, useInfiniteUserListItems, useOfferorsList, useListItemMove, diff --git a/frontends/api/src/test-utils/factories/userLists.ts b/frontends/api/src/test-utils/factories/userLists.ts index d2700df804..57b7c06801 100644 --- a/frontends/api/src/test-utils/factories/userLists.ts +++ b/frontends/api/src/test-utils/factories/userLists.ts @@ -2,6 +2,7 @@ import { Factory, makePaginatedFactory } from "ol-test-utilities" import { MicroUserListRelationship, PaginatedUserListRelationshipList, + PrivacyLevelEnum, UserList, UserListRelationship, } from "api" @@ -12,6 +13,8 @@ const userList: Factory = (overrides = {}) => { const list: UserList = { id: faker.helpers.unique(faker.datatype.number), title: faker.helpers.unique(faker.lorem.words), + description: faker.helpers.unique(faker.lorem.paragraph), + privacy_level: faker.helpers.arrayElement(Object.values(PrivacyLevelEnum)), item_count: 4, image: {}, author: faker.helpers.unique(faker.datatype.number), diff --git a/frontends/mit-open/src/GlobalStyles.tsx b/frontends/mit-open/src/GlobalStyles.tsx index a651a0c03d..70fa69471a 100644 --- a/frontends/mit-open/src/GlobalStyles.tsx +++ b/frontends/mit-open/src/GlobalStyles.tsx @@ -63,7 +63,7 @@ const formCss = css` form .form-row, .form-header .form-row { - margin: 10px 10px 24px 0; + margin: 10px 0 24px; } .MuiDialogContent-root { 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 1cd2240cf9..d50eab2b79 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 { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageListDialogs } 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(manageLearningPathDialogs, "upsert") + .spyOn(manageListDialogs, "upsertLearningPath") .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 7555f0d101..750dceb9c9 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 { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" type AddToListDialogProps = { resourceId: number @@ -189,7 +189,7 @@ const AddToListDialogInner: React.FC = ({ })} manageLearningPathDialogs.upsert()} + onClick={() => manageListDialogs.upsertLearningPath()} > 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 8fd6a8d46c..47eaccf302 100644 --- a/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx +++ b/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx @@ -1,11 +1,13 @@ import { faker } from "@faker-js/faker/locale/en" import { factories, urls, makeRequest } from "api/test-utils" -import type { - LearningPathResource, - PaginatedLearningResourceTopicList, +import { + PrivacyLevelEnum, + type LearningPathResource, + type PaginatedLearningResourceTopicList, + type UserList, } from "api" import { allowConsoleErrors, getDescriptionFor } from "ol-test-utilities" -import { manageLearningPathDialogs } from "./ManageListDialogs" +import { manageListDialogs } from "./ManageListDialogs" import { screen, renderWithProviders, @@ -39,6 +41,11 @@ const inputs = { const element = screen.getByLabelText(value ? "Public" : "Private") return element as HTMLInputElement }, + privacy_level: (value?: string) => { + invariant(value !== undefined) + const element = screen.getByDisplayValue(value) + return element as HTMLInputElement + }, title: () => screen.getByLabelText("Title", { exact: false }), description: () => screen.getByLabelText("Description", { exact: false }), topics: () => screen.getByLabelText("Subjects", { exact: false }), @@ -47,7 +54,7 @@ const inputs = { delete: () => screen.getByRole("button", { name: "Yes, delete" }), } -describe("manageListDialogs.upsert", () => { +describe("manageListDialogs.upsertLearningPath", () => { const setup = ({ resource, topics = factories.learningResources.topics({ count: 10 }), @@ -70,7 +77,7 @@ describe("manageListDialogs.upsert", () => { renderWithProviders(null, opts) act(() => { - manageLearningPathDialogs.upsert(resource) + manageListDialogs.upsertLearningPath(resource) }) return { topics } @@ -211,12 +218,145 @@ describe("manageListDialogs.upsert", () => { }) }) -describe("manageListDialogs.destroy", () => { +describe("manageListDialogs.upsertUserList", () => { + const setup = ({ + userList, + opts = { + user: { is_authenticated: true }, + }, + }: { + userList?: UserList + opts?: Partial + } = {}) => { + renderWithProviders(null, opts) + + act(() => { + manageListDialogs.upsertUserList(userList) + }) + } + + test.each([ + { + userList: undefined, + expectedTitle: "Create User List", + }, + { + userList: factories.userLists.userList(), + expectedTitle: "Edit User List", + }, + ])( + "Dialog title is $expectedTitle when userList=$userList", + async ({ userList, expectedTitle }) => { + setup({ userList }) + const dialog = screen.getByRole("heading", { name: expectedTitle }) + expect(dialog).toBeVisible() + }, + ) + + test("'Cancel' closes dialog (and does not make request)", async () => { + // behavior does not depend on stafflist / userlist, so just pick one + setup({ + userList: factories.userLists.userList(), + }) + const dialog = screen.getByRole("dialog") + await user.click(inputs.cancel()) + expect(makeRequest).not.toHaveBeenCalledWith( + "patch", + expect.anything(), + expect.anything(), + ) + await waitForElementToBeRemoved(dialog) + }) + + test("Validates required fields", async () => { + setup() + await user.click(inputs.submit()) + + const titleInput = inputs.title() + const titleFeedback = getDescriptionFor(titleInput) + expect(titleInput).toBeInvalid() + expect(titleFeedback).toHaveTextContent("Title is required.") + + const descriptionInput = inputs.description() + const descriptionFeedback = getDescriptionFor(descriptionInput) + expect(descriptionInput).toBeInvalid() + expect(descriptionFeedback).toHaveTextContent("Description is required.") + }) + + test("Form defaults are set", () => { + setup() + expect(inputs.title()).toHaveValue("") + expect(inputs.description()).toHaveValue("") + expect(inputs.privacy_level(PrivacyLevelEnum.Private).checked).toBe(true) + expect(inputs.privacy_level(PrivacyLevelEnum.Unlisted).checked).toBe(false) + }) + + test("Editing form values", async () => { + const userList = factories.userLists.userList() + setup({ userList: userList }) + const patch = { + title: faker.lorem.words(), + description: faker.lorem.paragraph(), + privacy_level: PrivacyLevelEnum.Unlisted, + } + + // Title + expect(inputs.title()).toHaveValue(userList.title) + await user.click(inputs.title()) + await user.clear(inputs.title()) + await user.paste(patch.title) + + // Description + expect(inputs.description()).toHaveValue(userList.description) + await user.click(inputs.description()) + await user.clear(inputs.description()) + await user.paste(patch.description) + + // Privacy Level + expect(inputs.privacy_level(PrivacyLevelEnum.Private).checked).toBe(true) + expect(inputs.privacy_level(PrivacyLevelEnum.Unlisted).checked).toBe(false) + await user.click(inputs.privacy_level(patch.privacy_level)) + + // Submit + const patchUrl = urls.userLists.details({ id: userList.id }) + setMockResponse.patch(patchUrl, { ...userList, ...patch }) + await user.click(inputs.submit()) + + expect(makeRequest).toHaveBeenCalledWith( + "patch", + patchUrl, + expect.objectContaining({ ...patch }), + ) + }) + + test("Displays overall error if form validates but API call fails", async () => { + allowConsoleErrors() + const userList = factories.userLists.userList() + await setup({ userList: userList }) + + const patchUrl = urls.userLists.details({ id: userList.id }) + setMockResponse.patch(patchUrl, {}, { code: 408 }) + await user.click(inputs.submit()) + + expect(makeRequest).toHaveBeenCalledWith( + "patch", + patchUrl, + expect.anything(), + ) + const alertMessage = await screen.findByRole("alert") + + expect(alertMessage).toHaveTextContent( + "There was a problem saving your list.", + ) + }) +}) + +describe("manageListDialogs.destroyLearningPath", () => { const setup = () => { const resource = factories.learningResources.learningPath() renderWithProviders(null) act(() => { - manageLearningPathDialogs.destroy(resource) + manageListDialogs.destroyLearningPath(resource) }) return { resource } } @@ -249,3 +389,42 @@ describe("manageListDialogs.destroy", () => { await waitForElementToBeRemoved(dialog) }) }) + +describe("manageListDialogs.destroyUserList", () => { + const setup = () => { + const userList = factories.userLists.userList() + renderWithProviders(null) + act(() => { + manageListDialogs.destroyUserList(userList) + }) + return { userList: userList } + } + + test("Dialog title is 'Delete list'", async () => { + setup() + const dialog = screen.getByRole("heading", { name: "Delete User List" }) + expect(dialog).toBeVisible() + }) + + test("Deleting a $label calls correct API", async () => { + const { userList } = setup() + + const dialog = screen.getByRole("dialog") + const url = urls.userLists.details({ id: userList.id }) + setMockResponse.delete(url, undefined) + await user.click(inputs.delete()) + + expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined) + await waitForElementToBeRemoved(dialog) + }) + + test("Clicking cancel does not delete list", async () => { + setup() + + const dialog = screen.getByRole("dialog") + await user.click(inputs.cancel()) + + expect(makeRequest).not.toHaveBeenCalled() + await waitForElementToBeRemoved(dialog) + }) +}) diff --git a/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.tsx b/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.tsx index 09ce2512be..1ca6cac8e7 100644 --- a/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.tsx +++ b/frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.tsx @@ -9,15 +9,19 @@ import { FormDialog, BasicDialog, styled, + RadioChoiceField, } from "ol-components" import * as Yup from "yup" -import type { LearningPathResource } from "api" +import { PrivacyLevelEnum, type LearningPathResource, UserList } from "api" import { useLearningpathCreate, useLearningpathUpdate, useLearningpathDestroy, useLearningResourceTopics, + useUserListCreate, + useUserListUpdate, + useUserListDestroy, } from "api/hooks/learningResources" /* @@ -66,7 +70,16 @@ const learningPathFormSchema = Yup.object().shape({ .required(), }) -const PRIVACY_CHOICES = [ +const userListFormSchema = Yup.object().shape({ + privacy_level: Yup.string() + .oneOf(Object.values(PrivacyLevelEnum)) + .default(PrivacyLevelEnum.Private) + .required("Privacy Level is required"), + title: Yup.string().default("").required("Title is required."), + description: Yup.string().default("").required("Description is required."), +}) + +const LEARNING_PATH_PRIVACY_CHOICES = [ { value: false, label: "Private", @@ -79,17 +92,31 @@ const PRIVACY_CHOICES = [ }, ] -type FormValues = Yup.InferType +const USER_LIST_PRIVACY_CHOICES = [ + { + value: PrivacyLevelEnum.Private, + label: "Private", + className: "radio-option", + }, + { + value: PrivacyLevelEnum.Unlisted, + label: "Unlisted", + className: "radio-option", + }, +] + +type LearningPathFormValues = Yup.InferType +type UserListFormValues = Yup.InferType const variantProps = { InputLabelProps: { shrink: true } } -interface UpsertListDialogProps { +interface UpsertLearningPathDialogProps { title: string resource?: LearningPathResource | null } -const UpsertListDialog = NiceModal.create( - ({ resource, title }: UpsertListDialogProps) => { +const UpsertLearningPathDialog = NiceModal.create( + ({ resource, title }: UpsertLearningPathDialogProps) => { const modal = NiceModal.useModal() const topicsQuery = useLearningResourceTopics(undefined, { enabled: modal.visible, @@ -98,7 +125,7 @@ const UpsertListDialog = NiceModal.create( const updateList = useLearningpathUpdate() const mutation = resource?.id ? updateList : createList const handleSubmit: FormikConfig< - LearningPathResource | FormValues + LearningPathResource | LearningPathFormValues >["onSubmit"] = useCallback( async (values) => { if (resource?.id) { @@ -114,7 +141,8 @@ const UpsertListDialog = NiceModal.create( const formik = useFormik({ enableReinitialize: true, initialValues: - resource ?? (learningPathFormSchema.getDefault() as FormValues), + resource ?? + (learningPathFormSchema.getDefault() as LearningPathFormValues), validationSchema: learningPathFormSchema, onSubmit: handleSubmit, validateOnChange: false, @@ -199,7 +227,7 @@ const UpsertListDialog = NiceModal.create( className="form-row" name="published" label="Privacy" - choices={PRIVACY_CHOICES} + choices={LEARNING_PATH_PRIVACY_CHOICES} value={formik.values.published} row onChange={(e) => formik.setFieldValue(e.name, e.value)} @@ -209,12 +237,107 @@ const UpsertListDialog = NiceModal.create( }, ) -type DeleteListDialogProps = { +interface UpsertUserListDialogProps { + title: string + userList?: UserList | null +} + +const UpsertUserListDialog = NiceModal.create( + ({ userList, title }: UpsertUserListDialogProps) => { + const modal = NiceModal.useModal() + const createList = useUserListCreate() + const updateList = useUserListUpdate() + const mutation = userList?.id ? updateList : createList + const handleSubmit: FormikConfig< + UserList | UserListFormValues + >["onSubmit"] = useCallback( + async (values) => { + if (userList?.id) { + await updateList.mutateAsync({ ...values, id: userList.id }) + } else { + await createList.mutateAsync(values) + } + modal.hide() + }, + [userList, createList, updateList, modal], + ) + + const formik = useFormik({ + enableReinitialize: true, + initialValues: + userList ?? (userListFormSchema.getDefault() as UserListFormValues), + validationSchema: userListFormSchema, + onSubmit: handleSubmit, + validateOnChange: false, + validateOnBlur: false, + }) + + return ( + + There was a problem saving your list. Please try again later. + + ) + } + > + + + formik.setFieldValue(e.target.name, e.target.value)} + /> + + ) + }, +) + +type DeleteLearningPathDialogProps = { resource: LearningPathResource } -const DeleteListDialog = NiceModal.create( - ({ resource }: DeleteListDialogProps) => { +const DeleteLearningPathDialog = NiceModal.create( + ({ resource }: DeleteLearningPathDialogProps) => { const modal = NiceModal.useModal() const hideModal = modal.hide const destroyList = useLearningpathDestroy() @@ -238,13 +361,48 @@ const DeleteListDialog = NiceModal.create( }, ) -const manageLearningPathDialogs = { - upsert: (resource?: LearningPathResource) => { +type DeleteUserListDialogProps = { + userList: UserList +} + +const DeleteUserListDialog = NiceModal.create( + ({ userList }: DeleteUserListDialogProps) => { + const modal = NiceModal.useModal() + const hideModal = modal.hide + const destroyList = useUserListDestroy() + + const handleConfirm = useCallback(async () => { + await destroyList.mutateAsync({ + id: userList.id, + }) + hideModal() + }, [destroyList, hideModal, userList]) + return ( + + Are you sure you want to delete this list? + + ) + }, +) + +const manageListDialogs = { + upsertLearningPath: (resource?: LearningPathResource) => { const title = resource ? "Edit Learning Path" : "Create Learning Path" - NiceModal.show(UpsertListDialog, { title, resource }) + NiceModal.show(UpsertLearningPathDialog, { title, resource }) + }, + destroyLearningPath: (resource: LearningPathResource) => + NiceModal.show(DeleteLearningPathDialog, { resource }), + upsertUserList: (userList?: UserList) => { + const title = userList ? "Edit User List" : "Create User List" + NiceModal.show(UpsertUserListDialog, { title, userList }) }, - destroy: (resource: LearningPathResource) => - NiceModal.show(DeleteListDialog, { resource }), + destroyUserList: (userList: UserList) => + NiceModal.show(DeleteUserListDialog, { userList }), } -export { manageLearningPathDialogs } +export { manageListDialogs } diff --git a/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.tsx b/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.tsx index d7393af1f3..4e6c2cdf10 100644 --- a/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.tsx +++ b/frontends/mit-open/src/page-components/UserListCardTemplate/UserListCardTemplate.tsx @@ -16,6 +16,7 @@ type UserListCardTemplateProps = { className?: string imgConfig: EmbedlyConfig onActivate?: OnActivateCard + footerActionSlot?: React.ReactNode } const UserListCardTemplate = ({ @@ -25,6 +26,7 @@ const UserListCardTemplate = ({ imgConfig, sortable, onActivate, + footerActionSlot, }: UserListCardTemplateProps) => { const handleActivate = useCallback( () => onActivate?.(userList), @@ -50,6 +52,7 @@ const UserListCardTemplate = ({ {userList.item_count} {pluralize("item", userList.item_count)} } + footerActionSlot={footerActionSlot} > ) } diff --git a/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx b/frontends/mit-open/src/pages/LearningPathListingPage/LearningPathListingPage.test.tsx index cc3064fcc8..30eee28c74 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 { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageListDialogs } 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(manageLearningPathDialogs, "upsert") + .spyOn(manageListDialogs, "upsertLearningPath") .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(manageLearningPathDialogs, "destroy") + .spyOn(manageListDialogs, "destroyLearningPath") .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(manageLearningPathDialogs, "upsert") + .spyOn(manageListDialogs, "upsertLearningPath") .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 76d0e5c497..ee5c1774bc 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 { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageListDialogs } 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: () => manageLearningPathDialogs.upsert(resource), + onClick: () => manageListDialogs.upsertLearningPath(resource), }, { key: "delete", label: "Delete", icon: , - onClick: () => manageLearningPathDialogs.destroy(resource), + onClick: () => manageListDialogs.destroyLearningPath(resource), }, ], [resource], @@ -96,7 +96,7 @@ const LearningPathListingPage: React.FC = () => { [navigate], ) const handleCreate = useCallback(() => { - manageLearningPathDialogs.upsert() + manageListDialogs.upsertLearningPath() }, []) const canEdit = window.SETTINGS.user.is_learning_path_editor diff --git a/frontends/mit-open/src/pages/ListDetailsPage/LearningPathDetailsPage.tsx b/frontends/mit-open/src/pages/ListDetailsPage/LearningPathDetailsPage.tsx index 2c2ae567cf..dda7eb657a 100644 --- a/frontends/mit-open/src/pages/ListDetailsPage/LearningPathDetailsPage.tsx +++ b/frontends/mit-open/src/pages/ListDetailsPage/LearningPathDetailsPage.tsx @@ -8,7 +8,7 @@ import { } from "api/hooks/learningResources" import ListDetailsPage from "./ListDetailsPage" -import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import { ListType } from "api/constants" type RouteParams = { @@ -32,7 +32,7 @@ const LearningPathDetailsPage: React.FC = () => { items={items} isLoading={itemsQuery.isLoading} isFetching={itemsQuery.isFetching} - handleEdit={() => manageLearningPathDialogs.upsert(pathQuery.data)} + handleEdit={() => manageListDialogs.upsertLearningPath(pathQuery.data)} /> ) } diff --git a/frontends/mit-open/src/pages/ListDetailsPage/ListDetailsPage.test.ts b/frontends/mit-open/src/pages/ListDetailsPage/ListDetailsPage.test.ts index 6c5512de03..d16c7ffc73 100644 --- a/frontends/mit-open/src/pages/ListDetailsPage/ListDetailsPage.test.ts +++ b/frontends/mit-open/src/pages/ListDetailsPage/ListDetailsPage.test.ts @@ -4,7 +4,7 @@ import type { LearningPathResource, PaginatedLearningPathRelationshipList, } from "api" -import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" +import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" import ItemsListing from "./ItemsListing" import { learningPathsView } from "@/common/urls" import { @@ -141,7 +141,7 @@ describe("ListDetailsPage", () => { setup({ path, userSettings: { is_learning_path_editor: true } }) const editButton = await screen.findByRole("button", { name: "Edit" }) - const editList = jest.spyOn(manageLearningPathDialogs, "upsert") + const editList = jest.spyOn(manageListDialogs, "upsertLearningPath") editList.mockImplementationOnce(jest.fn()) expect(editList).not.toHaveBeenCalled() diff --git a/frontends/mit-open/src/pages/ListDetailsPage/UserListDetailsPage.tsx b/frontends/mit-open/src/pages/ListDetailsPage/UserListDetailsPage.tsx index ae4f08607a..745fc935b5 100644 --- a/frontends/mit-open/src/pages/ListDetailsPage/UserListDetailsPage.tsx +++ b/frontends/mit-open/src/pages/ListDetailsPage/UserListDetailsPage.tsx @@ -9,6 +9,7 @@ import { import { ListType } from "api/constants" import ListDetailsPage from "./ListDetailsPage" +import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" type RouteParams = { id: string @@ -31,7 +32,7 @@ const UserListDetailsPage: React.FC = () => { items={items} isLoading={itemsQuery.isLoading} isFetching={itemsQuery.isFetching} - handleEdit={() => {}} + handleEdit={() => manageListDialogs.upsertUserList(pathQuery.data)} /> ) } diff --git a/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.test.tsx b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.test.tsx index b06380f909..de6b68b2b5 100644 --- a/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.test.tsx +++ b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.test.tsx @@ -5,12 +5,14 @@ import { screen, renderWithProviders, setMockResponse, + user, expectProps, waitFor, } from "../../test-utils" import type { User } from "../../types/settings" import UserListListingPage from "./UserListListingPage" import UserListCardTemplate from "@/page-components/UserListCardTemplate/UserListCardTemplate" +import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" jest.mock( "../../page-components/UserListCardTemplate/UserListCardTemplate", @@ -70,4 +72,72 @@ describe("UserListListingPage", () => { expectProps(spyULCardTemplate, { userList: userList }) }) }) + + test("Clicking edit -> Edit on opens the editing dialog", async () => { + const editList = jest + .spyOn(manageListDialogs, "upsertUserList") + .mockImplementationOnce(jest.fn()) + + const { paths } = setup() + const path = faker.helpers.arrayElement(paths.results) + + const menuButton = await screen.findByRole("button", { + name: `Edit list ${path.title}`, + }) + await user.click(menuButton) + const editButton = screen.getByRole("menuitem", { name: "Edit" }) + await user.click(editButton) + + expect(editList).toHaveBeenCalledWith(path) + }) + + test("Clicking edit -> Delete opens the deletion dialog", async () => { + const deleteList = jest + .spyOn(manageListDialogs, "destroyUserList") + .mockImplementationOnce(jest.fn()) + + const { paths } = setup() + const path = faker.helpers.arrayElement(paths.results) + + const menuButton = await screen.findByRole("button", { + name: `Edit list ${path.title}`, + }) + await user.click(menuButton) + const deleteButton = screen.getByRole("menuitem", { name: "Delete" }) + + await user.click(deleteButton) + + // Check details of this dialog elsewhere + expect(deleteList).toHaveBeenCalledWith(path) + }) + + test("Clicking new list opens the creation dialog", async () => { + const createList = jest + .spyOn(manageListDialogs, "upsertUserList") + .mockImplementationOnce(jest.fn()) + setup() + const newListButton = await screen.findByRole("button", { + name: "Create new list", + }) + + expect(createList).not.toHaveBeenCalled() + await user.click(newListButton) + + // Check details of this dialog elsewhere + expect(createList).toHaveBeenCalledWith() + }) + + test("Clicking on list title navigates to list page", async () => { + const { location, paths } = setup() + const path = faker.helpers.arrayElement(paths.results) + const listTitle = await screen.findByRole("heading", { name: path.title }) + await user.click(listTitle) + expect(location.current).toEqual( + expect.objectContaining({ + pathname: `/userlists/${path.id}`, + search: "", + hash: "", + }), + ) + }) }) diff --git a/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx index aa1782e359..4bcd4b68c7 100644 --- a/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx +++ b/frontends/mit-open/src/pages/UserListListingPage/UserListListingPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react" +import React, { useCallback, useMemo } from "react" import { Button, Grid, @@ -6,7 +6,13 @@ import { BannerPage, Container, styled, + SimpleMenuItem, + SimpleMenu, + IconButton, } from "ol-components" +import EditIcon from "@mui/icons-material/Edit" +import MoreVertIcon from "@mui/icons-material/MoreVert" +import DeleteIcon from "@mui/icons-material/Delete" import { MetaTags } from "ol-utilities" import type { UserList } from "api" @@ -19,12 +25,47 @@ import UserListCardTemplate from "@/page-components/UserListCardTemplate/UserLis import { useNavigate } from "react-router" import * as urls from "@/common/urls" import { imgConfigs } from "@/common/constants" +import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs" const ListHeaderGrid = styled(Grid)` margin-top: 1rem; margin-bottom: 1rem; ` +type EditUserListMenuProps = { + userList: UserList +} + +const EditUserListMenu: React.FC = ({ userList }) => { + const items: SimpleMenuItem[] = useMemo( + () => [ + { + key: "edit", + label: "Edit", + icon: , + onClick: () => manageListDialogs.upsertUserList(userList), + }, + { + key: "delete", + label: "Delete", + icon: , + onClick: () => manageListDialogs.destroyUserList(userList), + }, + ], + [userList], + ) + return ( + + + + } + items={items} + /> + ) +} + type ListCardProps = { list: UserList onActivate: (userList: UserList) => void @@ -38,6 +79,7 @@ const ListCard: React.FC = ({ list, onActivate }) => { className="ic-resource-card" imgConfig={imgConfigs["row-reverse-small"]} onActivate={onActivate} + footerActionSlot={} /> ) } @@ -53,6 +95,9 @@ const UserListListingPage: React.FC = () => { }, [navigate], ) + const handleCreate = useCallback(() => { + manageListDialogs.upsertUserList() + }, []) return ( { alignItems="center" display="flex" > - +