Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d94c3a8
adding initial method to return original url query params
shanbady Jul 26, 2024
104560a
adding subscription management page
shanbady Jul 26, 2024
7f263a9
switching name to preferences page
shanbady Jul 26, 2024
72cb080
adding preferences to dashboard page
shanbady Jul 26, 2024
ba4ab69
changing icon
shanbady Jul 29, 2024
9c93f0e
adding source types and labels and regenerating api to match
shanbady Jul 29, 2024
a21fd38
changing response for saved search
shanbady Jul 29, 2024
5542dfa
updating frontend to match mocks
shanbady Jul 29, 2024
c22f07f
style changes
shanbady Jul 29, 2024
6ea8846
fixing typechecks
shanbady Jul 29, 2024
cae76c9
removing log
shanbady Jul 29, 2024
0a3e82f
fixing typecheck
shanbady Jul 29, 2024
70a41bb
fixing tests
shanbady Jul 29, 2024
03ba1c4
styling fix for mobile and preventing nav
shanbady Jul 29, 2024
f922d49
removing bad prop
shanbady Jul 29, 2024
a9caa58
switching to settings page instead of preferences page
shanbady Jul 29, 2024
cf7de0b
adding tests for percolate match labels
shanbady Jul 30, 2024
f4be241
adding tests for percolate match labels
shanbady Jul 30, 2024
0b928b8
Update learning_resources_search/models.py
shanbady Jul 31, 2024
f2e04af
Update frontends/mit-open/src/pages/DashboardPage/SettingsPage.tsx
shanbady Jul 31, 2024
28e5879
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 31, 2024
05fcb28
updating font size
shanbady Jul 31, 2024
578fa1d
snake casing 'saved search'
shanbady Jul 31, 2024
aa045f1
switching to plainlist
shanbady Jul 31, 2024
bee5e8c
style fixes
shanbady Jul 31, 2024
5a66e85
using custom listitem element
shanbady Jul 31, 2024
1012018
removing divider param
shanbady Jul 31, 2024
eba3e8b
dropping support for comma-delim'd array params
shanbady Jul 31, 2024
23e35a8
removing broken style
shanbady Jul 31, 2024
2108735
fixing heading padding
shanbady Jul 31, 2024
b48ea13
remove defunct test
shanbady Jul 31, 2024
ed7ca31
Update frontends/mit-open/src/pages/DashboardPage/SettingsPage.tsx
shanbady Aug 2, 2024
2e57307
Update frontends/mit-open/src/pages/DashboardPage/SettingsPage.tsx
shanbady Aug 2, 2024
8860049
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 2, 2024
59a393b
simplify check for null profile
shanbady Aug 2, 2024
2992f84
adding frontend tests
shanbady Aug 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions frontends/api/src/generated/v1/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3277,6 +3277,18 @@ export interface PercolateQuery {
* @memberof PercolateQuery
*/
id: number
/**
*
* @type {string}
* @memberof PercolateQuery
*/
source_description: string
/**
*
* @type {string}
* @memberof PercolateQuery
*/
source_label: string
/**
*
* @type {any}
Expand Down
2 changes: 2 additions & 0 deletions frontends/api/src/test-utils/factories/percolateQueries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ const percolateQuery: Factory<PercolateQuery> = (overrides = {}) => {
original_query: {},
query: {},
source_type: SourceTypeEnum.SearchSubscriptionType,
source_description: "",
source_label: "",
...overrides,
}
return percolateQuery
Expand Down
7 changes: 0 additions & 7 deletions frontends/mit-open/src/common/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,4 @@ describe("getSearchParamMap", () => {
const result = getSearchParamMap(urlParams)
expect(result).toEqual({ topic: ["Leadership", "Business"] })
})

it("should handle parameters with comma-separated values", () => {
const urlParams = new URLSearchParams()
urlParams.append("topic", "Leadership,Business,Management")
const result = getSearchParamMap(urlParams)
expect(result).toEqual({ topic: ["Leadership", "Business", "Management"] })
})
})
4 changes: 1 addition & 3 deletions frontends/mit-open/src/common/utils.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
const getSearchParamMap = (urlParams: URLSearchParams) => {
const params: Record<string, string[] | string> = {}
for (const [key] of urlParams.entries()) {
const paramValues = urlParams.getAll(key)
const finalparams = paramValues.flatMap((p) => p.split(","))
params[key] = finalparams
params[key] = urlParams.getAll(key)
}
return params
}
Expand Down
6 changes: 3 additions & 3 deletions frontends/mit-open/src/pages/DashboardPage/Dashboard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,9 @@ describe("DashboardPage", () => {
const tabPanels = await screen.findAllByRole("tabpanel", { hidden: true })
// 1 for mobile, 1 for desktop
expect(tabLists).toHaveLength(2)
expect(mobileTabs).toHaveLength(3)
expect(desktopTabs).toHaveLength(3)
expect(tabPanels).toHaveLength(3)
expect(mobileTabs).toHaveLength(4)
expect(desktopTabs).toHaveLength(4)
expect(tabPanels).toHaveLength(4)
Object.values(DashboardTabLabels).forEach((label) => {
const desktopLabel = within(desktopTabList).getByText(label)
const mobileLabel = within(mobileTabList).getByText(label)
Expand Down
39 changes: 34 additions & 5 deletions frontends/mit-open/src/pages/DashboardPage/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
RiDashboardLine,
RiBookMarkedLine,
RiEditLine,
RiNotificationLine,
} from "@remixicon/react"
import {
ButtonLink,
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
} from "./carousels"
import ResourceCarousel from "@/page-components/ResourceCarousel/ResourceCarousel"
import UserListDetailsTab from "./UserListDetailsTab"
import { SettingsPage } from "./SettingsPage"

/**
*
Expand Down Expand Up @@ -206,6 +208,7 @@ const TabPanelStyled = styled(TabPanel)({

const TitleText = styled(Typography)(({ theme }) => ({
color: theme.custom.colors.black,
paddingBottom: "16px",
...theme.typography.h3,
[theme.breakpoints.down("md")]: {
...theme.typography.h5,
Expand Down Expand Up @@ -284,17 +287,18 @@ const UserMenuTab: React.FC<UserMenuTabProps> = (props) => {
enum TabValues {
HOME = "home",
MY_LISTS = "my-lists",
SETTINGS = "settings",
PROFILE = "profile",
}

const TabLabels = {
[TabValues.HOME.toString()]: "Home",
[TabValues.MY_LISTS.toString()]: "My Lists",
[TabValues.PROFILE.toString()]: "Profile",
[TabValues.HOME]: "Home",
[TabValues.MY_LISTS]: "My Lists",
[TabValues.PROFILE]: "Profile",
[TabValues.SETTINGS]: "Settings",
}

const keyFromHash = (hash: string) => {
const keys = [TabValues.HOME, TabValues.MY_LISTS, TabValues.PROFILE]
const keys = Object.values(TabValues)
const match = keys.find((key) => `#${key}` === hash)
return match ?? "home"
}
Expand Down Expand Up @@ -352,6 +356,12 @@ const DashboardPage: React.FC = () => {
value={TabValues.PROFILE}
currentValue={tabValue}
/>
<UserMenuTab
icon={<RiNotificationLine />}
text={TabLabels[TabValues.SETTINGS]}
value={TabValues.SETTINGS}
currentValue={tabValue}
/>
</TabsContainer>
</Card.Content>
</ProfileSidebar>
Expand All @@ -378,6 +388,12 @@ const DashboardPage: React.FC = () => {
href={`#${TabValues.PROFILE}`}
label="Profile"
/>
<TabButtonLink
data-testid={`mobile-tab-${TabValues.SETTINGS}`}
value={TabValues.SETTINGS}
href={`#${TabValues.SETTINGS}`}
label="Settings"
/>
</TabButtonList>
)

Expand Down Expand Up @@ -480,6 +496,19 @@ const DashboardPage: React.FC = () => {
</div>
)}
</TabPanelStyled>
<TabPanelStyled
key={TabValues.SETTINGS}
value={TabValues.SETTINGS}
>
<TitleText role="heading">Settings</TitleText>
{isLoadingProfile || !profile ? (
<Skeleton variant="text" width={128} height={32} />
) : (
<div id="user-settings">
<SettingsPage />
</div>
)}
</TabPanelStyled>
</DashboardGridItem>
</DashboardGrid>
</TabContext>
Expand Down
66 changes: 66 additions & 0 deletions frontends/mit-open/src/pages/DashboardPage/SettingsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React from "react"
import { SettingsPage } from "./SettingsPage"
import { renderWithProviders, screen, within, user } from "@/test-utils"
import { urls, setMockResponse, factories, makeRequest } from "api/test-utils"
import type { LearningResourcesUserSubscriptionApiLearningResourcesUserSubscriptionCheckListRequest as CheckSubscriptionRequest } from "api"

type SetupApisOptions = {
isAuthenticated?: boolean
isSubscribed?: boolean
subscriptionRequest?: CheckSubscriptionRequest
}
const setupApis = ({
isAuthenticated = false,
isSubscribed = false,
subscriptionRequest = {},
}: SetupApisOptions = {}) => {
setMockResponse.get(urls.userMe.get(), {
is_authenticated: isAuthenticated,
})

const subscribeResponse = isSubscribed
? factories.percolateQueries.percolateQueryList({ count: 5 }).results
: factories.percolateQueries.percolateQueryList({ count: 0 }).results
setMockResponse.get(
`${urls.userSubscription.check(subscriptionRequest)}`,
subscribeResponse,
)
const unsubscribeUrl = urls.userSubscription.delete(subscribeResponse[0]?.id)
setMockResponse.delete(unsubscribeUrl, subscribeResponse[0])
return {
unsubscribeUrl,
}
}

describe("SettingsPage", () => {
it("Renders user subscriptions in a list", async () => {
setupApis({
isAuthenticated: true,
isSubscribed: true,
subscriptionRequest: {},
})
renderWithProviders(<SettingsPage />)

const followList = await screen.findByTestId("follow-list")
expect(followList.children.length).toBe(5)
})

test("Clicking 'Unfollow' removes the subscription", async () => {
const { unsubscribeUrl } = setupApis({
isAuthenticated: true,
isSubscribed: true,
subscriptionRequest: {},
})
renderWithProviders(<SettingsPage />)

const followList = await screen.findByTestId("follow-list")
const unsubscribeButton = within(followList).getAllByText("Unfollow")[0]
Copy link
Contributor

Choose a reason for hiding this comment

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

I try to use getByRole* for most things, since it effectively adds an assertion about the role in the accessibility treat. E.g., if we know it's an element with role link, it's almost certainly tab-navigable[^1]

Suggested change
const unsubscribeButton = within(followList).getAllByText("Unfollow")[0]
const unsubscribeButton = within(followList).getAllByRole("link", { name: "Unfollow" })[0]

[^1] I say "almost certainly" because it's probably an anchor tag. If someone did <div role="link" /> (don't!), that would show up as a link in the browser's accessibility treat, but wouldn't be tab navigable.

await user.click(unsubscribeButton)

expect(makeRequest).toHaveBeenCalledWith(
"delete",
unsubscribeUrl,
undefined,
)
})
})
128 changes: 128 additions & 0 deletions frontends/mit-open/src/pages/DashboardPage/SettingsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import React from "react"
import { PlainList, Typography, Link, styled } from "ol-components"
import { useUserMe } from "api/hooks/user"
import {
useSearchSubscriptionDelete,
useSearchSubscriptionList,
} from "api/hooks/searchSubscription"

const SOURCE_LABEL_DISPLAY = {
topic: "Topic",
unit: "MIT Unit",
department: "MIT Academic Department",
saved_search: "Saved Search",
}

const FollowList = styled(PlainList)(({ theme }) => ({
borderRadius: "8px",
background: theme.custom.colors.white,
border: `1px solid ${theme.custom.colors.lightGray2}`,
}))

const StyledLink = styled(Link)(({ theme }) => ({
color: theme.custom.colors.red,
}))

const TitleText = styled(Typography)(({ theme }) => ({
marginTop: "16px",
marginBottom: "8px",

color: theme.custom.colors.darkGray2,
...theme.typography.h5,
}))

const SubTitleText = styled(Typography)(({ theme }) => ({
marginBottom: "16px",
color: theme.custom.colors.darkGray2,
...theme.typography.body2,
}))

const ListItem = styled.li(({ theme }) => [
{
padding: "16px 32px",
display: "flex",
gap: "16px",
alignItems: "center",
borderBottom: `1px solid ${theme.custom.colors.lightGray2}`,
":last-child": {
borderBottom: "none",
},
},
])
const _ListItemBody = styled.div({
display: "flex",
flexDirection: "column",
justifyContent: "center",
gap: "4px",
flex: "1 0 0",
})
const Title = styled.span(({ theme }) => ({
...theme.typography.subtitle1,
color: theme.custom.colors.darkGray2,
}))
const Subtitle = styled.span(({ theme }) => ({
...theme.typography.body2,
color: theme.custom.colors.silverGrayDark,
}))
type ListItemBodyProps = {
children?: React.ReactNode
title?: string
subtitle?: string
}
const ListItemBody: React.FC<ListItemBodyProps> = ({
children,
title,
subtitle,
}) => {
return (
<_ListItemBody>
{children}
<Title>{title}</Title>
<Subtitle>{subtitle}</Subtitle>
</_ListItemBody>
)
}

const SettingsPage: React.FC = () => {
const { data: user } = useUserMe()
const subscriptionDelete = useSearchSubscriptionDelete()
const subscriptionList = useSearchSubscriptionList({
enabled: !!user?.is_authenticated,
})

const unsubscribe = subscriptionDelete.mutate
if (!user || subscriptionList.isLoading) return null

return (
<>
<TitleText>Following</TitleText>
<SubTitleText>
All topics, academic departments, and MIT units you are following.
</SubTitleText>
<FollowList data-testid="follow-list">
{subscriptionList?.data?.map((subscriptionItem) => (
<ListItem key={subscriptionItem.id}>
<ListItemBody
title={subscriptionItem.source_description}
subtitle={
SOURCE_LABEL_DISPLAY[
subscriptionItem.source_label as keyof typeof SOURCE_LABEL_DISPLAY
]
}
/>
<StyledLink
onClick={(event) => {
event.preventDefault()
unsubscribe(subscriptionItem.id)
}}
>
Unfollow
</StyledLink>
</ListItem>
))}
</FollowList>
</>
)
}

export { SettingsPage }
29 changes: 29 additions & 0 deletions learning_resources_search/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
from urllib.parse import urlencode

from django.contrib.auth import get_user_model
from django.db import models
from django.db.models import JSONField

from channels.constants import ChannelType
from channels.models import Channel
from main.models import TimestampedModel

User = get_user_model()
Expand Down Expand Up @@ -30,3 +34,28 @@ def __str__(self):

class Meta:
unique_together = (("source_type", "original_query"),)

def original_url_params(self):
ignore_params = ["endpoint"]
query = self.original_query
defined_params = {
key: query[key] for key in query if query[key] and key not in ignore_params
}
return urlencode(defined_params, doseq=True)

def source_label(self):
original_query_params = self.original_url_params()
channels_filtered = Channel.objects.filter(search_filter=original_query_params)
if channels_filtered.exists():
return channels_filtered.first().channel_type
else:
return "saved_search"

def source_description(self):
original_query_params = self.original_url_params()
source_label = self.source_label()

if source_label in ChannelType:
channel = Channel.objects.get(search_filter=original_query_params)
return channel.title
return self.original_url_params()
Loading