From d7ea2216cbd0f24e77a6f6db4110c1eb4de0e678 Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Thu, 9 Oct 2025 19:20:42 +0000 Subject: [PATCH 1/3] Port remaining book details page modules from JS to TS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converted all remaining JavaScript files in src/app/pages/details to TypeScript: Desktop view modules: - desktop-view.js → desktop-view.tsx - details-tab.js → details-tab.tsx - videos-tab.js → videos-tab.tsx - partners.js → partners.tsx - import-*.js → import-*.ts Phone view modules: - phone-view.js → phone-view.tsx - details-pane.js → details-pane.tsx - student-resources-pane.js → student-resources-pane.tsx - instructor-resources-pane.js → instructor-resources-pane.tsx All conversions follow TypeScript best practices: - Avoided using 'any' type - Preferred 'type' to 'interface' - Used type inference where possible - Inline type definitions for function parameters - Line lengths kept under 120 characters 🤖 Generated with [Claude Code](https://claude.com/claude-code) Port remaining book details page modules from JS to TS Converted all remaining JavaScript files in src/app/pages/details to TypeScript: Desktop view modules: - desktop-view.js → desktop-view.tsx - details-tab.js → details-tab.tsx - videos-tab.js → videos-tab.tsx - partners.js → partners.tsx - import-*.js → import-*.ts Phone view modules: - phone-view.js → phone-view.tsx - details-pane.js → details-pane.tsx - student-resources-pane.js → student-resources-pane.tsx - instructor-resources-pane.js → instructor-resources-pane.tsx All conversions follow TypeScript best practices: - Avoided using 'any' type - Preferred 'type' to 'interface' - Used type inference where possible - Inline type definitions for function parameters - Line lengths kept under 120 characters 🤖 Generated with [Claude Code](https://claude.com/claude-code) Revert to JS (loaders) Co-Authored-By: Claude --- .../pages/details/{context.tsx => context.ts} | 8 +- .../{desktop-view.js => desktop-view.tsx} | 104 +++++++++----- .../{details-tab.js => details-tab.tsx} | 25 +++- .../details-tab/import-details-tab.js | 3 + ...b.js => import-instructor-resource-tab.ts} | 0 .../partners/{partners.js => partners.tsx} | 49 +++++-- ...-tab.js => import-student-resource-tab.ts} | 0 .../desktop-view/videos-tab/videos-tab.js | 19 --- .../desktop-view/videos-tab/videos-tab.tsx | 26 ++++ .../{details-pane.js => details-pane.tsx} | 13 +- ...-pane.js => instructor-resources-pane.tsx} | 86 ++++++----- .../pages/details/phone-view/phone-view.js | 104 -------------- .../pages/details/phone-view/phone-view.tsx | 134 ++++++++++++++++++ .../student-resources-pane.js | 34 ----- .../student-resources-pane.tsx | 60 ++++++++ 15 files changed, 416 insertions(+), 249 deletions(-) rename src/app/pages/details/{context.tsx => context.ts} (93%) rename src/app/pages/details/desktop-view/{desktop-view.js => desktop-view.tsx} (50%) rename src/app/pages/details/desktop-view/details-tab/{details-tab.js => details-tab.tsx} (77%) create mode 100644 src/app/pages/details/desktop-view/details-tab/import-details-tab.js rename src/app/pages/details/desktop-view/instructor-resource-tab/{import-instructor-resource-tab.js => import-instructor-resource-tab.ts} (100%) rename src/app/pages/details/desktop-view/instructor-resource-tab/partners/{partners.js => partners.tsx} (62%) rename src/app/pages/details/desktop-view/student-resource-tab/{import-student-resource-tab.js => import-student-resource-tab.ts} (100%) delete mode 100644 src/app/pages/details/desktop-view/videos-tab/videos-tab.js create mode 100644 src/app/pages/details/desktop-view/videos-tab/videos-tab.tsx rename src/app/pages/details/phone-view/details-pane/{details-pane.js => details-pane.tsx} (75%) rename src/app/pages/details/phone-view/instructor-resources-pane/{instructor-resources-pane.js => instructor-resources-pane.tsx} (56%) delete mode 100644 src/app/pages/details/phone-view/phone-view.js create mode 100644 src/app/pages/details/phone-view/phone-view.tsx delete mode 100644 src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.js create mode 100644 src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx diff --git a/src/app/pages/details/context.tsx b/src/app/pages/details/context.ts similarity index 93% rename from src/app/pages/details/context.tsx rename to src/app/pages/details/context.ts index 529cb24d8..8c23feec6 100644 --- a/src/app/pages/details/context.tsx +++ b/src/app/pages/details/context.ts @@ -2,6 +2,7 @@ import {useState} from 'react'; import buildContext from '~/components/jsx-helpers/build-context'; import type {ResourceData} from './common/resource-box/resource-box-utils'; import type {PromoteData} from './desktop-view/promo'; +import type {Model as GetThisTitleModel} from './common/get-this-title'; export type LocaleType = { locale: string; @@ -24,7 +25,7 @@ type StuffContent = { heading: string; }; }; -type VideoContent = { +export type VideoContent = { title: string; description: string; embed: string; @@ -37,13 +38,14 @@ type Author = { export type IsbnType = 'print' | 'printSoftcover' | 'digital' | 'assignable'; -export type ContextValues = { +export type ContextValues = GetThisTitleModel & { id: number; slug: string; translations: Array; bookState: string; comingSoon: boolean; coverColor: string; + description: string; meta: LocaleType; reverseGradient: boolean; title: string; @@ -59,7 +61,7 @@ export type ContextValues = { webinarContent?: WebinarContent; freeStuffStudent: StuffContent; freeStuffInstructor: StuffContent; - videos: VideoContent[]; + videos: [VideoContent[]] | never[]; setUseCardBackground?: React.Dispatch>; authors: Author[]; created: string; diff --git a/src/app/pages/details/desktop-view/desktop-view.js b/src/app/pages/details/desktop-view/desktop-view.tsx similarity index 50% rename from src/app/pages/details/desktop-view/desktop-view.js rename to src/app/pages/details/desktop-view/desktop-view.tsx index 5a129761c..5df9b84c5 100644 --- a/src/app/pages/details/desktop-view/desktop-view.js +++ b/src/app/pages/details/desktop-view/desktop-view.tsx @@ -3,7 +3,7 @@ import TabGroup from '~/components/tab-group/tab-group'; import ContentGroup from '~/components/content-group/content-group'; import {useIntl} from 'react-intl'; import VideoTab from './videos-tab/videos-tab'; -import useDetailsContext from '../context'; +import useDetailsContext, {type ContextValues} from '../context'; import {GiveLink} from '../common/common'; import {useNavigate} from 'react-router-dom'; import $ from '~/helpers/$'; @@ -11,41 +11,53 @@ import JITLoad from '~/helpers/jit-load'; import {findSelectedTab, replaceSearchTerm} from '../common/tab-utils'; import './desktop-view.scss'; -const importDetailsTab = () => import('./details-tab/details-tab.js'); -const importInstructorTab = () => import('./instructor-resource-tab/import-instructor-resource-tab.js'); -const importStudentTab = () => import('./student-resource-tab/import-student-resource-tab.js'); +const importDetailsTab = () => import('./details-tab/details-tab'); +const importInstructorTab = () => + import('./instructor-resource-tab/import-instructor-resource-tab'); +const importStudentTab = () => + import('./student-resource-tab/import-student-resource-tab'); // eslint-disable-next-line complexity -function useLabelsFromModel(model, polish) { +function useLabelsFromModel(model: ContextValues, polish: boolean) { const intl = useIntl(); - const tabLabels = [polish ? 'Szczegóły książki' : intl.formatMessage({ - id: 'tabs.bookDetails' - })]; + const tabLabels = [ + polish + ? 'Szczegóły książki' + : intl.formatMessage({ + id: 'tabs.bookDetails' + }) + ]; if (!polish && model.freeStuffInstructor.content) { - tabLabels.push(intl.formatMessage({ - id: 'tabs.instructorResources' - })); + tabLabels.push( + intl.formatMessage({ + id: 'tabs.instructorResources' + }) + ); } if (!polish && model.freeStuffStudent.content) { - tabLabels.push(intl.formatMessage({ - id: 'tabs.studentResources' - })); + tabLabels.push( + intl.formatMessage({ + id: 'tabs.studentResources' + }) + ); } if (model.videos.length) { - tabLabels.push(intl.formatMessage({ - id: 'tabs.videos' - })); + tabLabels.push( + intl.formatMessage({ + id: 'tabs.videos' + }) + ); } return tabLabels; } -function useSelectedLabelTiedToSearchString(labels) { +function useSelectedLabelTiedToSearchString(labels: string[]) { const navigate = useNavigate(); const selectedTab = findSelectedTab(labels); const updateSelectedLabel = React.useCallback( - (newValue) => { + (newValue: string) => { const newSearchString = replaceSearchTerm(labels, newValue); navigate(newSearchString, {replace: true}); @@ -53,37 +65,43 @@ function useSelectedLabelTiedToSearchString(labels) { [labels, navigate] ); - return [selectedTab, updateSelectedLabel]; + return [selectedTab, updateSelectedLabel] as const; } -function StubUntilSeen({active, ...JLParams}) { +type StubUntilSeenProps = { + active: boolean; + importFn: () => Promise<{default: React.ComponentType}>; + polish?: boolean; + model?: ContextValues; +}; + +function StubUntilSeen({active, ...JLParams}: StubUntilSeenProps) { const [seen, setSeen] = useState(false); - useEffect( - () => { - if (active) { - setSeen(true); - } - }, - [active] - ); + useEffect(() => { + if (active) { + setSeen(true); + } + }, [active]); - return ( - seen ? :
- ); + return seen ? :
; } export default function DesktopView() { const model = useDetailsContext(); const polish = $.isPolish(model.title); const labels = useLabelsFromModel(model, polish); - const [selectedLabel, setSelectedLabel] = useSelectedLabelTiedToSearchString(labels); + const [selectedLabel, setSelectedLabel] = + useSelectedLabelTiedToSearchString(labels); const TabTag = 'h2'; const activeIndex = labels.indexOf(selectedLabel); const isShowingCards = selectedLabel !== 'Book details'; const setUseCardBackground = model.setUseCardBackground; - useEffect(() => setUseCardBackground(isShowingCards), [isShowingCards, setUseCardBackground]); + useEffect( + () => setUseCardBackground?.(isShowingCards), + [isShowingCards, setUseCardBackground] + ); return ( @@ -98,9 +116,21 @@ export default function DesktopView() {
- - - + + + {model.videos[0] && }
diff --git a/src/app/pages/details/desktop-view/details-tab/details-tab.js b/src/app/pages/details/desktop-view/details-tab/details-tab.tsx similarity index 77% rename from src/app/pages/details/desktop-view/details-tab/details-tab.js rename to src/app/pages/details/desktop-view/details-tab/details-tab.tsx index 99ad6bb16..00b1154d2 100644 --- a/src/app/pages/details/desktop-view/details-tab/details-tab.js +++ b/src/app/pages/details/desktop-view/details-tab/details-tab.tsx @@ -6,9 +6,10 @@ import GetThisTitle from '../../common/get-this-title'; import LetUsKnow from '../../common/let-us-know/let-us-know'; import SavingsBlurb from '../../common/savings-blurb'; import Promo from '../promo'; +import type {ContextValues} from '../../context'; import './details-tab.scss'; -function PolishTab({model}) { +function PolishTab({model}: {model: ContextValues}) { return (
@@ -33,13 +34,16 @@ function PolishTab({model}) { ); } -function EnglishTab({model}) { +function EnglishTab({model}: {model: ContextValues}) { return (

- +

@@ -51,7 +55,10 @@ function EnglishTab({model}) {

- +

@@ -66,8 +73,14 @@ function EnglishTab({model}) { ); } -export default function DetailsTab({model, polish}) { +export default function DetailsTab({ + model, + polish +}: { + model: ContextValues; + polish: boolean; +}) { const Child = polish ? PolishTab : EnglishTab; - return (); + return ; } diff --git a/src/app/pages/details/desktop-view/details-tab/import-details-tab.js b/src/app/pages/details/desktop-view/details-tab/import-details-tab.js new file mode 100644 index 000000000..b86453b71 --- /dev/null +++ b/src/app/pages/details/desktop-view/details-tab/import-details-tab.js @@ -0,0 +1,3 @@ +import DetailsTab from './details-tab'; + +export default DetailsTab; diff --git a/src/app/pages/details/desktop-view/instructor-resource-tab/import-instructor-resource-tab.js b/src/app/pages/details/desktop-view/instructor-resource-tab/import-instructor-resource-tab.ts similarity index 100% rename from src/app/pages/details/desktop-view/instructor-resource-tab/import-instructor-resource-tab.js rename to src/app/pages/details/desktop-view/instructor-resource-tab/import-instructor-resource-tab.ts diff --git a/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.js b/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx similarity index 62% rename from src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.js rename to src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx index c1ed371d0..77a9c51ab 100644 --- a/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.js +++ b/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx @@ -3,17 +3,41 @@ import {useNavigate} from 'react-router-dom'; import PartnerCard from '~/components/partner-card/partner-card'; import './partners.scss'; -export default function Partners({bookAbbreviation, model}) { +type PartnerBlurb = { + url: string; + name: string; + type: string; + image: string; + cost?: string; + verifiedFeatures: string[]; +}; + +type PartnersModel = { + title: string; + seeMoreText: string; + blurbs: PartnerBlurb[]; + badgeImage?: string; +}; + +export default function Partners({ + bookAbbreviation, + model +}: { + bookAbbreviation: string; + model: PartnersModel; +}) { const {title, seeMoreText, blurbs, badgeImage} = model; const navigate = useNavigate(); const onClick = React.useCallback( - (event) => { - const destUrl = event.target.getAttribute('href'); + (event: React.MouseEvent) => { + const destUrl = event.currentTarget.getAttribute('href'); - navigate(destUrl, { - book: bookAbbreviation, - redirect: true - }); + if (destUrl) { + navigate(destUrl, { + book: bookAbbreviation, + redirect: true + } as never); + } event.preventDefault(); }, [navigate, bookAbbreviation] @@ -34,7 +58,10 @@ export default function Partners({bookAbbreviation, model}) { {seeMoreText}
-
+
    {blurbs.map((blurb) => (
  • @@ -43,11 +70,13 @@ export default function Partners({bookAbbreviation, model}) { title={blurb.name} href={blurb.url} logoUrl={blurb.image} - tags={[blurb.cost, blurb.type].filter((x) => x)} + tags={[blurb.cost, blurb.type].filter( + (x) => x + )} onClick={onClick} badgeImage={badgeImage} verifiedFeatures={blurb.verifiedFeatures} - analyticsContentType='Partner Profile' + analyticsContentType="Partner Profile" />
  • ))} diff --git a/src/app/pages/details/desktop-view/student-resource-tab/import-student-resource-tab.js b/src/app/pages/details/desktop-view/student-resource-tab/import-student-resource-tab.ts similarity index 100% rename from src/app/pages/details/desktop-view/student-resource-tab/import-student-resource-tab.js rename to src/app/pages/details/desktop-view/student-resource-tab/import-student-resource-tab.ts diff --git a/src/app/pages/details/desktop-view/videos-tab/videos-tab.js b/src/app/pages/details/desktop-view/videos-tab/videos-tab.js deleted file mode 100644 index 1ed626c63..000000000 --- a/src/app/pages/details/desktop-view/videos-tab/videos-tab.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import RawHTML from '~/components/jsx-helpers/raw-html'; -import './videos-tab.scss'; - -export default function VideoTab({videos}) { - return ( -
    - { - videos.map(({title, description, embed}) => -
    -

    {title}

    - - -
    - ) - } -
    - ); -} diff --git a/src/app/pages/details/desktop-view/videos-tab/videos-tab.tsx b/src/app/pages/details/desktop-view/videos-tab/videos-tab.tsx new file mode 100644 index 000000000..a897fb6b1 --- /dev/null +++ b/src/app/pages/details/desktop-view/videos-tab/videos-tab.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import RawHTML from '~/components/jsx-helpers/raw-html'; +import './videos-tab.scss'; + +type Video = { + title: string; + description: string; + embed: string; +}; + +export default function VideoTab({videos}: {videos: Video[]}) { + return ( +
    + {videos.map(({title, description, embed}) => ( +
    +

    {title}

    + + +
    + ))} +
    + ); +} diff --git a/src/app/pages/details/phone-view/details-pane/details-pane.js b/src/app/pages/details/phone-view/details-pane/details-pane.tsx similarity index 75% rename from src/app/pages/details/phone-view/details-pane/details-pane.js rename to src/app/pages/details/phone-view/details-pane/details-pane.tsx index 2f27e9634..a0481d71f 100644 --- a/src/app/pages/details/phone-view/details-pane/details-pane.js +++ b/src/app/pages/details/phone-view/details-pane/details-pane.tsx @@ -3,9 +3,16 @@ import RawHTML from '~/components/jsx-helpers/raw-html'; import {Authors, PublicationInfo} from '../../common/common'; import CollapsingPane from '~/components/collapsing-pane/collapsing-pane'; import SavingsBlurb from '../../common/savings-blurb'; +import type {ContextValues} from '../../context'; import './details-pane.scss'; -export default function DetailsPane({polish, model}) { +export default function DetailsPane({ + polish, + model +}: { + polish: boolean; + model: ContextValues; +}) { return (
    @@ -17,7 +24,9 @@ export default function DetailsPane({polish, model}) {
    - +
    diff --git a/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.js b/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx similarity index 56% rename from src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.js rename to src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx index 01b48174b..3579ce647 100644 --- a/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.js +++ b/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx @@ -6,15 +6,21 @@ import {resourceBoxModel, useResources} from '../../common/resource-box/resource import FeaturedResourcesSection from '../../common/featured-resources/featured-resources'; import ResourceBoxes from '../../common/resource-box/resource-boxes'; import VideoResourceBoxes from '../../common/resource-box/video-resource-box'; -import useUserContext from '~/contexts/user'; +import useUserContext, {type UserStatus} from '~/contexts/user'; import useWindowContext, {WindowContextProvider} from '~/contexts/window'; +import type {ContextValues} from '../../context'; import './instructor-resources-pane.scss'; -export function InstructorResourcesPane({model, userStatus}) { - const { - bookVideoFacultyResources, - bookFacultyResources - } = useResources(model.slug); +export function InstructorResourcesPane({ + model, + userStatus +}: { + model: ContextValues; + userStatus: UserStatus; +}) { + const {bookVideoFacultyResources, bookFacultyResources} = useResources( + model.slug + ); const bookId = model.id; for (const r of bookFacultyResources) { @@ -22,75 +28,87 @@ export function InstructorResourcesPane({model, userStatus}) { r.resource.comingSoonText = ''; } const featuredResources = bookFacultyResources.filter((r) => r.featured); - const featuredModels = featuredResources - .map((res) => resourceBoxModel(res, userStatus, bookId)); + const featuredModels = featuredResources.map((res) => + resourceBoxModel(res, userStatus, bookId) + ); const referenceModels = bookFacultyResources .filter((r) => r.videoReferenceNumber !== null) .map((res) => resourceBoxModel(res, userStatus, bookId)); const otherModels = bookFacultyResources - .filter((r) => - !r.featured && r.videoReferenceNumber === null && - r.linkText !== 'View resources' + .filter( + (r) => + !r.featured && + r.videoReferenceNumber === null && + r.linkText !== 'View resources' ) .map((res) => resourceBoxModel(res, userStatus, bookId)); const navigate = useNavigate(); - function goToPartners(event) { + function goToPartners(event: React.MouseEvent) { event.preventDefault(); navigate('/partners', { book: model.salesforceAbbreviation, redirect: true - }); + } as never); } return ( - { - featuredModels.length > 0 && - - } + {featuredModels.length > 0 && ( + + )} - OpenStax Partners{' '} - + OpenStax Partners
    - +
    ); } -function StubUnlessDisplayed({model, userStatus}) { - const ref = React.useRef(); - const [y, setY] = React.useState(null); +function StubUnlessDisplayed({ + model, + userStatus +}: { + model: ContextValues; + userStatus: UserStatus; +}) { + const ref = React.useRef(null); + const [y, setY] = React.useState(null); const {innerWidth, scrollY} = useWindowContext(); React.useEffect( - () => setY(y || ref.current?.getBoundingClientRect().y), + () => setY(y || ref.current?.getBoundingClientRect().y ?? null), [innerWidth, scrollY, y] ); return (
    - { - y ? - : - null - } + {y ? ( + + ) : null}
    ); } -export default function LoadUserStatusThenInstructorPane({model}) { +export default function LoadUserStatusThenInstructorPane({ + model +}: { + model: ContextValues; +}) { const {userStatus} = useUserContext(); if (!userStatus) { diff --git a/src/app/pages/details/phone-view/phone-view.js b/src/app/pages/details/phone-view/phone-view.js deleted file mode 100644 index e5472b659..000000000 --- a/src/app/pages/details/phone-view/phone-view.js +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react'; -import LetUsKnow from '../common/let-us-know/let-us-know'; -import GetThisTitle from '../common/get-this-title'; -import AccordionGroup from '~/components/accordion-group/accordion-group'; -import useDetailsContext from '../context'; -import $ from '~/helpers/$'; -import JITLoad from '~/helpers/jit-load'; -import {findSelectedTab} from '../common/tab-utils'; -import RawHTML from '~/components/jsx-helpers/raw-html'; -import {useTableOfContents} from '../common/hooks'; -import {ErrataContents, GiveLink} from '../common/common'; -import './phone-view.scss'; - -const importDetailsPane = () => import('./details-pane/details-pane.js'); -const importInstructorPane = () => import('./instructor-resources-pane/instructor-resources-pane.js'); -const importStudentPane = () => import('./student-resources-pane/student-resources-pane.js'); - -function TocPane({model}) { - const tocHtml = useTableOfContents(model); - - return ( -
    - -
    - ); -} - -function ErrataPane({model, polish}) { - return ( -
    - -
    - ); -} - -function items(model) { - const polish = $.isPolish(model.title); - const result = polish ? - [ - { - title: 'Szczegóły książki', - contentComponent: - } - ] : - [ - { - title: 'Book details', - contentComponent: - }, - { - title: 'Instructor resources', - titleTag: 'updated', - contentComponent: - }, - { - title: 'Student resources', - openTitle: `Student resources (${model.bookStudentResources.length})`, - contentComponent: - } - ]; - - const includeTOC = ['live', 'new_edition_available'].includes(model.bookState); - - if (includeTOC) { - result.splice(1, 0, { - title: polish ? 'Spis treści' : 'Table of contents', - contentComponent: - }); - result.push({ - title: polish ? 'Zgłoś erratę' : 'Report errata', - contentComponent: - }); - } - - result.push({ - inline: - }); - - return result; -} - -export default function PhoneView() { - const model = useDetailsContext(); - const accordionItems = items(model); - const selectedTab = findSelectedTab(accordionItems.map((i) => i.title)); - - return ( -
    -
    - -
    - -
    -
    -
    - -
    -
    - ); -} diff --git a/src/app/pages/details/phone-view/phone-view.tsx b/src/app/pages/details/phone-view/phone-view.tsx new file mode 100644 index 000000000..115704254 --- /dev/null +++ b/src/app/pages/details/phone-view/phone-view.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import LetUsKnow from '../common/let-us-know/let-us-know'; +import GetThisTitle from '../common/get-this-title'; +import AccordionGroup from '~/components/accordion-group/accordion-group'; +import useDetailsContext, {type ContextValues} from '../context'; +import $ from '~/helpers/$'; +import JITLoad from '~/helpers/jit-load'; +import {findSelectedTab} from '../common/tab-utils'; +import RawHTML from '~/components/jsx-helpers/raw-html'; +import {useTableOfContents} from '../common/hooks'; +import {ErrataContents, GiveLink} from '../common/common'; +import './phone-view.scss'; + +const importDetailsPane = () => import('./details-pane/details-pane'); +const importInstructorPane = () => + import('./instructor-resources-pane/instructor-resources-pane'); +const importStudentPane = () => + import('./student-resources-pane/student-resources-pane'); + +function TocPane({model}: {model: ContextValues}) { + const tocHtml = useTableOfContents(model); + + return ( +
    + +
    + ); +} + +function ErrataPane({model, polish}: {model: ContextValues; polish: boolean}) { + return ( +
    + +
    + ); +} + +type AccordionItem = { + title?: string; + titleTag?: string; + openTitle?: string; + contentComponent?: React.ReactNode; + inline?: React.ReactNode; +}; + +function items(model: ContextValues): AccordionItem[] { + const polish = $.isPolish(model.title); + const result: AccordionItem[] = polish + ? [ + { + title: 'Szczegóły książki', + contentComponent: ( + + ) + } + ] + : [ + { + title: 'Book details', + contentComponent: ( + + ) + }, + { + title: 'Instructor resources', + titleTag: 'updated', + contentComponent: ( + + ) + }, + { + title: 'Student resources', + openTitle: `Student resources (${model.bookStudentResources.length})`, + contentComponent: ( + + ) + } + ]; + + const includeTOC = ['live', 'new_edition_available'].includes( + model.bookState + ); + + if (includeTOC) { + result.splice(1, 0, { + title: polish ? 'Spis treści' : 'Table of contents', + contentComponent: + }); + result.push({ + title: polish ? 'Zgłoś erratę' : 'Report errata', + contentComponent: + }); + } + + result.push({ + inline: + }); + + return result; +} + +export default function PhoneView() { + const model = useDetailsContext(); + const accordionItems = items(model); + const selectedTab = findSelectedTab( + accordionItems.map((i) => i.title).filter(Boolean) as string[] + ); + + return ( +
    +
    + +
    + +
    +
    +
    + +
    +
    + ); +} diff --git a/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.js b/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.js deleted file mode 100644 index bda5f3c04..000000000 --- a/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import {studentResourceBoxPermissions} from '../../common/resource-box/resource-box-utils'; -import ResourceBoxes from '../../common/resource-box/resource-boxes'; -import useUserContext from '~/contexts/user'; -import './student-resources-pane.scss'; - -function resourceBoxModel(resourceData, userStatus) { - return Object.assign({ - heading: resourceData.resourceHeading, - description: '', - comingSoon: Boolean(resourceData.comingSoonText), - comingSoonText: '', - printLink: resourceData.printLink - }, studentResourceBoxPermissions(resourceData, userStatus, 'Student resources')); -} - -function StudentResourcesPane({model, userStatus}) { - return ( -
    - resourceBoxModel(res, userStatus))} /> -
    - ); -} - -export default function LoadUserStatusThenStudentPane({model}) { - const {userStatus} = useUserContext(); - - if (!userStatus) { - return null; - } - return ( - - ); -} diff --git a/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx b/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx new file mode 100644 index 000000000..98fb2437d --- /dev/null +++ b/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { + studentResourceBoxPermissions, + type ResourceData +} from '../../common/resource-box/resource-box-utils'; +import ResourceBoxes, {type ResourceModel} from '../../common/resource-box/resource-boxes'; +import useUserContext, {type UserStatus} from '~/contexts/user'; +import type {ContextValues} from '../../context'; +import './student-resources-pane.scss'; + +function resourceBoxModel( + resourceData: ResourceData, + userStatus: UserStatus +): ResourceModel { + return Object.assign( + { + heading: resourceData.resourceHeading, + description: '', + comingSoon: Boolean(resourceData.comingSoonText), + comingSoonText: '', + printLink: resourceData.printLink + }, + studentResourceBoxPermissions( + resourceData, + userStatus, + 'Student resources' + ) + ) as ResourceModel; +} + +function StudentResourcesPane({ + model, + userStatus +}: { + model: ContextValues; + userStatus: UserStatus; +}) { + return ( +
    + + resourceBoxModel(res, userStatus) + )} + /> +
    + ); +} + +export default function LoadUserStatusThenStudentPane({ + model +}: { + model: ContextValues; +}) { + const {userStatus} = useUserContext(); + + if (!userStatus) { + return null; + } + return ; +} From a73f87bda8af350230e92d04fa20e273e86fe8cc Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Thu, 9 Oct 2025 17:38:31 -0500 Subject: [PATCH 2/3] Lint clean --- .../accordion-group/accordion-group.tsx | 22 ++++++++-------- .../common/get-this-title-files/options.tsx | 20 +++++++------- .../pages/details/common/get-this-title.tsx | 14 +++++----- src/app/pages/details/common/hooks.tsx | 4 +-- .../pages/details/common/publication-info.tsx | 6 ++--- .../common/resource-box/left-content.tsx | 2 +- .../resource-box/resource-box-utils.tsx | 1 + .../details/desktop-view/desktop-view.tsx | 7 +++-- .../desktop-view/details-tab/details-tab.tsx | 14 +++++----- .../partners/partners.tsx | 8 +++--- .../desktop-view/videos-tab/videos-tab.tsx | 9 ++----- .../phone-view/details-pane/details-pane.tsx | 2 +- .../instructor-resources-pane.tsx | 15 ++++++----- .../pages/details/phone-view/phone-view.tsx | 26 ++++++++++--------- .../student-resources-pane.tsx | 3 +-- 15 files changed, 76 insertions(+), 77 deletions(-) diff --git a/src/app/components/accordion-group/accordion-group.tsx b/src/app/components/accordion-group/accordion-group.tsx index b7e57ffb9..31426eee4 100644 --- a/src/app/components/accordion-group/accordion-group.tsx +++ b/src/app/components/accordion-group/accordion-group.tsx @@ -109,8 +109,9 @@ function Item({ type ItemType = { title: string; - inline?: React.ReactNode; contentComponent: React.ReactNode; +} | { + inline: React.ReactNode; }; export default function AccordionGroup({ @@ -161,16 +162,15 @@ export default function AccordionGroup({ preExpanded={preExpandedUuids} data-analytics-nav={analyticsNav} > - {items.map( - (item) => - item.inline || ( - - ) + {items.filter((i) => 'title' in i).map( + (item) => ( + + ) )}
diff --git a/src/app/pages/details/common/get-this-title-files/options.tsx b/src/app/pages/details/common/get-this-title-files/options.tsx index da09edad3..e3466066f 100644 --- a/src/app/pages/details/common/get-this-title-files/options.tsx +++ b/src/app/pages/details/common/get-this-title-files/options.tsx @@ -121,7 +121,7 @@ export function WebviewOption({model}: {model: Model}) { const {GiveDialog, openGiveDialog} = useOpenGiveDialog(); const trackDownload = React.useCallback( (event: TrackedMouseEvent) => { - trackLink(event, model.id); + trackLink(event, model.id.toString()); }, [model.id] ); @@ -140,10 +140,10 @@ export function WebviewOption({model}: {model: Model}) { {showCallout && (
@@ -173,12 +173,12 @@ export function PdfOption({model}: {model: Model}) { const {GiveDialog, openGiveDialog} = useOpenGiveDialog(); const trackDownload = React.useCallback( (event: TrackedMouseEvent) => { - trackLink(event, model.id); + trackLink(event, model.id.toString()); }, [model.id] ); - return ( + return pdfLink ? ( - ); + ) : null; } export function usePrintCopyDialog() { @@ -254,8 +254,8 @@ export function KindleOption({model}: {model: Model}) { export function CheggOption({model}: {model: Model}) { return ( -
); @@ -65,7 +70,7 @@ function EnglishTab({model}: {model: ContextValues}) {
- +
{model.adoptions && }
@@ -76,10 +81,7 @@ function EnglishTab({model}: {model: ContextValues}) { export default function DetailsTab({ model, polish -}: { - model: ContextValues; - polish: boolean; -}) { +}: DetailsTabArgs) { const Child = polish ? PolishTab : EnglishTab; return ; diff --git a/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx b/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx index 77a9c51ab..60ed2d551 100644 --- a/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx +++ b/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx @@ -9,7 +9,7 @@ type PartnerBlurb = { type: string; image: string; cost?: string; - verifiedFeatures: string[]; + verifiedFeatures?: string[]; }; type PartnersModel = { @@ -47,7 +47,7 @@ export default function Partners({
- + {title} x - )} + ) as string[]} onClick={onClick} badgeImage={badgeImage} - verifiedFeatures={blurb.verifiedFeatures} + verifiedFeatures={blurb.verifiedFeatures?.join(', ')} analyticsContentType="Partner Profile" /> diff --git a/src/app/pages/details/desktop-view/videos-tab/videos-tab.tsx b/src/app/pages/details/desktop-view/videos-tab/videos-tab.tsx index a897fb6b1..e7c17495e 100644 --- a/src/app/pages/details/desktop-view/videos-tab/videos-tab.tsx +++ b/src/app/pages/details/desktop-view/videos-tab/videos-tab.tsx @@ -1,14 +1,9 @@ import React from 'react'; import RawHTML from '~/components/jsx-helpers/raw-html'; +import type {VideoContent} from '../../context'; import './videos-tab.scss'; -type Video = { - title: string; - description: string; - embed: string; -}; - -export default function VideoTab({videos}: {videos: Video[]}) { +export default function VideoTab({videos}: {videos: VideoContent[]}) { return (
{videos.map(({title, description, embed}) => ( diff --git a/src/app/pages/details/phone-view/details-pane/details-pane.tsx b/src/app/pages/details/phone-view/details-pane/details-pane.tsx index a0481d71f..f830e2ddc 100644 --- a/src/app/pages/details/phone-view/details-pane/details-pane.tsx +++ b/src/app/pages/details/phone-view/details-pane/details-pane.tsx @@ -27,7 +27,7 @@ export default function DetailsPane({ - +
diff --git a/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx b/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx index 3579ce647..d4c28cc84 100644 --- a/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx +++ b/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx @@ -21,19 +21,20 @@ export function InstructorResourcesPane({ const {bookVideoFacultyResources, bookFacultyResources} = useResources( model.slug ); - const bookId = model.id; for (const r of bookFacultyResources) { - r.resource.description = ''; - r.resource.comingSoonText = ''; + if (r.resource) { + r.resource.description = ''; + r.resource.comingSoonText = ''; + } } const featuredResources = bookFacultyResources.filter((r) => r.featured); const featuredModels = featuredResources.map((res) => - resourceBoxModel(res, userStatus, bookId) + resourceBoxModel(res, userStatus, model) ); const referenceModels = bookFacultyResources .filter((r) => r.videoReferenceNumber !== null) - .map((res) => resourceBoxModel(res, userStatus, bookId)); + .map((res) => resourceBoxModel(res, userStatus, model)); const otherModels = bookFacultyResources .filter( (r) => @@ -41,7 +42,7 @@ export function InstructorResourcesPane({ r.videoReferenceNumber === null && r.linkText !== 'View resources' ) - .map((res) => resourceBoxModel(res, userStatus, bookId)); + .map((res) => resourceBoxModel(res, userStatus, model)); const navigate = useNavigate(); function goToPartners(event: React.MouseEvent) { @@ -91,7 +92,7 @@ function StubUnlessDisplayed({ const {innerWidth, scrollY} = useWindowContext(); React.useEffect( - () => setY(y || ref.current?.getBoundingClientRect().y ?? null), + () => setY((y || ref.current?.getBoundingClientRect().y) ?? null), [innerWidth, scrollY, y] ); diff --git a/src/app/pages/details/phone-view/phone-view.tsx b/src/app/pages/details/phone-view/phone-view.tsx index 115704254..5dfbbf76a 100644 --- a/src/app/pages/details/phone-view/phone-view.tsx +++ b/src/app/pages/details/phone-view/phone-view.tsx @@ -17,8 +17,8 @@ const importInstructorPane = () => const importStudentPane = () => import('./student-resources-pane/student-resources-pane'); -function TocPane({model}: {model: ContextValues}) { - const tocHtml = useTableOfContents(model); +function TocPane() { + const tocHtml = useTableOfContents(); return (
@@ -27,23 +27,24 @@ function TocPane({model}: {model: ContextValues}) { ); } -function ErrataPane({model, polish}: {model: ContextValues; polish: boolean}) { +function ErrataPane() { return (
- +
); } type AccordionItem = { - title?: string; + title: string; titleTag?: string; openTitle?: string; - contentComponent?: React.ReactNode; - inline?: React.ReactNode; + contentComponent: React.ReactNode; +} | { + inline: React.ReactNode; }; -function items(model: ContextValues): AccordionItem[] { +function items(model: ContextValues) { const polish = $.isPolish(model.title); const result: AccordionItem[] = polish ? [ @@ -92,11 +93,11 @@ function items(model: ContextValues): AccordionItem[] { if (includeTOC) { result.splice(1, 0, { title: polish ? 'Spis treści' : 'Table of contents', - contentComponent: + contentComponent: }); result.push({ title: polish ? 'Zgłoś erratę' : 'Report errata', - contentComponent: + contentComponent: }); } @@ -111,7 +112,8 @@ export default function PhoneView() { const model = useDetailsContext(); const accordionItems = items(model); const selectedTab = findSelectedTab( - accordionItems.map((i) => i.title).filter(Boolean) as string[] + accordionItems.filter((i) => 'title' in i) + .map((i) => i.title) ); return ( @@ -121,7 +123,7 @@ export default function PhoneView() {
diff --git a/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx b/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx index 98fb2437d..c898cf601 100644 --- a/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx +++ b/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx @@ -22,8 +22,7 @@ function resourceBoxModel( }, studentResourceBoxPermissions( resourceData, - userStatus, - 'Student resources' + userStatus ) ) as ResourceModel; } From f0ec3e64f20617845146ffc95db6281edfe647fc Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Tue, 14 Oct 2025 12:54:55 -0500 Subject: [PATCH 3/3] Test coverage --- .../resource-box/resource-box-utils.tsx | 12 +-- .../partners/partners.tsx | 13 ++- .../instructor-resources-pane.tsx | 12 +-- .../student-resources-pane.tsx | 3 - test/src/data/details-college-algebra.js | 14 ++- test/src/pages/details/details-tab.test.tsx | 36 +++++++ test/src/pages/details/details.test.tsx | 93 ++++++++++++++++++- test/src/pages/details/left-content.test.tsx | 7 +- 8 files changed, 159 insertions(+), 31 deletions(-) create mode 100644 test/src/pages/details/details-tab.test.tsx diff --git a/src/app/pages/details/common/resource-box/resource-box-utils.tsx b/src/app/pages/details/common/resource-box/resource-box-utils.tsx index 4bd6808b3..5f42de0b2 100644 --- a/src/app/pages/details/common/resource-box/resource-box-utils.tsx +++ b/src/app/pages/details/common/resource-box/resource-box-utils.tsx @@ -18,8 +18,8 @@ function encodeLocation(search: string) { } export type ResourceData = { - linkExternal: string; - linkDocumentUrl: string; + linkExternal?: string; + linkDocumentUrl?: string; linkDocument?: { file: string; }; @@ -29,7 +29,7 @@ export type ResourceData = { heading: string; resourceCategory: string; resourceUnlocked: boolean; - creatorFestResource: boolean; + creatorFestResource?: boolean; description: string; comingSoonText?: string; }; @@ -37,10 +37,10 @@ export type ResourceData = { videoReferenceNumber?: number | null; k12?: boolean; printLink: string | null; - resourceUnlocked: boolean; + resourceUnlocked?: boolean; lockedText?: string; - resourceHeading: string; - resourceDescription: string; + resourceHeading?: string; + resourceDescription?: string; featured?: boolean; }; diff --git a/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx b/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx index 60ed2d551..acde31910 100644 --- a/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx +++ b/src/app/pages/details/desktop-view/instructor-resource-tab/partners/partners.tsx @@ -1,6 +1,7 @@ import React from 'react'; -import {useNavigate} from 'react-router-dom'; +import {NavigateOptions, useNavigate} from 'react-router-dom'; import PartnerCard from '~/components/partner-card/partner-card'; +import {assertNotNull} from '~/helpers/data'; import './partners.scss'; type PartnerBlurb = { @@ -32,12 +33,10 @@ export default function Partners({ (event: React.MouseEvent) => { const destUrl = event.currentTarget.getAttribute('href'); - if (destUrl) { - navigate(destUrl, { - book: bookAbbreviation, - redirect: true - } as never); - } + navigate(assertNotNull(destUrl), { + book: bookAbbreviation, + redirect: true + } as NavigateOptions); event.preventDefault(); }, [navigate, bookAbbreviation] diff --git a/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx b/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx index d4c28cc84..a80e9e540 100644 --- a/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx +++ b/src/app/pages/details/phone-view/instructor-resources-pane/instructor-resources-pane.tsx @@ -1,7 +1,7 @@ import React from 'react'; import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; import {faSignOutAlt} from '@fortawesome/free-solid-svg-icons/faSignOutAlt'; -import {useNavigate} from 'react-router-dom'; +import {NavigateOptions, useNavigate} from 'react-router-dom'; import {resourceBoxModel, useResources} from '../../common/resource-box/resource-box-utils'; import FeaturedResourcesSection from '../../common/featured-resources/featured-resources'; import ResourceBoxes from '../../common/resource-box/resource-boxes'; @@ -10,6 +10,7 @@ import useUserContext, {type UserStatus} from '~/contexts/user'; import useWindowContext, {WindowContextProvider} from '~/contexts/window'; import type {ContextValues} from '../../context'; import './instructor-resources-pane.scss'; +import {assertNotNull} from '~/helpers/data'; export function InstructorResourcesPane({ model, @@ -50,7 +51,7 @@ export function InstructorResourcesPane({ navigate('/partners', { book: model.salesforceAbbreviation, redirect: true - } as never); + } as NavigateOptions); } return ( @@ -62,7 +63,7 @@ export function InstructorResourcesPane({ models={featuredModels} /> )} -
+ OpenStax Partners
setY((y || ref.current?.getBoundingClientRect().y) ?? null), + () => setY(y || assertNotNull(ref.current).getBoundingClientRect().y), [innerWidth, scrollY, y] ); @@ -112,9 +113,6 @@ export default function LoadUserStatusThenInstructorPane({ }) { const {userStatus} = useUserContext(); - if (!userStatus) { - return null; - } return ( diff --git a/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx b/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx index c898cf601..b1837b1d9 100644 --- a/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx +++ b/src/app/pages/details/phone-view/student-resources-pane/student-resources-pane.tsx @@ -52,8 +52,5 @@ export default function LoadUserStatusThenStudentPane({ }) { const {userStatus} = useUserContext(); - if (!userStatus) { - return null; - } return ; } diff --git a/test/src/data/details-college-algebra.js b/test/src/data/details-college-algebra.js index 8bfa44107..dbfbb8aa3 100644 --- a/test/src/data/details-college-algebra.js +++ b/test/src/data/details-college-algebra.js @@ -1313,7 +1313,19 @@ const details = { }, "partner_list_label": "Technology Partners", "partner_page_link_text": "See all partners", - "videos": [], + "videos": [ + { + "type": "video", + "value": [ + { + "title": "Interface", + "description": "

As the world’s largest producer of carpet tile and other flooring materials, Interface is everywhere. Their modular products foster a great degree of creativity and innovation in interior design and function, and allow their clients to have a positive impact on the world around them. Interface’s sustainability principles and initiatives -- Mission Zero and Climate Take Back -- have led to powerful community engagement and revolutionary technological enhancements.

This video is part of the OpenStax Business series, which showcases entrepreneurs and businesses with a focus on purpose, principles, and best business practices. It can be used with any OpenStax business textbook.

", + "embed": "
\"\"
" + } + ], + "id": "28d0ee63-04e0-4128-ba2b-9239b7da5e4e" + } + ], "promote_image": null, "last_updated_pdf": null, "featured_resources_header": null diff --git a/test/src/pages/details/details-tab.test.tsx b/test/src/pages/details/details-tab.test.tsx new file mode 100644 index 000000000..54ab6a0f5 --- /dev/null +++ b/test/src/pages/details/details-tab.test.tsx @@ -0,0 +1,36 @@ +// details-tab is dynamically loaded, so needs to be tested separately +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import DetailsTab from '~/pages/details/desktop-view/details-tab/details-tab'; +import {LanguageContextProvider} from '~/contexts/language'; + +type Props = Parameters[0]; + +jest.mock('~/pages/details/common/get-this-title'); + +const model = { + id: 2, + title: 'model-title', + description: 'model-description', + adoptions: 69, + slug: 'whatever' +} as Props['model']; + +function Component(props: Props) { + return ( + + + + ); +} + +describe('details-tab', () => { + it('renders (English)', () => { + render(); + screen.getByRole('heading', {level: 3, name: 'Summary'}); + }); + it('renders (Polish)', () => { + render(); + screen.getByRole('heading', {level: 3, name: 'Przejdź do książki'}); + }); +}); diff --git a/test/src/pages/details/details.test.tsx b/test/src/pages/details/details.test.tsx index 88b79b341..1267f4a81 100644 --- a/test/src/pages/details/details.test.tsx +++ b/test/src/pages/details/details.test.tsx @@ -8,6 +8,7 @@ import ShellContextProvider from '~/../../test/helpers/shell-context'; import * as DH from '~/helpers/use-document-head'; import $ from '~/helpers/$'; import * as WC from '~/contexts/window'; +import * as RBU from '~/pages/details/common/resource-box/resource-box-utils'; // Tamp down meaningless errors jest.mock('~/models/rex-release', () => @@ -23,14 +24,15 @@ jest.mock('~/models/table-of-contents-html', () => jest.fn().mockReturnValue(Promise.resolve({})) ); +jest.spyOn(window, 'scrollBy').mockImplementation(() => null); jest.spyOn(DH, 'setPageTitleAndDescriptionFromBookData').mockReturnValue(); const spyIsPolish = jest.spyOn($, 'isPolish'); const spyWindowContext = jest.spyOn(WC, 'default'); -function Component() { +function Component({path='/details/books/college-algebra'}) { return ( - + { + const user = userEvent.setup(); + const saveWarn = console.warn; + beforeEach(() => { document.head.innerHTML = ''; const el = document.createElement('meta'); @@ -65,20 +70,64 @@ describe('Details page', () => { el.setAttribute('name', 'description'); document.head.appendChild(el); }); + console.debug = jest.fn(); - it('renders book', async () => { + it('renders book with video data', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any spyWindowContext.mockReturnValue({innerWidth: 1280} as any); render(); await finishedRendering(); expect(lengthOfView('phone')).toBeUndefined(); - expect(lengthOfView('bigger')).toBe(98); + expect(lengthOfView('bigger')).toBe(794); const jsonLdScript = document.head.querySelector('script'); expect(jsonLdScript?.textContent).toEqual( expect.stringContaining('mainEntity') ); + const tabs = screen.getAllByRole('tab'); + + expect(tabs).toHaveLength(4); + // These do not seem to update the tab state as expected, though they + // do exercise some code. + await user.click(tabs[1]); + }); + it('renders with Student tab selected', async () => { + const mockLocation = jest.spyOn(window, 'location', 'get').mockReturnValue({ + ...window.location, + search: '?Student%20resources' + }); + + render(); + const tabs = await screen.findAllByRole('tab'); + + expect(tabs[2].getAttribute('aria-selected')).toBe('true'); + await user.click(tabs[1]); + mockLocation.mockRestore(); + }); + it('renders with Instructor tab selected', async () => { + jest.spyOn(RBU, 'useResources').mockReturnValue({ + bookVideoFacultyResources: [], + bookFacultyResources: [] + }); + const mockLocation = jest.spyOn(window, 'location', 'get').mockReturnValue({ + ...window.location, + search: '?Instructor%20resources' + }); + + render(); + await finishedRendering(); + const tabs = screen.getAllByRole('tab'); + + expect(tabs[1].getAttribute('aria-selected')).toBe('true'); + await user.click(tabs[2]); + + screen.getByRole('heading', {name: 'Technology Partners'}); + console.warn = jest.fn(); + await user.click(screen.getByRole('link', {name: 'MagicBox E-Reader'})); + expect(console.warn).toHaveBeenCalled(); + console.warn = saveWarn; + mockLocation.mockRestore(); }); it('renders Polish book', async () => { spyIsPolish.mockReturnValue(true); @@ -104,11 +153,46 @@ describe('Details page', () => { ); }); it('renders only phone-view at phone width', async () => { + jest.spyOn(RBU, 'useResources').mockReturnValue({ + bookVideoFacultyResources: [], + bookFacultyResources: [ + { + featured: true, + linkText: 'Link text', + comingSoonText: '', + printLink: 'print-link', + videoReferenceNumber: 13 + }, + { + featured: false, + linkText: 'Link text2', + comingSoonText: '', + printLink: 'print-link2', + videoReferenceNumber: null, + resource: { + id: 1, + heading: 'resource-heading', + resourceCategory: 'any', + resourceUnlocked: true, + description: 'resource-description' + } + } + ] + }); spyWindowContext.mockReturnValue({innerWidth: 480} as any); // eslint-disable-line render(); await finishedRendering(); expect(lengthOfView('phone')).toBe(346); expect(lengthOfView('bigger')).toBeUndefined(); + + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ + y: 100 + } as any); // eslint-disable-line + await user.click(screen.getByRole('button', {name: 'Instructor resources updated'})); + console.warn = jest.fn(); + await user.click(await screen.findByRole('link', {name: 'OpenStax Partners'})); + expect(console.warn).toHaveBeenCalled(); + console.warn = saveWarn; }); it('toggles authors at phone width', async () => { spyWindowContext.mockReturnValue({innerWidth: 480} as any); // eslint-disable-line @@ -116,7 +200,6 @@ describe('Details page', () => { await finishedRendering(); const authorToggle = await screen.findByText('Authors'); const detailsEl = authorToggle.closest('details'); - const user = userEvent.setup(); await user.click(authorToggle); expect(detailsEl?.open).toBe(true); diff --git a/test/src/pages/details/left-content.test.tsx b/test/src/pages/details/left-content.test.tsx index 57865e92b..383e6e89a 100644 --- a/test/src/pages/details/left-content.test.tsx +++ b/test/src/pages/details/left-content.test.tsx @@ -19,8 +19,11 @@ describe('left-content', () => { comingSoon: false, iconType: 'lock', heading: 'heading', - double: false - } as unknown as ModelType; // incomplete, but it's enough for testing + double: false, + bookModel: { + id: 1 + } + } as ModelType; // incomplete, but it's enough for testing const link = {url: '#good-url', text: 'button-label'}; // Setup option prevents await click from hanging when using faketimers const user = userEvent.setup({advanceTimers: jest.advanceTimersByTime});