diff --git a/frontends/api/src/generated/v1/api.ts b/frontends/api/src/generated/v1/api.ts index ffb2c69453..ff61423386 100644 --- a/frontends/api/src/generated/v1/api.ts +++ b/frontends/api/src/generated/v1/api.ts @@ -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} diff --git a/frontends/api/src/test-utils/factories/percolateQueries.ts b/frontends/api/src/test-utils/factories/percolateQueries.ts index 42c66642d2..c89e37ea7b 100644 --- a/frontends/api/src/test-utils/factories/percolateQueries.ts +++ b/frontends/api/src/test-utils/factories/percolateQueries.ts @@ -9,6 +9,8 @@ const percolateQuery: Factory = (overrides = {}) => { original_query: {}, query: {}, source_type: SourceTypeEnum.SearchSubscriptionType, + source_description: "", + source_label: "", ...overrides, } return percolateQuery diff --git a/frontends/mit-open/src/common/utils.test.ts b/frontends/mit-open/src/common/utils.test.ts index fc9bec2138..a03b08387b 100644 --- a/frontends/mit-open/src/common/utils.test.ts +++ b/frontends/mit-open/src/common/utils.test.ts @@ -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"] }) - }) }) diff --git a/frontends/mit-open/src/common/utils.ts b/frontends/mit-open/src/common/utils.ts index 8f020c25d8..462ac1d428 100644 --- a/frontends/mit-open/src/common/utils.ts +++ b/frontends/mit-open/src/common/utils.ts @@ -1,9 +1,7 @@ const getSearchParamMap = (urlParams: URLSearchParams) => { const params: Record = {} 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 } diff --git a/frontends/mit-open/src/pages/DashboardPage/Dashboard.test.tsx b/frontends/mit-open/src/pages/DashboardPage/Dashboard.test.tsx index 0e6b9ff33e..acc6a70d58 100644 --- a/frontends/mit-open/src/pages/DashboardPage/Dashboard.test.tsx +++ b/frontends/mit-open/src/pages/DashboardPage/Dashboard.test.tsx @@ -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) diff --git a/frontends/mit-open/src/pages/DashboardPage/DashboardPage.tsx b/frontends/mit-open/src/pages/DashboardPage/DashboardPage.tsx index b578c594f3..6433bc8d9f 100644 --- a/frontends/mit-open/src/pages/DashboardPage/DashboardPage.tsx +++ b/frontends/mit-open/src/pages/DashboardPage/DashboardPage.tsx @@ -4,6 +4,7 @@ import { RiDashboardLine, RiBookMarkedLine, RiEditLine, + RiNotificationLine, } from "@remixicon/react" import { ButtonLink, @@ -38,6 +39,7 @@ import { } from "./carousels" import ResourceCarousel from "@/page-components/ResourceCarousel/ResourceCarousel" import UserListDetailsTab from "./UserListDetailsTab" +import { SettingsPage } from "./SettingsPage" /** * @@ -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, @@ -284,17 +287,18 @@ const UserMenuTab: React.FC = (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" } @@ -352,6 +356,12 @@ const DashboardPage: React.FC = () => { value={TabValues.PROFILE} currentValue={tabValue} /> + } + text={TabLabels[TabValues.SETTINGS]} + value={TabValues.SETTINGS} + currentValue={tabValue} + /> @@ -378,6 +388,12 @@ const DashboardPage: React.FC = () => { href={`#${TabValues.PROFILE}`} label="Profile" /> + ) @@ -480,6 +496,19 @@ const DashboardPage: React.FC = () => { )} + + Settings + {isLoadingProfile || !profile ? ( + + ) : ( +
+ +
+ )} +
diff --git a/frontends/mit-open/src/pages/DashboardPage/SettingsPage.test.tsx b/frontends/mit-open/src/pages/DashboardPage/SettingsPage.test.tsx new file mode 100644 index 0000000000..38812527f3 --- /dev/null +++ b/frontends/mit-open/src/pages/DashboardPage/SettingsPage.test.tsx @@ -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() + + 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() + + const followList = await screen.findByTestId("follow-list") + const unsubscribeButton = within(followList).getAllByText("Unfollow")[0] + await user.click(unsubscribeButton) + + expect(makeRequest).toHaveBeenCalledWith( + "delete", + unsubscribeUrl, + undefined, + ) + }) +}) diff --git a/frontends/mit-open/src/pages/DashboardPage/SettingsPage.tsx b/frontends/mit-open/src/pages/DashboardPage/SettingsPage.tsx new file mode 100644 index 0000000000..2a135a5178 --- /dev/null +++ b/frontends/mit-open/src/pages/DashboardPage/SettingsPage.tsx @@ -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 = ({ + children, + title, + subtitle, +}) => { + return ( + <_ListItemBody> + {children} + {title} + {subtitle} + + ) +} + +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 ( + <> + Following + + All topics, academic departments, and MIT units you are following. + + + {subscriptionList?.data?.map((subscriptionItem) => ( + + + { + event.preventDefault() + unsubscribe(subscriptionItem.id) + }} + > + Unfollow + + + ))} + + + ) +} + +export { SettingsPage } diff --git a/learning_resources_search/models.py b/learning_resources_search/models.py index e8ef4d0a8a..5a37efebc6 100644 --- a/learning_resources_search/models.py +++ b/learning_resources_search/models.py @@ -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() @@ -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() diff --git a/learning_resources_search/models_test.py b/learning_resources_search/models_test.py new file mode 100644 index 0000000000..70dcefb42b --- /dev/null +++ b/learning_resources_search/models_test.py @@ -0,0 +1,154 @@ +from types import SimpleNamespace + +import pytest + +from channels.factories import ChannelFactory +from learning_resources_search.connection import get_default_alias_name +from learning_resources_search.constants import ( + COURSE_TYPE, +) +from learning_resources_search.factories import PercolateQueryFactory +from learning_resources_search.indexing_api import ( + get_reindexing_alias_name, +) + +pytestmark = [pytest.mark.django_db, pytest.mark.usefixtures("mocked_es")] + + +@pytest.fixture() +def mocked_es(mocker, settings): + """ES client objects/functions mock""" + index_name = "test" + settings.OPENSEARCH_INDEX = index_name + conn = mocker.Mock() + get_conn_patch = mocker.patch( + "learning_resources_search.indexing_api.get_conn", + autospec=True, + return_value=conn, + ) + mocker.patch("learning_resources_search.connection.get_conn", autospec=True) + default_alias = get_default_alias_name(COURSE_TYPE) + reindex_alias = get_reindexing_alias_name(COURSE_TYPE) + return SimpleNamespace( + get_conn=get_conn_patch, + conn=conn, + index_name=index_name, + default_alias=default_alias, + reindex_alias=reindex_alias, + active_aliases=[default_alias, reindex_alias], + ) + + +@pytest.mark.django_db() +def test_percolate_query_unit_labels(mocker, mocked_es): + mocker.patch( + "learning_resources_search.indexing_api.index_percolators", autospec=True + ) + mocker.patch( + "learning_resources_search.indexing_api._update_document_by_id", autospec=True + ) + + ChannelFactory.create(search_filter="topic=Math", channel_type="topic") + ChannelFactory.create(search_filter="department=physics", channel_type="department") + unit_channel = ChannelFactory.create( + search_filter="offered_by=mitx", channel_type="unit" + ) + original_query = { + "free": None, + "endpoint": "learning_resource", + "offered_by": ["mitx"], + "professional": None, + "certification": None, + "yearly_decay_percent": None, + } + query = PercolateQueryFactory.create( + original_query=original_query, query=original_query + ) + assert query.original_url_params() == "offered_by=mitx" + assert query.source_label() == "unit" + assert query.source_description() == unit_channel.title + + +@pytest.mark.django_db() +def test_percolate_query_topic_labels(mocker, mocked_es): + mocker.patch( + "learning_resources_search.indexing_api.index_percolators", autospec=True + ) + mocker.patch( + "learning_resources_search.indexing_api._update_document_by_id", autospec=True + ) + + topic_channel = ChannelFactory.create( + search_filter="topic=Math", channel_type="topic" + ) + original_query = { + "free": None, + "endpoint": "learning_resource", + "topic": ["Math"], + "professional": None, + "certification": None, + "yearly_decay_percent": None, + } + query = PercolateQueryFactory.create( + original_query=original_query, query=original_query + ) + assert query.original_url_params() == "topic=Math" + assert query.source_label() == "topic" + assert query.source_description() == topic_channel.title + + +@pytest.mark.django_db() +def test_percolate_query_department_labels(mocker, mocked_es): + mocker.patch( + "learning_resources_search.indexing_api.index_percolators", autospec=True + ) + mocker.patch( + "learning_resources_search.indexing_api._update_document_by_id", autospec=True + ) + + department_channel = ChannelFactory.create( + search_filter="department=physics", channel_type="department" + ) + original_query = { + "free": None, + "department": ["physics"], + "professional": None, + "certification": None, + "yearly_decay_percent": None, + } + query = PercolateQueryFactory.create( + original_query=original_query, query=original_query + ) + assert query.original_url_params() == "department=physics" + assert query.source_label() == "department" + assert query.source_description() == department_channel.title + + +@pytest.mark.django_db() +def test_percolate_query_search_labels(mocker, mocked_es): + mocker.patch( + "learning_resources_search.indexing_api.index_percolators", autospec=True + ) + mocker.patch( + "learning_resources_search.indexing_api._update_document_by_id", autospec=True + ) + ChannelFactory.create(search_filter="topic=Math", channel_type="topic") + ChannelFactory.create(search_filter="department=physics", channel_type="department") + ChannelFactory.create(search_filter="offered_by=mitx", channel_type="unit") + original_query = { + "q": "testing search filter", + "free": None, + "department": ["physics"], + "topic": ["math"], + "professional": None, + "certification": None, + "yearly_decay_percent": None, + } + query = PercolateQueryFactory.create( + original_query=original_query, query=original_query + ) + assert ( + query.original_url_params() + == "q=testing+search+filter&department=physics&topic=math" + ) + assert query.source_label() == "saved_search" diff --git a/learning_resources_search/serializers.py b/learning_resources_search/serializers.py index 34884e3897..c7878157d6 100644 --- a/learning_resources_search/serializers.py +++ b/learning_resources_search/serializers.py @@ -544,6 +544,10 @@ class PercolateQuerySerializer(serializers.ModelSerializer): Serializer for PercolateQuery objects """ + source_description = serializers.CharField(read_only=True) + + source_label = serializers.CharField(read_only=True) + class Meta: model = PercolateQuery exclude = (*COMMON_IGNORED_FIELDS, "users") diff --git a/openapi/specs/v1.yaml b/openapi/specs/v1.yaml index b0838ebf07..4010226213 100644 --- a/openapi/specs/v1.yaml +++ b/openapi/specs/v1.yaml @@ -9413,6 +9413,12 @@ components: id: type: integer readOnly: true + source_description: + type: string + readOnly: true + source_label: + type: string + readOnly: true original_query: {} query: {} source_type: @@ -9421,6 +9427,8 @@ components: - id - original_query - query + - source_description + - source_label - source_type PercolateQuerySubscriptionRequestRequest: type: object