Skip to content

Commit 6e502a8

Browse files
authored
add UserList modals and wire up buttons (#718)
* add hooks for creating, updating and deleting UserList * add UserList dialogs to ManageListDialogs * fix existing tests * fix UserList validationSchema * wire up UpsertUserListDialog to Create new list button on UserListListingPage * wire up edit button on UserListDetailsPage * name property on RadioChoiceField in UpsertUserListDialog should be "privacy_level" not "published" * add tests for upsertUserList * add tests for deleting a UserList * change margins on form-row * add context menu to UserListListingPage items, wire up buttons and add tests * remove unit from 0px margin * remove extraneous 0 in margin * remove unnecessary omit * correct accidental renaming of manageListDialogs * use faker.helpers.arrayElement
1 parent 24c6ba8 commit 6e502a8

File tree

15 files changed

+550
-46
lines changed

15 files changed

+550
-46
lines changed

frontends/api/src/hooks/learningResources/index.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ import type {
1919
LearningResource,
2020
LearningResourcesSearchApiLearningResourcesSearchRetrieveRequest as LRSearchRequest,
2121
UserlistsApiUserlistsListRequest as ULListRequest,
22+
UserlistsApiUserlistsCreateRequest as ULCreateRequest,
23+
UserlistsApiUserlistsDestroyRequest as ULDestroyRequest,
2224
UserlistsApiUserlistsItemsListRequest as ULItemsListRequest,
2325
OfferorsApiOfferorsListRequest,
26+
UserList,
2427
} from "../../generated/v1"
2528
import learningResources, { invalidateResourceQueries } from "./keyFactory"
2629
import { ListType } from "../../common/constants"
@@ -220,6 +223,43 @@ const useUserListsDetail = (id: number) => {
220223
return useQuery(learningResources.userlists._ctx.detail(id))
221224
}
222225

226+
const useUserListCreate = () => {
227+
const queryClient = useQueryClient()
228+
return useMutation({
229+
mutationFn: (params: ULCreateRequest["UserListRequest"]) =>
230+
userListsApi.userlistsCreate({
231+
UserListRequest: params,
232+
}),
233+
onSettled: () => {
234+
queryClient.invalidateQueries(learningResources.userlists._ctx.list._def)
235+
},
236+
})
237+
}
238+
const useUserListUpdate = () => {
239+
const queryClient = useQueryClient()
240+
return useMutation({
241+
mutationFn: (params: Pick<UserList, "id"> & Partial<UserList>) =>
242+
userListsApi.userlistsPartialUpdate({
243+
id: params.id,
244+
PatchedUserListRequest: params,
245+
}),
246+
onSettled: (_data, _err, vars) => {
247+
invalidateResourceQueries(queryClient, vars.id)
248+
},
249+
})
250+
}
251+
252+
const useUserListDestroy = () => {
253+
const queryClient = useQueryClient()
254+
return useMutation({
255+
mutationFn: (params: ULDestroyRequest) =>
256+
userListsApi.userlistsDestroy(params),
257+
onSettled: (_data, _err, vars) => {
258+
invalidateResourceQueries(queryClient, vars.id)
259+
},
260+
})
261+
}
262+
223263
const useInfiniteUserListItems = (
224264
params: ULItemsListRequest,
225265
options: Pick<UseQueryOptions, "enabled"> = {},
@@ -306,6 +346,9 @@ export {
306346
useLearningResourcesSearch,
307347
useUserListList,
308348
useUserListsDetail,
349+
useUserListCreate,
350+
useUserListUpdate,
351+
useUserListDestroy,
309352
useInfiniteUserListItems,
310353
useOfferorsList,
311354
useListItemMove,

frontends/api/src/test-utils/factories/userLists.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Factory, makePaginatedFactory } from "ol-test-utilities"
22
import {
33
MicroUserListRelationship,
44
PaginatedUserListRelationshipList,
5+
PrivacyLevelEnum,
56
UserList,
67
UserListRelationship,
78
} from "api"
@@ -12,6 +13,8 @@ const userList: Factory<UserList> = (overrides = {}) => {
1213
const list: UserList = {
1314
id: faker.helpers.unique(faker.datatype.number),
1415
title: faker.helpers.unique(faker.lorem.words),
16+
description: faker.helpers.unique(faker.lorem.paragraph),
17+
privacy_level: faker.helpers.arrayElement(Object.values(PrivacyLevelEnum)),
1518
item_count: 4,
1619
image: {},
1720
author: faker.helpers.unique(faker.datatype.number),

frontends/mit-open/src/GlobalStyles.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ const formCss = css`
6363
6464
form .form-row,
6565
.form-header .form-row {
66-
margin: 10px 10px 24px 0;
66+
margin: 10px 0 24px;
6767
}
6868
6969
.MuiDialogContent-root {

frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
within,
1212
act,
1313
} from "../../test-utils"
14-
import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs"
14+
import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs"
1515
import { waitForElementToBeRemoved } from "@testing-library/react"
1616
import {
1717
LearningPathRelationship,
@@ -163,7 +163,7 @@ describe("AddToListDialog", () => {
163163
test("Clicking 'Create a new list' opens the create list dialog", async () => {
164164
// Don't actually open the 'Create List' modal, or we'll need to mock API responses.
165165
const createList = jest
166-
.spyOn(manageLearningPathDialogs, "upsert")
166+
.spyOn(manageListDialogs, "upsertLearningPath")
167167
.mockImplementationOnce(jest.fn())
168168

169169
setup()

frontends/mit-open/src/page-components/LearningResourceCard/AddToListDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
useLearningpathRelationshipCreate,
2626
useLearningpathRelationshipDestroy,
2727
} from "api/hooks/learningResources"
28-
import { manageLearningPathDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs"
28+
import { manageListDialogs } from "@/page-components/ManageListDialogs/ManageListDialogs"
2929

3030
type AddToListDialogProps = {
3131
resourceId: number
@@ -189,7 +189,7 @@ const AddToListDialogInner: React.FC<AddToListDialogProps> = ({
189189
})}
190190
<ListItem className="add-to-list-new">
191191
<ListItemButton
192-
onClick={() => manageLearningPathDialogs.upsert()}
192+
onClick={() => manageListDialogs.upsertLearningPath()}
193193
>
194194
<AddIcon />
195195
<ListItemText primary="Create a new list" />

frontends/mit-open/src/page-components/ManageListDialogs/ManageListDialogs.test.tsx

Lines changed: 187 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { faker } from "@faker-js/faker/locale/en"
22
import { factories, urls, makeRequest } from "api/test-utils"
3-
import type {
4-
LearningPathResource,
5-
PaginatedLearningResourceTopicList,
3+
import {
4+
PrivacyLevelEnum,
5+
type LearningPathResource,
6+
type PaginatedLearningResourceTopicList,
7+
type UserList,
68
} from "api"
79
import { allowConsoleErrors, getDescriptionFor } from "ol-test-utilities"
8-
import { manageLearningPathDialogs } from "./ManageListDialogs"
10+
import { manageListDialogs } from "./ManageListDialogs"
911
import {
1012
screen,
1113
renderWithProviders,
@@ -39,6 +41,11 @@ const inputs = {
3941
const element = screen.getByLabelText(value ? "Public" : "Private")
4042
return element as HTMLInputElement
4143
},
44+
privacy_level: (value?: string) => {
45+
invariant(value !== undefined)
46+
const element = screen.getByDisplayValue(value)
47+
return element as HTMLInputElement
48+
},
4249
title: () => screen.getByLabelText("Title", { exact: false }),
4350
description: () => screen.getByLabelText("Description", { exact: false }),
4451
topics: () => screen.getByLabelText("Subjects", { exact: false }),
@@ -47,7 +54,7 @@ const inputs = {
4754
delete: () => screen.getByRole("button", { name: "Yes, delete" }),
4855
}
4956

50-
describe("manageListDialogs.upsert", () => {
57+
describe("manageListDialogs.upsertLearningPath", () => {
5158
const setup = ({
5259
resource,
5360
topics = factories.learningResources.topics({ count: 10 }),
@@ -70,7 +77,7 @@ describe("manageListDialogs.upsert", () => {
7077
renderWithProviders(null, opts)
7178

7279
act(() => {
73-
manageLearningPathDialogs.upsert(resource)
80+
manageListDialogs.upsertLearningPath(resource)
7481
})
7582

7683
return { topics }
@@ -211,12 +218,145 @@ describe("manageListDialogs.upsert", () => {
211218
})
212219
})
213220

214-
describe("manageListDialogs.destroy", () => {
221+
describe("manageListDialogs.upsertUserList", () => {
222+
const setup = ({
223+
userList,
224+
opts = {
225+
user: { is_authenticated: true },
226+
},
227+
}: {
228+
userList?: UserList
229+
opts?: Partial<TestAppOptions>
230+
} = {}) => {
231+
renderWithProviders(null, opts)
232+
233+
act(() => {
234+
manageListDialogs.upsertUserList(userList)
235+
})
236+
}
237+
238+
test.each([
239+
{
240+
userList: undefined,
241+
expectedTitle: "Create User List",
242+
},
243+
{
244+
userList: factories.userLists.userList(),
245+
expectedTitle: "Edit User List",
246+
},
247+
])(
248+
"Dialog title is $expectedTitle when userList=$userList",
249+
async ({ userList, expectedTitle }) => {
250+
setup({ userList })
251+
const dialog = screen.getByRole("heading", { name: expectedTitle })
252+
expect(dialog).toBeVisible()
253+
},
254+
)
255+
256+
test("'Cancel' closes dialog (and does not make request)", async () => {
257+
// behavior does not depend on stafflist / userlist, so just pick one
258+
setup({
259+
userList: factories.userLists.userList(),
260+
})
261+
const dialog = screen.getByRole("dialog")
262+
await user.click(inputs.cancel())
263+
expect(makeRequest).not.toHaveBeenCalledWith(
264+
"patch",
265+
expect.anything(),
266+
expect.anything(),
267+
)
268+
await waitForElementToBeRemoved(dialog)
269+
})
270+
271+
test("Validates required fields", async () => {
272+
setup()
273+
await user.click(inputs.submit())
274+
275+
const titleInput = inputs.title()
276+
const titleFeedback = getDescriptionFor(titleInput)
277+
expect(titleInput).toBeInvalid()
278+
expect(titleFeedback).toHaveTextContent("Title is required.")
279+
280+
const descriptionInput = inputs.description()
281+
const descriptionFeedback = getDescriptionFor(descriptionInput)
282+
expect(descriptionInput).toBeInvalid()
283+
expect(descriptionFeedback).toHaveTextContent("Description is required.")
284+
})
285+
286+
test("Form defaults are set", () => {
287+
setup()
288+
expect(inputs.title()).toHaveValue("")
289+
expect(inputs.description()).toHaveValue("")
290+
expect(inputs.privacy_level(PrivacyLevelEnum.Private).checked).toBe(true)
291+
expect(inputs.privacy_level(PrivacyLevelEnum.Unlisted).checked).toBe(false)
292+
})
293+
294+
test("Editing form values", async () => {
295+
const userList = factories.userLists.userList()
296+
setup({ userList: userList })
297+
const patch = {
298+
title: faker.lorem.words(),
299+
description: faker.lorem.paragraph(),
300+
privacy_level: PrivacyLevelEnum.Unlisted,
301+
}
302+
303+
// Title
304+
expect(inputs.title()).toHaveValue(userList.title)
305+
await user.click(inputs.title())
306+
await user.clear(inputs.title())
307+
await user.paste(patch.title)
308+
309+
// Description
310+
expect(inputs.description()).toHaveValue(userList.description)
311+
await user.click(inputs.description())
312+
await user.clear(inputs.description())
313+
await user.paste(patch.description)
314+
315+
// Privacy Level
316+
expect(inputs.privacy_level(PrivacyLevelEnum.Private).checked).toBe(true)
317+
expect(inputs.privacy_level(PrivacyLevelEnum.Unlisted).checked).toBe(false)
318+
await user.click(inputs.privacy_level(patch.privacy_level))
319+
320+
// Submit
321+
const patchUrl = urls.userLists.details({ id: userList.id })
322+
setMockResponse.patch(patchUrl, { ...userList, ...patch })
323+
await user.click(inputs.submit())
324+
325+
expect(makeRequest).toHaveBeenCalledWith(
326+
"patch",
327+
patchUrl,
328+
expect.objectContaining({ ...patch }),
329+
)
330+
})
331+
332+
test("Displays overall error if form validates but API call fails", async () => {
333+
allowConsoleErrors()
334+
const userList = factories.userLists.userList()
335+
await setup({ userList: userList })
336+
337+
const patchUrl = urls.userLists.details({ id: userList.id })
338+
setMockResponse.patch(patchUrl, {}, { code: 408 })
339+
await user.click(inputs.submit())
340+
341+
expect(makeRequest).toHaveBeenCalledWith(
342+
"patch",
343+
patchUrl,
344+
expect.anything(),
345+
)
346+
const alertMessage = await screen.findByRole("alert")
347+
348+
expect(alertMessage).toHaveTextContent(
349+
"There was a problem saving your list.",
350+
)
351+
})
352+
})
353+
354+
describe("manageListDialogs.destroyLearningPath", () => {
215355
const setup = () => {
216356
const resource = factories.learningResources.learningPath()
217357
renderWithProviders(null)
218358
act(() => {
219-
manageLearningPathDialogs.destroy(resource)
359+
manageListDialogs.destroyLearningPath(resource)
220360
})
221361
return { resource }
222362
}
@@ -249,3 +389,42 @@ describe("manageListDialogs.destroy", () => {
249389
await waitForElementToBeRemoved(dialog)
250390
})
251391
})
392+
393+
describe("manageListDialogs.destroyUserList", () => {
394+
const setup = () => {
395+
const userList = factories.userLists.userList()
396+
renderWithProviders(null)
397+
act(() => {
398+
manageListDialogs.destroyUserList(userList)
399+
})
400+
return { userList: userList }
401+
}
402+
403+
test("Dialog title is 'Delete list'", async () => {
404+
setup()
405+
const dialog = screen.getByRole("heading", { name: "Delete User List" })
406+
expect(dialog).toBeVisible()
407+
})
408+
409+
test("Deleting a $label calls correct API", async () => {
410+
const { userList } = setup()
411+
412+
const dialog = screen.getByRole("dialog")
413+
const url = urls.userLists.details({ id: userList.id })
414+
setMockResponse.delete(url, undefined)
415+
await user.click(inputs.delete())
416+
417+
expect(makeRequest).toHaveBeenCalledWith("delete", url, undefined)
418+
await waitForElementToBeRemoved(dialog)
419+
})
420+
421+
test("Clicking cancel does not delete list", async () => {
422+
setup()
423+
424+
const dialog = screen.getByRole("dialog")
425+
await user.click(inputs.cancel())
426+
427+
expect(makeRequest).not.toHaveBeenCalled()
428+
await waitForElementToBeRemoved(dialog)
429+
})
430+
})

0 commit comments

Comments
 (0)