diff --git a/frontends/api/src/hooks/learningResources/index.ts b/frontends/api/src/hooks/learningResources/index.ts index 4519d09720..5f6cd22ac4 100644 --- a/frontends/api/src/hooks/learningResources/index.ts +++ b/frontends/api/src/hooks/learningResources/index.ts @@ -62,6 +62,16 @@ const useFeaturedLearningResourcesList = (params: FeaturedListParams = {}) => { return useQuery(learningResources.featured(params)) } +const useLearningResourceTopic = ( + id: number, + opts: Pick = {}, +) => { + return useQuery({ + ...learningResources.topic(id), + ...opts, + }) +} + const useLearningResourceTopics = ( params: TopicsListRequest = {}, opts: Pick = {}, @@ -486,6 +496,7 @@ export { useLearningResourcesList, useFeaturedLearningResourcesList, useLearningResourcesDetail, + useLearningResourceTopic, useLearningResourceTopics, useLearningPathsList, useLearningPathsDetail, diff --git a/frontends/api/src/hooks/learningResources/keyFactory.ts b/frontends/api/src/hooks/learningResources/keyFactory.ts index 4732b3909a..fd84a8b093 100644 --- a/frontends/api/src/hooks/learningResources/keyFactory.ts +++ b/frontends/api/src/hooks/learningResources/keyFactory.ts @@ -86,6 +86,11 @@ const learningResources = createQueryKeys("learningResources", { }) }, }), + topic: (id: number | undefined) => ({ + queryKey: [id], + queryFn: () => + id ? topicsApi.topicsRetrieve({ id }).then((res) => res.data) : null, + }), topics: (params: TopicsListRequest) => ({ queryKey: [params], queryFn: () => topicsApi.topicsList(params).then((res) => res.data), diff --git a/frontends/api/src/test-utils/urls.ts b/frontends/api/src/test-utils/urls.ts index 4b93e5ada2..f440423d7f 100644 --- a/frontends/api/src/test-utils/urls.ts +++ b/frontends/api/src/test-utils/urls.ts @@ -37,7 +37,11 @@ const query = (params: any) => { const queryString = new URLSearchParams() for (const [key, value] of Object.entries(params)) { if (Array.isArray(value)) { - value.forEach((v) => queryString.append(key, String(v))) + if (value.length === 0) { + queryString.append(key, "") + } else { + value.forEach((v) => queryString.append(key, String(v))) + } } else { queryString.append(key, String(value)) } @@ -96,6 +100,7 @@ const platforms = { } const topics = { + get: (id: number) => `${API_BASE_URL}/api/v1/topics/${id}/`, list: (params?: Params) => `${API_BASE_URL}/api/v1/topics/${query(params)}`, } diff --git a/frontends/mit-learn/public/images/background_steps.jpeg b/frontends/mit-learn/public/images/background_steps.jpeg deleted file mode 100644 index 0f27b9bb62..0000000000 Binary files a/frontends/mit-learn/public/images/background_steps.jpeg and /dev/null differ diff --git a/frontends/mit-learn/public/images/background_steps.jpg b/frontends/mit-learn/public/images/background_steps.jpg new file mode 100644 index 0000000000..7132c197d3 Binary files /dev/null and b/frontends/mit-learn/public/images/background_steps.jpg differ diff --git a/frontends/mit-learn/src/pages/ChannelPage/ChannelPage.test.tsx b/frontends/mit-learn/src/pages/ChannelPage/ChannelPage.test.tsx index 39643f66ce..615f64f3a7 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/ChannelPage.test.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/ChannelPage.test.tsx @@ -105,23 +105,51 @@ const setupApis = ( results: [], }) - if ( - channel.channel_type === ChannelTypeEnum.Topic && - channel.topic_detail.topic - ) { - const subTopics = factories.learningResources.topics({ count: 5 }) - setMockResponse.get( - urls.topics.list({ parent_topic_id: [channel.topic_detail.topic] }), - subTopics, - ) - return { - channel, - subTopics, - } + return { + channel, } +} +const setupTopicApis = (channel: Channel) => { + invariant( + channel.channel_type === ChannelTypeEnum.Topic, + "Topic channel must have a topic", + ) + const topic = factories.learningResources.topic() + channel.channel_url = `/c/${channel.channel_type}/${channel.name.replace(/\s/g, "-")}` + topic.channel_url = channel.channel_url + topic.id = channel.topic_detail.topic ?? 0 + const subTopics = factories.learningResources.topics({ count: 5 }) + setMockResponse.get(urls.topics.get(topic.id), topic) + setMockResponse.get( + urls.topics.list({ parent_topic_id: [topic.id] }), + subTopics, + ) + const subTopicChannels = subTopics.results.map((subTopic) => { + subTopic.parent = topic.id + const subTopicChannel = factories.channels.channel({ + channel_type: ChannelTypeEnum.Topic, + name: subTopic.name.replace(/\s/g, "-"), + title: subTopic.name, + topic_detail: { topic: subTopic.id }, + }) + const channelUrl = `/c/${subTopicChannel.channel_type}/${subTopicChannel.name.replace(/\s/g, "-")}` + subTopic.channel_url = channelUrl + subTopicChannel.channel_url = channelUrl + setMockResponse.get(urls.topics.get(subTopic.id), subTopic) + setMockResponse.get( + urls.channels.details( + subTopicChannel.channel_type, + subTopicChannel.name.replace(/\s/g, "-"), + ), + subTopicChannel, + ) + return subTopicChannel + }) return { - channel, + topic, + subTopics, + subTopicChannels, } } @@ -141,6 +169,9 @@ describe.each(ALL_CHANNEL_TYPES)( "platform=ocw&platform=mitxonline&department=8&department=9", channel_type: channelType, }) + if (channelType === ChannelTypeEnum.Topic) { + setupTopicApis(channel) + } renderTestApp({ url: `/c/${channel.channel_type}/${channel.name}` }) await screen.findAllByText(channel.title) const expectedProps = expect.objectContaining({ @@ -155,23 +186,29 @@ describe.each(ALL_CHANNEL_TYPES)( expectedProps, expectedContext, ) - }) + }, 10000) it("Does not display the channel search if search_filter is undefined", async () => { const { channel } = setupApis({ channel_type: channelType, }) channel.search_filter = undefined + if (channelType === ChannelTypeEnum.Topic) { + setupTopicApis(channel) + } renderTestApp({ url: `/c/${channel.channel_type}/${channel.name}` }) await screen.findAllByText(channel.title) expect(mockedChannelSearch).toHaveBeenCalledTimes(0) - }) + }, 10000) it("Includes heading and subheading in banner", async () => { const { channel } = setupApis({ channel_type: channelType, }) channel.search_filter = undefined + if (channelType === ChannelTypeEnum.Topic) { + setupTopicApis(channel) + } renderTestApp({ url: `/c/${channel.channel_type}/${channel.name}` }) await screen.findAllByText(channel.title) @@ -185,7 +222,7 @@ describe.each(ALL_CHANNEL_TYPES)( expect(el).toBeInTheDocument() }) }) - }) + }, 10000) it.each([{ isSubscribed: false }, { isSubscribed: true }])( "Displays the subscribe toggle for authenticated and unauthenticated users", @@ -195,10 +232,14 @@ describe.each(ALL_CHANNEL_TYPES)( {}, { isSubscribed }, ) + if (channelType === ChannelTypeEnum.Topic) { + setupTopicApis(channel) + } renderTestApp({ url: `/c/${channel.channel_type}/${channel.name}` }) - const subscribedButton = await screen.findByText("Follow") - expect(subscribedButton).toBeVisible() + const subscribedButton = await screen.findAllByText("Follow") + expect(subscribedButton[0]).toBeVisible() }, + 10000, ) }, ) @@ -211,18 +252,24 @@ describe.each(NON_UNIT_CHANNEL_TYPES)( search_filter: "topic=physics", channel_type: channelType, }) + if (channelType === ChannelTypeEnum.Topic) { + setupTopicApis(channel) + } renderTestApp({ url: `/c/${channel.channel_type}/${channel.name}` }) await screen.findAllByText(channel.title) const carousels = screen.queryByText("Featured Courses") expect(carousels).toBe(null) - }) + }, 10000) it("Displays the title, background, and avatar (channelType: %s)", async () => { const { channel } = setupApis({ search_filter: "offered_by=ocw", channel_type: channelType, }) + if (channelType === ChannelTypeEnum.Topic) { + setupTopicApis(channel) + } renderTestApp({ url: `/c/${channel.channel_type}/${channel.name}` }) const title = await screen.findByRole("heading", { name: channel.title }) @@ -245,13 +292,16 @@ describe.each(NON_UNIT_CHANNEL_TYPES)( img.src.includes(channel.configuration.logo), ) expect(logos.length).toBe(1) - }) + }, 10000) test("headings", async () => { const { channel } = setupApis({ search_filter: "topic=Physics", channel_type: channelType, }) + if (channelType === ChannelTypeEnum.Topic) { + setupTopicApis(channel) + } renderTestApp({ url: `/c/${channel.channel_type}/${channel.name}` }) await waitFor(() => { @@ -262,27 +312,59 @@ describe.each(NON_UNIT_CHANNEL_TYPES)( { level: 3, name: "Search Results" }, ]) }) - }) + }, 10000) }, ) describe("Channel Pages, Topic only", () => { test("Subtopics display", async () => { - const { channel, subTopics } = setupApis({ + const { channel } = setupApis({ search_filter: "topic=Physics", channel_type: ChannelTypeEnum.Topic, }) - renderTestApp({ url: `/c/${channel.channel_type}/${channel.name}` }) + const { topic, subTopics } = setupTopicApis(channel) + invariant(topic) + renderTestApp({ + url: `/c/${channel.channel_type}/${channel.name.replace(/\s/g, "-")}`, + }) - invariant(subTopics) + const subTopicsTitle = await screen.findByText("Subtopics") + expect(subTopicsTitle).toBeInTheDocument() const links = await screen.findAllByRole("link", { // name arg can be string, regex, or function name: (name) => subTopics?.results.map((t) => t.name).includes(name), }) - links.forEach((link, i) => { + links.forEach(async (link, i) => { expect(link).toHaveAttribute("href", subTopics.results[i].channel_url) }) - }) + }, 10000) + + test("Related topics display", async () => { + const { channel } = setupApis({ + search_filter: "topic=Physics", + channel_type: ChannelTypeEnum.Topic, + }) + const { subTopics, subTopicChannels } = setupTopicApis(channel) + invariant(subTopicChannels) + const subTopicChannel = subTopicChannels[0] + const filteredSubTopics = subTopics?.results.filter( + (t) => + t.name.replace(/\s/g, "-") !== subTopicChannel.name.replace(/\s/g, "-"), + ) + renderTestApp({ + url: `/c/${subTopicChannel.channel_type}/${subTopicChannel.name.replace(/\s/g, "-")}`, + }) + + const relatedTopicsTitle = await screen.findByText("Related Topics") + expect(relatedTopicsTitle).toBeInTheDocument() + const links = await screen.findAllByRole("link", { + // name arg can be string, regex, or function + name: (name) => filteredSubTopics?.map((t) => t.name).includes(name), + }) + links.forEach(async (link, i) => { + expect(link).toHaveAttribute("href", filteredSubTopics[i].channel_url) + }) + }, 10000) }) describe("Channel Pages, Unit only", () => { diff --git a/frontends/mit-learn/src/pages/ChannelPage/ChannelPage.tsx b/frontends/mit-learn/src/pages/ChannelPage/ChannelPage.tsx index 022e35ebe4..a252894862 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/ChannelPage.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/ChannelPage.tsx @@ -9,66 +9,13 @@ import type { BooleanFacets, } from "@mitodl/course-search-utils" import { ChannelTypeEnum } from "api/v0" -import { useLearningResourceTopics } from "api/hooks/learningResources" -import { ChipLink, Container, styled, Typography } from "ol-components" -import { propsNotNil } from "ol-utilities" - -const SubTopicsContainer = styled(Container)(({ theme }) => ({ - marginBottom: "60px", - [theme.breakpoints.down("sm")]: { - marginBottom: "24px", - }, -})) - -const SubTopicsHeader = styled(Typography)(({ theme }) => ({ - marginBottom: "10px", - ...theme.typography.subtitle1, -})) - -const ChipsContainer = styled.div({ - display: "flex", - flexWrap: "wrap", - gap: "12px", -}) +import { Typography } from "ol-components" type RouteParams = { channelType: ChannelTypeEnum name: string } -type SubTopicDisplayProps = { - parentTopicId: number -} - -const SubTopicsDisplay: React.FC = (props) => { - const { parentTopicId } = props - const topicsQuery = useLearningResourceTopics({ - parent_topic_id: [parentTopicId], - }) - const totalSubtopics = topicsQuery.data?.results.length ?? 0 - const subTopics = topicsQuery.data?.results.filter( - propsNotNil(["channel_url"]), - ) - return ( - totalSubtopics > 0 && ( - - Related Topics - - {subTopics?.map((topic) => ( - - ))} - - - ) - ) -} - const ChannelPage: React.FC = () => { const { channelType, name } = useParams() const channelQuery = useChannelDetail(String(channelType), String(name)) @@ -95,12 +42,6 @@ const ChannelPage: React.FC = () => { {publicDescription && ( {publicDescription} )} - {channelQuery.data?.channel_type === ChannelTypeEnum.Topic && - channelQuery.data?.topic_detail?.topic ? ( - - ) : null} {channelQuery.data?.search_filter && ( = ({ channelType, name, }) => { - const ChannelTemplate = - channelType === ChannelTypeEnum.Unit - ? UnitChannelSkeleton - : DefaultChannelSkeleton + const getChannelTemplate = (channelType: string) => { + switch (channelType) { + case ChannelTypeEnum.Unit: + return UnitChannelTemplate + case ChannelTypeEnum.Topic: + return TopicChannelTemplate + default: + return DefaultChannelTemplate + } + } + const ChannelTemplate = getChannelTemplate(channelType) return ( diff --git a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.test.tsx b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.test.tsx index 0e77812442..760e7523e2 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.test.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/ChannelSearch.test.tsx @@ -76,14 +76,15 @@ const setMockApiResponses = ({ results: [], }) - if ( - channel.channel_type === ChannelTypeEnum.Topic && - channel.topic_detail.topic - ) { - setMockResponse.get( - urls.topics.list({ parent_topic_id: [channel.topic_detail.topic] }), - factories.learningResources.topics({ count: 5 }), - ) + if (channel.channel_type === ChannelTypeEnum.Topic) { + const topicId = channel.topic_detail.topic + if (topicId) { + setMockResponse.get(urls.topics.get(topicId), null) + setMockResponse.get( + urls.topics.list({ parent_topic_id: [topicId] }), + null, + ) + } } return { diff --git a/frontends/mit-learn/src/pages/ChannelPage/DefaultChannelTemplate.tsx b/frontends/mit-learn/src/pages/ChannelPage/DefaultChannelTemplate.tsx index eade19f177..30726a41a9 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/DefaultChannelTemplate.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/DefaultChannelTemplate.tsx @@ -25,7 +25,6 @@ const ChannelControlsContainer = styled.div(({ theme }) => ({ alignItems: "end", flexGrow: 0, flexShrink: 0, - order: 2, [theme.breakpoints.down("xs")]: { width: "100%", }, @@ -87,14 +86,11 @@ const DefaultChannelTemplate: React.FC = ({ /> ) } - header={channel.data?.title} - subheader={displayConfiguration?.heading} - extraHeader={displayConfiguration?.sub_heading} - backgroundUrl={ - displayConfiguration?.banner_background ?? - "/static/images/background_steps.jpeg" - } - extraRight={ + title={channel.data?.title} + header={displayConfiguration?.heading} + subHeader={displayConfiguration?.sub_heading} + backgroundUrl={displayConfiguration?.banner_background} + extraActions={ {channel.data?.search_filter ? ( diff --git a/frontends/mit-learn/src/pages/ChannelPage/EditChannelAppearanceForm.test.tsx b/frontends/mit-learn/src/pages/ChannelPage/EditChannelAppearanceForm.test.tsx index 5c18977909..ecdd46294e 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/EditChannelAppearanceForm.test.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/EditChannelAppearanceForm.test.tsx @@ -33,14 +33,15 @@ const setupApis = (channelOverrides: Partial) => { results: [], }) - if ( - channel.channel_type === ChannelTypeEnum.Topic && - channel.topic_detail.topic - ) { - setMockResponse.get( - urls.topics.list({ parent_topic_id: [channel.topic_detail.topic] }), - factories.learningResources.topics({ count: 5 }), - ) + if (channel.channel_type === ChannelTypeEnum.Topic) { + const topicId = channel.topic_detail.topic + if (topicId) { + setMockResponse.get(urls.topics.get(topicId), null) + setMockResponse.get( + urls.topics.list({ parent_topic_id: [topicId] }), + null, + ) + } } return channel @@ -86,13 +87,11 @@ describe("EditChannelAppearanceForm", () => { const newTitle = "New Title" const newDesc = "New Description" - const newChannelType = "topic" // Initial channel values const updatedValues = { ...channel, title: newTitle, public_description: newDesc, - channel_type: newChannelType, } setMockResponse.patch(urls.channels.patch(channel.id), updatedValues) const { location } = renderTestApp({ @@ -104,25 +103,30 @@ describe("EditChannelAppearanceForm", () => { const descInput = (await screen.findByLabelText( "Description", )) as HTMLInputElement - const channelTypeInput = (await screen.findByLabelText( - "Channel Type", - )) as HTMLInputElement const submitBtn = await screen.findByText("Save") - channelTypeInput.setAttribute("channel_type", newChannelType) titleInput.setSelectionRange(0, titleInput.value.length) await user.type(titleInput, newTitle) descInput.setSelectionRange(0, descInput.value.length) await user.type(descInput, newDesc) // Expected channel values after submit setMockResponse.get( - urls.channels.details(newChannelType, channel.name), + urls.channels.details(channel.channel_type, channel.name), updatedValues, ) + if ( + channel.channel_type === ChannelTypeEnum.Topic && + channel.topic_detail.topic + ) { + setMockResponse.get( + urls.topics.get(channel.topic_detail.topic), + factories.learningResources.topic(), + ) + } await user.click(submitBtn) await waitFor(() => { expect(location.current.pathname).toBe( - makeChannelViewPath(newChannelType, channel.name), + makeChannelViewPath(channel.channel_type, channel.name), ) }) await screen.findAllByText(newTitle) diff --git a/frontends/mit-learn/src/pages/ChannelPage/EditChannelPage.test.tsx b/frontends/mit-learn/src/pages/ChannelPage/EditChannelPage.test.tsx index 1a0c2a6e60..9dd9fadde3 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/EditChannelPage.test.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/EditChannelPage.test.tsx @@ -2,10 +2,14 @@ import { renderTestApp, screen } from "../../test-utils" import { channels as factory } from "api/test-utils/factories" import { setMockResponse, urls as apiUrls, factories } from "api/test-utils" import { makeChannelEditPath } from "@/common/urls" +import { ChannelTypeEnum } from "api/v0" describe("EditChannelPage", () => { const setup = () => { - const channel = factory.channel({ is_moderator: true }) + const channel = factory.channel({ + is_moderator: true, + channel_type: ChannelTypeEnum.Topic, + }) setMockResponse.get( apiUrls.channels.details(channel.channel_type, channel.name), channel, @@ -20,7 +24,16 @@ describe("EditChannelPage", () => { }), factories.percolateQueries, ) - + if (channel.channel_type === ChannelTypeEnum.Topic) { + const topicId = channel.topic_detail.topic + if (topicId) { + setMockResponse.get(apiUrls.topics.get(topicId), null) + setMockResponse.get( + apiUrls.topics.list({ parent_topic_id: [topicId] }), + null, + ) + } + } return channel } @@ -35,7 +48,8 @@ describe("EditChannelPage", () => { }) it("Displays message and no tabs for non-moderators", async () => { - const channel = factory.channel({ is_moderator: false }) + const channel = setup() + channel.is_moderator = false setMockResponse.get(apiUrls.userMe.get(), {}) setMockResponse.get( apiUrls.learningResources.featured({ limit: 12 }), diff --git a/frontends/mit-learn/src/pages/ChannelPage/TopicChannelTemplate.tsx b/frontends/mit-learn/src/pages/ChannelPage/TopicChannelTemplate.tsx new file mode 100644 index 0000000000..fc9ff70fa6 --- /dev/null +++ b/frontends/mit-learn/src/pages/ChannelPage/TopicChannelTemplate.tsx @@ -0,0 +1,280 @@ +import React from "react" +import { + styled, + Breadcrumbs, + Banner, + ChipLink, + Typography, + Skeleton, + BreadcrumbsProps, +} from "ol-components" +import { SearchSubscriptionToggle } from "@/page-components/SearchSubscriptionToggle/SearchSubscriptionToggle" +import { useChannelDetail } from "api/hooks/channels" +import ChannelMenu from "@/components/ChannelMenu/ChannelMenu" +import ChannelAvatar from "@/components/ChannelAvatar/ChannelAvatar" +import { LearningResourceTopic, SourceTypeEnum } from "api" +import { HOME as HOME_URL } from "../../common/urls" +import { + CHANNEL_TYPE_BREADCRUMB_TARGETS, + ChannelControls, +} from "./ChannelPageTemplate" +import MetaTags from "@/page-components/MetaTags/MetaTags" +import { ChannelTypeEnum, TopicChannel } from "api/v0" +import { + useLearningResourceTopic, + useLearningResourceTopics, +} from "api/hooks/learningResources" +import { propsNotNil } from "ol-utilities" +import invariant from "tiny-invariant" + +const ChildrenContainer = styled.div(({ theme }) => ({ + paddingTop: "40px", + [theme.breakpoints.down("sm")]: { + paddingTop: "24px", + }, +})) + +const ChannelControlsContainer = styled.div(({ theme }) => ({ + display: "flex", + flexDirection: "row", + alignItems: "end", + flexGrow: 0, + flexShrink: 0, + [theme.breakpoints.down("xs")]: { + width: "100%", + }, + [theme.breakpoints.down("sm")]: { + mt: "8px", + mb: "48px", + }, + [theme.breakpoints.up("md")]: { + mt: "0px", + mb: "48px", + width: "15%", + }, +})) + +const SubTopicsContainer = styled.div(({ theme }) => ({ + paddingTop: "30px", + [theme.breakpoints.down("md")]: { + paddingTop: "16px", + paddingBottom: "16px", + }, +})) + +const SubTopicsHeader = styled(Typography)(({ theme }) => ({ + marginBottom: "16px", + ...theme.typography.subtitle1, +})) + +const ChipsContainer = styled.div({ + display: "flex", + flexWrap: "wrap", + gap: "12px", +}) + +const BannerSkeleton = styled(Skeleton)(({ theme }) => ({ + backgroundColor: theme.custom.colors.darkGray2, +})) + +type TopicChipsInternalProps = { + title: string + topicId: number + parentTopicId: number +} + +const TopicChipsInternal: React.FC = (props) => { + const { title, topicId, parentTopicId } = props + const subTopicsQuery = useLearningResourceTopics({ + parent_topic_id: [parentTopicId], + }) + const topics = subTopicsQuery.data?.results + ?.filter(propsNotNil(["channel_url"])) + .filter((t) => t.id !== topicId) + const totalTopics = topics?.length ?? 0 + return totalTopics > 0 ? ( + + {title} + + {topics?.map((topic) => ( + + ))} + + + ) : null +} + +type TopicChipsProps = { + topic: LearningResourceTopic | null | undefined +} + +const TopicChips: React.FC = (props) => { + const { topic } = props + if (!topic) { + return null + } + const isTopLevelTopic = topic?.parent === null + if (isTopLevelTopic) { + return ( + + ) + } else if (topic?.parent) { + return ( + + ) + } else return null +} + +type SubTopicBreadcrumbsProps = { + topic: LearningResourceTopic | undefined + parentTopicId: number +} + +const SubTopicBreadcrumbs: React.FC = (props) => { + const { topic, parentTopicId } = props + const parentTopic = useLearningResourceTopic(parentTopicId).data + if (!topic?.parent) { + return null + } + return ( + + ) +} + +const BreadcrumbsInternal: React.FC< + Pick +> = (props) => { + return ( + + ) +} + +interface TopicChannelTemplateProps { + children: React.ReactNode + name: string +} + +/** + * Common structure for topic channel-oriented pages. + * + * Renders the channel title and avatar in a banner. + */ +const TopicChannelTemplate: React.FC = ({ + children, + name, +}) => { + const channel = useChannelDetail(String(ChannelTypeEnum.Topic), String(name)) + if (channel.data?.channel_type === ChannelTypeEnum.Topic) { + return ( + + {children} + + ) + } else return null +} + +type TopicChannelTemplateInternalProps = { + channel: TopicChannel + name: string + children: React.ReactNode +} + +const TopicChannelTemplateInternal: React.FC< + TopicChannelTemplateInternalProps +> = ({ channel, name, children }) => { + invariant(channel.topic_detail.topic, "Topic channel must have a topic") + const topicQuery = useLearningResourceTopic(channel.topic_detail.topic) + const topicQueryLoading = topicQuery.isLoading + const topic = topicQuery.data + const parentTopicId = topic?.parent + const urlParams = new URLSearchParams(channel.search_filter) + const displayConfiguration = channel.configuration + const navText = parentTopicId ? ( + + ) : ( + + ) + return ( + <> + + + ) : ( + navText + ) + } + avatar={ + displayConfiguration?.logo && + channel && ( + + ) + } + title={channel.title} + header={displayConfiguration?.heading} + subHeader={displayConfiguration?.sub_heading} + extraHeader={} + backgroundUrl={ + displayConfiguration?.banner_background ?? + "/static/images/background_steps.jpg" + } + extraActions={ + + + {channel.search_filter ? ( + + ) : null} + {channel.is_moderator ? ( + + ) : null} + + + } + /> + {children} + + ) +} + +export default TopicChannelTemplate diff --git a/frontends/mit-learn/src/pages/ChannelPage/UnitChannelTemplate.tsx b/frontends/mit-learn/src/pages/ChannelPage/UnitChannelTemplate.tsx index 3b42d65cbf..fb356686fa 100644 --- a/frontends/mit-learn/src/pages/ChannelPage/UnitChannelTemplate.tsx +++ b/frontends/mit-learn/src/pages/ChannelPage/UnitChannelTemplate.tsx @@ -112,10 +112,7 @@ const UnitChannelTemplate: React.FC = ({ // no image for now. Channel images are svg and not suitable for social sharing /> diff --git a/frontends/mit-learn/src/pages/DepartmentListingPage/DepartmentListingPage.tsx b/frontends/mit-learn/src/pages/DepartmentListingPage/DepartmentListingPage.tsx index 7606c5742e..d4bb1f0a49 100644 --- a/frontends/mit-learn/src/pages/DepartmentListingPage/DepartmentListingPage.tsx +++ b/frontends/mit-learn/src/pages/DepartmentListingPage/DepartmentListingPage.tsx @@ -194,9 +194,9 @@ const DepartmentListingPage: React.FC = () => { <> ({ }, })) -const ToopicsListingPage: React.FC = () => { +const TopicsListingPage: React.FC = () => { const channelCountQuery = useChannelCounts("topic") const topicsQuery = useLearningResourceTopics() @@ -274,9 +272,8 @@ const ToopicsListingPage: React.FC = () => { current="Topics" /> } - header="Browse by Topic" - subheader="Select a topic below to explore relevant learning resources across all Academic and Professional units." - backgroundUrl={TOPICS_BANNER_IMAGE} + title="Browse by Topic" + header="Select a topic below to explore relevant learning resources across all Academic and Professional units." /> @@ -305,4 +302,4 @@ const ToopicsListingPage: React.FC = () => { ) } -export default ToopicsListingPage +export default TopicsListingPage diff --git a/frontends/mit-learn/src/pages/UnitsListingPage/UnitsListingPage.tsx b/frontends/mit-learn/src/pages/UnitsListingPage/UnitsListingPage.tsx index a981904808..a7472a4f1b 100644 --- a/frontends/mit-learn/src/pages/UnitsListingPage/UnitsListingPage.tsx +++ b/frontends/mit-learn/src/pages/UnitsListingPage/UnitsListingPage.tsx @@ -18,7 +18,6 @@ import MetaTags from "@/page-components/MetaTags/MetaTags" import { aggregateProgramCounts, aggregateCourseCounts } from "@/common/utils" -const UNITS_BANNER_IMAGE = "/static/images/background_steps.jpeg" const DESKTOP_WIDTH = "1056px" const sortUnits = ( @@ -248,9 +247,8 @@ const UnitsListingPage: React.FC = () => { current="MIT Units" /> } - header="Academic & Professional Learning" - subheader="Non-degree learning resources tailored to the needs of students and working professionals." - backgroundUrl={UNITS_BANNER_IMAGE} + title="Academic & Professional Learning" + header="Non-degree learning resources tailored to the needs of students and working professionals." /> diff --git a/frontends/ol-components/src/components/Banner/Banner.stories.tsx b/frontends/ol-components/src/components/Banner/Banner.stories.tsx index e56690ae39..d5377862f8 100644 --- a/frontends/ol-components/src/components/Banner/Banner.stories.tsx +++ b/frontends/ol-components/src/components/Banner/Banner.stories.tsx @@ -28,8 +28,8 @@ const meta: Meta = { current={"Text"} /> ), - header: "Banner Title", - subheader: lipsum, + title: "Banner Title", + subHeader: lipsum, }, } export default meta @@ -76,7 +76,7 @@ export const logoBannerWithExtras: Story = { Action Button } - extraRight={ + extraActions={
Extra Content
diff --git a/frontends/ol-components/src/components/Banner/Banner.tsx b/frontends/ol-components/src/components/Banner/Banner.tsx index 2c1b209766..1dd469ff91 100644 --- a/frontends/ol-components/src/components/Banner/Banner.tsx +++ b/frontends/ol-components/src/components/Banner/Banner.tsx @@ -5,18 +5,15 @@ import Container from "@mui/material/Container" import { ResponsiveStyleValue, SxProps } from "@mui/system" import { Theme } from "../ThemeProvider/ThemeProvider" +const DEFAULT_BACKGROUND_IMAGE_URL = "/static/images/background_steps.jpg" + const SubHeader = styled(Typography)({ - maxWidth: "700px", marginTop: "8px", - marginBottom: "16px", -}) - -const ExtraHeader = styled(Typography)({ - marginBottom: "16px", + marginBottom: "8px", }) type BannerBackgroundProps = { - backgroundUrl: string + backgroundUrl?: string backgroundSize?: string backgroundDim?: number } @@ -25,15 +22,27 @@ type BannerBackgroundProps = { * This is a full-width banner component that takes a background image URL. */ const BannerBackground = styled.div( - ({ theme, backgroundUrl, backgroundDim = 0 }) => ({ + ({ + theme, + backgroundUrl = DEFAULT_BACKGROUND_IMAGE_URL, + backgroundSize = "cover", + backgroundDim = 0, + }) => ({ backgroundAttachment: "fixed", backgroundImage: backgroundDim ? `linear-gradient(rgba(0 0 0 / ${backgroundDim}%), rgba(0 0 0 / ${backgroundDim}%)), url('${backgroundUrl}')` : `url(${backgroundUrl})`, - backgroundSize: "cover", + backgroundSize: backgroundSize, + backgroundPosition: "center top", backgroundRepeat: "no-repeat", color: theme.custom.colors.white, padding: "48px 0 48px 0", + [theme.breakpoints.up("lg")]: { + backgroundSize: + backgroundUrl === DEFAULT_BACKGROUND_IMAGE_URL + ? "140%" + : backgroundSize, + }, [theme.breakpoints.down("sm")]: { padding: "32px 0 32px 0", }, @@ -55,28 +64,42 @@ const HeaderContainer = styled.div({ flexDirection: "column", }) -const RightContainer = styled.div(({ theme }) => ({ +const ActionsContainer = styled.div(({ theme }) => ({ display: "flex", flexDirection: "row", [theme.breakpoints.down("md")]: { - width: "100%", + display: "none", + }, +})) + +const ActionsContainerDesktop = styled(ActionsContainer)(({ theme }) => ({ + [theme.breakpoints.down("md")]: { + display: "none", + }, +})) + +const ActionsContainerMobile = styled.div(({ theme }) => ({ + paddingTop: "16px", + paddingBottom: "8px", + [theme.breakpoints.up("md")]: { + display: "none", }, })) type BannerProps = BannerBackgroundProps & { - backgroundUrl: string - backgroundSize?: string - backgroundDim?: number navText: React.ReactNode avatar?: React.ReactNode + title?: React.ReactNode + titleTypography?: ResponsiveStyleValue + titleStyles?: SxProps header: React.ReactNode headerTypography?: ResponsiveStyleValue headerStyles?: SxProps - subheader?: React.ReactNode - subheaderTypography?: ResponsiveStyleValue - subheaderStyles?: SxProps + subHeader?: React.ReactNode + subHeaderTypography?: ResponsiveStyleValue + subHeaderStyles?: SxProps extraHeader?: React.ReactNode - extraRight?: React.ReactNode + extraActions?: React.ReactNode } /** @@ -88,19 +111,20 @@ const TYPOGRAPHY_DEFAULTS = { defaultSubHeaderTypography: { xs: "body2", md: "body1" }, } const Banner = ({ - backgroundUrl, + backgroundUrl = DEFAULT_BACKGROUND_IMAGE_URL, backgroundSize = "cover", backgroundDim = 0, navText, avatar, + title, + titleTypography = TYPOGRAPHY_DEFAULTS.defaultHeaderTypography, + titleStyles, header, - headerTypography = TYPOGRAPHY_DEFAULTS.defaultHeaderTypography, - headerStyles, - subheader, - subheaderTypography = TYPOGRAPHY_DEFAULTS.defaultSubHeaderTypography, - subheaderStyles, + subHeader, + subHeaderTypography = TYPOGRAPHY_DEFAULTS.defaultSubHeaderTypography, + subHeaderStyles, extraHeader, - extraRight, + extraActions, }: BannerProps) => { return ( - {header} + {title} - - {subheader} - - - {extraHeader} - + {extraActions} + {header && ( + + {header} + + )} + {subHeader && ( + + {subHeader} + + )} + {extraHeader ? extraHeader : null} - {extraRight} + {extraActions} diff --git a/frontends/ol-components/src/components/Breadcrumbs/Breadcrumbs.tsx b/frontends/ol-components/src/components/Breadcrumbs/Breadcrumbs.tsx index 60c7f1ec7b..d12cbb9a65 100644 --- a/frontends/ol-components/src/components/Breadcrumbs/Breadcrumbs.tsx +++ b/frontends/ol-components/src/components/Breadcrumbs/Breadcrumbs.tsx @@ -58,11 +58,12 @@ const DarkCurrent = styled(Current)({ type BreadcrumbsProps = { variant: "light" | "dark" ancestors: Array<{ href: string; label: string }> - current: string | undefined + current?: string | undefined | null + currentHref?: string | undefined | null } const Breadcrumbs: React.FC = (props) => { - const { variant, ancestors, current } = props + const { variant, ancestors, current, currentHref } = props const linkColor = variant === "light" ? "black" : "white" const _Separator = variant === "light" ? LightSeparator : DarkSeparator const _Current = variant === "light" ? LightCurrent : DarkCurrent @@ -85,7 +86,18 @@ const Breadcrumbs: React.FC = (props) => { ) })} - <_Current>{current} + {currentHref ? ( + + {current} + + ) : ( + <_Current>{current} + )} ) diff --git a/frontends/ol-components/src/components/Chips/Chip.stories.tsx b/frontends/ol-components/src/components/Chips/Chip.stories.tsx index e3edba480a..2b75437a3b 100644 --- a/frontends/ol-components/src/components/Chips/Chip.stories.tsx +++ b/frontends/ol-components/src/components/Chips/Chip.stories.tsx @@ -40,6 +40,10 @@ const VARIANTS: { variant: "dark", label: "Dark", }, + { + variant: "darker", + label: "Darker", + }, { variant: "filled", label: "Filled", diff --git a/frontends/ol-components/src/components/ThemeProvider/chips.tsx b/frontends/ol-components/src/components/ThemeProvider/chips.tsx index 5e9c695214..c75839234d 100644 --- a/frontends/ol-components/src/components/ThemeProvider/chips.tsx +++ b/frontends/ol-components/src/components/ThemeProvider/chips.tsx @@ -132,6 +132,21 @@ const chipComponent: NonNullable["MuiChip"] = { }, }, }, + { + props: { variant: "darker" }, + style: { + backgroundColor: colors.darkGray2, + border: `1px solid ${colors.darkGray1}`, + color: colors.white, + "&.Mui-focusVisible": { + backgroundColor: colors.darkGray2, + }, + "&.MuiChip-clickable:hover, &.MuiChip-deletable:hover": { + backgroundColor: colors.black, + border: `1px solid ${colors.silverGray}`, + }, + }, + }, { props: { variant: "filled" }, style: { diff --git a/frontends/ol-components/src/types/theme.d.ts b/frontends/ol-components/src/types/theme.d.ts index 21986bf478..ee121f26c0 100644 --- a/frontends/ol-components/src/types/theme.d.ts +++ b/frontends/ol-components/src/types/theme.d.ts @@ -88,6 +88,7 @@ declare module "@mui/material/Chip" { outlined: true outlinedWhite: true dark: true + darker: true gray: true } }