diff --git a/src/app/components/jsx-helpers/raw-html.tsx b/src/app/components/jsx-helpers/raw-html.tsx index 585c67d14..7456aa5bf 100644 --- a/src/app/components/jsx-helpers/raw-html.tsx +++ b/src/app/components/jsx-helpers/raw-html.tsx @@ -38,7 +38,9 @@ type RawHTMLArgs = { Tag?: string; html?: TrustedHTML; embed?: boolean; -} & React.HTMLAttributes; +} & React.HTMLAttributes & { + href?: string; +}; export default function RawHTML({ Tag = 'div', diff --git a/src/app/layouts/default/default.tsx b/src/app/layouts/default/default.tsx index 86c251f0e..da5c2cf9f 100644 --- a/src/app/layouts/default/default.tsx +++ b/src/app/layouts/default/default.tsx @@ -9,7 +9,6 @@ import useMainClassContext, { } from '~/contexts/main-class'; import useLanguageContext from '~/contexts/language'; import ReactModal from 'react-modal'; -import Welcome from './welcome/welcome'; import TakeoverDialog from './takeover-dialog/takeover-dialog'; import cn from 'classnames'; import './default.scss'; @@ -53,7 +52,6 @@ function Main({children}: React.PropsWithChildren) { ref={ref} tabIndex={-1} > - {children} diff --git a/src/app/layouts/default/footer/copyright.tsx b/src/app/layouts/default/footer/copyright.tsx index 3efb85317..d77bf4bc8 100644 --- a/src/app/layouts/default/footer/copyright.tsx +++ b/src/app/layouts/default/footer/copyright.tsx @@ -7,9 +7,7 @@ type Props = { } export default function Copyright({copyright, apStatement}: Props) { - const updatedCopyright = copyright - ? copyright.replace(/-\d+/, `-${new Date().getFullYear()}`) - : copyright; + const updatedCopyright = copyright?.replace(/-\d+/, `-${new Date().getFullYear()}`); return ( diff --git a/src/app/layouts/default/header/header.js b/src/app/layouts/default/header/header.tsx similarity index 67% rename from src/app/layouts/default/header/header.js rename to src/app/layouts/default/header/header.tsx index 809a40b6e..b4b9d1a31 100644 --- a/src/app/layouts/default/header/header.js +++ b/src/app/layouts/default/header/header.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import JITLoad from '~/helpers/jit-load'; +import StickyNote from './sticky-note/sticky-note'; import {useStickyData} from '../shared'; import Menus from './menus/menus'; import './header.scss'; @@ -9,7 +9,7 @@ export default function Header() { return (
- import('./sticky-note/sticky-note.js')} stickyData={stickyData} /> +
); diff --git a/src/app/layouts/default/header/menus/dropdown-context.tsx b/src/app/layouts/default/header/menus/dropdown-context.tsx index c8deb6778..2344e82f3 100644 --- a/src/app/layouts/default/header/menus/dropdown-context.tsx +++ b/src/app/layouts/default/header/menus/dropdown-context.tsx @@ -3,7 +3,7 @@ import {useState} from 'react'; function useContextValue({prefix} = {prefix: 'menulabel'}) { const [activeDropdown, setActiveDropdown] = useState< - React.MutableRefObject | Record + React.MutableRefObject | Record >({}); const [submenuLabel, setSubmenuLabel] = useState(); diff --git a/src/app/layouts/default/header/menus/give-button/give-button.js b/src/app/layouts/default/header/menus/give-button/give-button.tsx similarity index 100% rename from src/app/layouts/default/header/menus/give-button/give-button.js rename to src/app/layouts/default/header/menus/give-button/give-button.tsx diff --git a/src/app/layouts/default/header/menus/logo/logo.js b/src/app/layouts/default/header/menus/logo/logo.tsx similarity index 100% rename from src/app/layouts/default/header/menus/logo/logo.js rename to src/app/layouts/default/header/menus/logo/logo.tsx diff --git a/src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.js b/src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.tsx similarity index 80% rename from src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.js rename to src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.tsx index 69f4732bd..c3d2397a7 100644 --- a/src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.js +++ b/src/app/layouts/default/header/menus/main-menu/dropdown/dropdown.tsx @@ -15,7 +15,11 @@ import './dropdown.scss'; // for ordinary website navigations, per // https://www.w3.org/WAI/ARIA/apg/patterns/menubar/examples/menubar-navigation/ -export function MenuItem({label, url, local=undefined}) { +export function MenuItem({label, url, local = undefined}: { + label: string; + url: string; + local?: string; +}) { const {innerWidth: _} = useWindowContext(); const urlPath = url.replace('/view-all', ''); const {pathname} = useLocation(); @@ -33,7 +37,10 @@ export function MenuItem({label, url, local=undefined}) { ); } -function OptionalWrapper({isWrapper = true, children}) { +function OptionalWrapper({isWrapper = true, children}: { + isWrapper?: boolean; + children: React.ReactNode; +}) { return isWrapper ? (
{children}
) : ( @@ -41,6 +48,15 @@ function OptionalWrapper({isWrapper = true, children}) { ); } +type DropdownProps = { + Tag?: keyof JSX.IntrinsicElements; + className?: string; + label: string; + children: React.ReactNode; + excludeWrapper?: boolean; + navAnalytics?: string; +}; + export default function Dropdown({ Tag = 'li', className = undefined, @@ -48,9 +64,9 @@ export default function Dropdown({ children, excludeWrapper = false, navAnalytics -}) { - const topRef = useRef(); - const dropdownRef = useRef(null); +}: DropdownProps) { + const topRef = useRef(null); + const dropdownRef = useRef(null); const ddId = `ddId-${label}`; const { closeMenu, closeDesktopMenu, openMenu, openDesktopMenu @@ -98,12 +114,19 @@ function DropdownController({ closeMenu, openMenu, label +}: { + ddId: string; + closeDesktopMenu: () => void; + topRef: React.RefObject; + closeMenu: () => void; + openMenu: (event: React.MouseEvent) => void; + label: string; }) { const {activeDropdown, prefix} = useDropdownContext(); const isOpen = activeDropdown === topRef; const labelId = `${prefix}-${label}`; const toggleMenu = React.useCallback( - (event) => { + (event: React.MouseEvent) => { if (activeDropdown === topRef) { event.preventDefault(); closeMenu(); @@ -114,8 +137,8 @@ function DropdownController({ [openMenu, closeMenu, activeDropdown, topRef] ); const closeOnBlur = React.useCallback( - ({currentTarget, relatedTarget}) => { - if (currentTarget.parentNode.contains(relatedTarget)) { + (event: React.FocusEvent) => { + if (event.currentTarget.parentNode?.contains(event.relatedTarget as Node)) { return; } closeDesktopMenu(); @@ -154,7 +177,13 @@ function DropdownController({ ); } -function DropdownContents({id, label, dropdownRef, navAnalytics, children}) { +function DropdownContents({id, label, dropdownRef, navAnalytics, children}: { + id: string; + label: string; + dropdownRef: React.RefObject; + navAnalytics?: string; + children: React.ReactNode; +}) { return (
; + topRef: React.MutableRefObject; label: string; }) { const {setSubmenuLabel, setActiveDropdown} = useDropdownContext(); diff --git a/src/app/layouts/default/header/menus/main-menu/dropdown/use-navigate-by-key.ts b/src/app/layouts/default/header/menus/main-menu/dropdown/use-navigate-by-key.ts index 2ed36ed99..adeb7eada 100644 --- a/src/app/layouts/default/header/menus/main-menu/dropdown/use-navigate-by-key.ts +++ b/src/app/layouts/default/header/menus/main-menu/dropdown/use-navigate-by-key.ts @@ -1,13 +1,13 @@ import useDropdownContext from '../../dropdown-context'; import {isMobileDisplay} from '~/helpers/device'; -function findNext(dropdownRef: React.MutableRefObject) { +function findNext(dropdownRef: React.RefObject) { const nextSib = document.activeElement?.nextElementSibling; if (nextSib?.matches('a')) { return nextSib as HTMLAnchorElement; } - const targets = Array.from(dropdownRef.current.querySelectorAll('a')); + const targets = dropdownRef.current ? Array.from(dropdownRef.current.querySelectorAll('a')) : []; const idx = targets.indexOf(document.activeElement as HTMLAnchorElement); const nextIdx = (idx + 1) % targets.length; @@ -15,15 +15,15 @@ function findNext(dropdownRef: React.MutableRefObject) { } function findPrev( - topRef: React.MutableRefObject, - dropdownRef: React.MutableRefObject + topRef: React.RefObject, + dropdownRef: React.RefObject ) { const prevSib = document.activeElement?.previousElementSibling; if (prevSib?.matches('a')) { return prevSib as HTMLAnchorElement; } - const targets = Array.from(dropdownRef.current.querySelectorAll('a')); + const targets = dropdownRef.current ? Array.from(dropdownRef.current.querySelectorAll('a')) : []; const idx = targets.indexOf(document.activeElement as HTMLAnchorElement); if (idx === 0) { @@ -40,8 +40,8 @@ export default function useNavigateByKey({ closeMenu, closeDesktopMenu }: { - topRef: React.MutableRefObject; - dropdownRef: React.MutableRefObject; + topRef: React.MutableRefObject; + dropdownRef: React.MutableRefObject; closeMenu: () => void; closeDesktopMenu: () => void; }) { @@ -68,8 +68,8 @@ export default function useNavigateByKey({ switch (event.key) { case 'ArrowDown': event.preventDefault(); - if (document.activeElement === topRef.current) { - (dropdownRef.current.firstChild as HTMLAnchorElement)?.focus(); + if (document.activeElement === topRef?.current) { + (dropdownRef.current?.firstChild as HTMLAnchorElement)?.focus(); } else { findNext(dropdownRef).focus(); } @@ -77,7 +77,7 @@ export default function useNavigateByKey({ case 'ArrowUp': event.preventDefault(); if (document.activeElement !== topRef.current) { - findPrev(topRef, dropdownRef).focus(); + findPrev(topRef, dropdownRef)?.focus(); } break; case 'Escape': diff --git a/src/app/layouts/default/header/menus/main-menu/login-menu/load-login-menu-with-dropdown.js b/src/app/layouts/default/header/menus/main-menu/login-menu/load-login-menu-with-dropdown.js new file mode 100644 index 000000000..10b5d9c16 --- /dev/null +++ b/src/app/layouts/default/header/menus/main-menu/login-menu/load-login-menu-with-dropdown.js @@ -0,0 +1,3 @@ +import LoginMenuWithDropdown from './login-menu-with-dropdown'; + +export default LoginMenuWithDropdown; diff --git a/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.js b/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.tsx similarity index 89% rename from src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.js rename to src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.tsx index 309b3827b..f02a57ee5 100644 --- a/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.js +++ b/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu-with-dropdown.tsx @@ -3,8 +3,10 @@ import {useLocation} from 'react-router-dom'; import useUserContext from '~/contexts/user'; import linkHelper from '~/helpers/link'; import Dropdown, {MenuItem} from '../dropdown/dropdown'; +import getSettings from '~/helpers/window-settings'; +import {assertDefined} from '~/helpers/data'; -const settings = window.SETTINGS; +const settings = getSettings(); const reqFacultyAccessLink = `${settings.accountHref}/i/signup/educator/cs_form`; const profileLink = `${settings.accountHref}/profile`; @@ -21,7 +23,7 @@ function AccountItem() { export default function LoginMenuWithDropdown() { - const {userModel} = useUserContext(); + const userModel = assertDefined(useUserContext().userModel); // updates logoutLink useLocation(); diff --git a/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.js b/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.tsx similarity index 74% rename from src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.js rename to src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.tsx index f25f0b325..3c36fd314 100644 --- a/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.js +++ b/src/app/layouts/default/header/menus/main-menu/login-menu/login-menu.tsx @@ -8,9 +8,9 @@ function LoginLink() { // It's not used directly, but loginLink changes when it does useLocation(); const addressHinkyQAIssue = React.useCallback( - (e) => { - if (e.defaultPrevented) { - e.defaultPrevented = false; + (e: React.MouseEvent) => { + if ((e as React.MouseEvent & {defaultPrevented: boolean}).defaultPrevented) { + (e as React.MouseEvent & {defaultPrevented: boolean}).defaultPrevented = false; } }, [] @@ -34,7 +34,7 @@ export default function LoginMenu() { return ( loggedIn ? - import('./login-menu-with-dropdown')} /> : + import('./load-login-menu-with-dropdown.js')} /> : ); } diff --git a/src/app/layouts/default/header/menus/main-menu/main-menu.js b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx similarity index 74% rename from src/app/layouts/default/header/menus/main-menu/main-menu.js rename to src/app/layouts/default/header/menus/main-menu/main-menu.tsx index ad7826eaa..97fe87715 100644 --- a/src/app/layouts/default/header/menus/main-menu/main-menu.js +++ b/src/app/layouts/default/header/menus/main-menu/main-menu.tsx @@ -14,36 +14,41 @@ import GiveButton from '../give-button/give-button'; import {treatSpaceOrEnterAsClick} from '~/helpers/events'; import './main-menu.scss'; -function DropdownOrMenuItem({item}) { - if (! item.name && ! item.label) { - return null; - } - if ('menu' in item) { - return ( - - - - ); - } +type MenuDropDown = { + name: string; + menu: MenuItemData[]; +} +type MenuItemData = { + label: string; + partial_url: string; +}; - return ; +function DropdownItem({item}: {item: MenuDropDown}) { + return ( + + {item.menu.map((menuItem) => ( + + ))} + + ); } -function MenusFromStructure({structure}) { + +function MenusFromStructure({structure}: {structure: MenuDropDown[]}) { return ( {structure.map((item) => ( - + ))} ); } function MenusFromCMS() { - const structure = useDataFromSlug('oxmenus'); + const structure = useDataFromSlug('oxmenus') as MenuDropDown[] | null; if (!structure) { return null; @@ -60,7 +65,7 @@ function SubjectsMenu() { const categories = useSubjectCategoryContext(); const {language} = useLanguageContext(); // This will have to be revisited if/when we implement more languages - const otherLocale = ['en', 'es'].filter((la) => la !== language)[0]; + const otherLocale = (['en', 'es'] as const).filter((la) => la !== language)[0]; const {pathname} = useLocation(); if (!categories.length) { @@ -104,23 +109,26 @@ function SubjectsMenu() { ); } -function navigateWithArrows(event) { +// eslint-disable-next-line complexity +function navigateWithArrows(event: React.KeyboardEvent) { + const target = event.target as HTMLElement; + switch (event.key) { case 'ArrowRight': event.preventDefault(); event.stopPropagation(); - event.target + (target .closest('li') - .nextElementSibling?.querySelector('a') - .focus(); + ?.nextElementSibling?.querySelector('a') as HTMLAnchorElement) + ?.focus(); break; case 'ArrowLeft': event.preventDefault(); event.stopPropagation(); - event.target + (target .closest('li') - .previousElementSibling?.querySelector('a') - .focus(); + ?.previousElementSibling?.querySelector('a') as HTMLAnchorElement) + ?.focus(); break; default: break; diff --git a/src/app/layouts/default/header/menus/menu-expander/menu-expander.js b/src/app/layouts/default/header/menus/menu-expander/menu-expander.tsx similarity index 57% rename from src/app/layouts/default/header/menus/menu-expander/menu-expander.js rename to src/app/layouts/default/header/menus/menu-expander/menu-expander.tsx index 6d6e9dc4b..1ecd48186 100644 --- a/src/app/layouts/default/header/menus/menu-expander/menu-expander.js +++ b/src/app/layouts/default/header/menus/menu-expander/menu-expander.tsx @@ -4,38 +4,43 @@ import {useLocation} from 'react-router-dom'; import {treatSpaceOrEnterAsClick} from '~/helpers/events'; import './menu-expander.scss'; -function useCloseOnLocationChange(onClick, active) { +function useCloseOnLocationChange(toggleActive: (v?: boolean) => void, active: boolean) { const location = useLocation(); const {setActiveDropdown} = useDropdownContext(); - const activeRef = React.useRef(); + const activeRef = React.useRef(); activeRef.current = active; React.useEffect(() => { if (activeRef.current) { - onClick({}); + toggleActive(false); setActiveDropdown({}); } - }, [location, onClick, setActiveDropdown]); + }, [location, toggleActive, setActiveDropdown]); } -export default function MenuExpander({active, onClick, ...props}) { +type MenuExpanderProps = { + active: boolean; + toggleActive: (v?: boolean) => void; +} & React.ButtonHTMLAttributes; + +export default function MenuExpander({active, toggleActive, ...props}: MenuExpanderProps) { const onClickAndBlur = React.useCallback( - (event) => { - onClick(event); + (event: React.MouseEvent) => { + toggleActive(); event.currentTarget.blur(); }, - [onClick] + [toggleActive] ); - useCloseOnLocationChange(onClick, active); + useCloseOnLocationChange(toggleActive, active); return ( - -
- ); -} - -function CloseButton({welcomeDone}) { - return ( -
- -
- ); -} - -const HOUR_IN_MS = 3600000; - -function dialogData(isNew, isFaculty, firstName) { - if (isNew) { - return isFaculty ? - { - title: `Welcome to OpenStax, ${firstName}!`, - body: `

Your books, additional resources, and profile information are - saved here.

-

To get a quick walkthrough of My OpenStax, click “Show me around” – - or skip the tour and explore on your own.

`, - walkthrough: true - } : - { - title: `Thanks, ${firstName}!`, - body: `You’re just clicks away from accessing free textbooks and - resources. Take full advantage of your OpenStax account by using - features like highlighting and notetaking in our digital reading - experience.`, - walkthrough: false - }; - } - return isFaculty ? - { - title: `Welcome back, ${firstName}!`, - body: `

Your account has been upgraded to My OpenStax, a new, personalized - dashboard to help you navigate our website.

-

Take a quick walkthrough of My OpenStax, or skip this and explore on your own.

`, - walkthrough: true - } : null; -} - -function CustomDialog({data, welcomeDone}) { - const [Dialog] = useDialog(true); - - return ( - - - {data.walkthrough ? - : - } - - ); -} - -export default function Welcome() { - const [showWelcome, welcomeDone] = React.useReducer( - () => Cookies.set('hasBeenWelcomed', 'true'), - !Cookies.get('hasBeenWelcomed') - ); - const {createdAt, firstName, role} = useAccount(); - - if (!showWelcome || !firstName) { - return null; - } - - const elapsedHours = (Date.now() - new Date(createdAt)) / HOUR_IN_MS; - const isNew = elapsedHours < 1000; // *** CHANGE THIS TO LIKE 24 - const isFaculty = role === 'Faculty'; - const data = dialogData(isNew, isFaculty, firstName); - - return ( - - ); -} diff --git a/src/app/layouts/default/welcome/welcome.js b/src/app/layouts/default/welcome/welcome.js deleted file mode 100644 index f781f0f9d..000000000 --- a/src/app/layouts/default/welcome/welcome.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import JITLoad from '~/helpers/jit-load'; -import useSharedDataContext from '../../../contexts/shared-data'; -// import cookie from '~/helpers/cookie'; // If using the TESTING lines - -/* - There should be two values in the cookie - 1. Has the user been welcomed? - 2. Is the user a new account? - If already welcomed, done. - If the user is a new account, show the welcome message for new - student or educator account - If an existing account and an educator, show a message for that - Students can be navigated to subjects -*/ - -// FOR TESTING -- these lines reset the welcome and walkthrough cookies -// if ((/dev|local/).test(window.location.hostname)) { -// console.info('Resetting welcome and walkthrough cookies'); // Leave this -// cookie.deleteKey('hasBeenWelcomed'); -// cookie.deleteKey('walkthroughDone'); -// } - -export default function WelcomeStoreWrapper() { - const {flags: {my_openstax: isEnabled}} = useSharedDataContext(); - - if (!isEnabled) { - return null; - } - - return ( - import('./welcome-content.js')} /> - ); -} diff --git a/src/app/layouts/default/welcome/welcome.scss b/src/app/layouts/default/welcome/welcome.scss deleted file mode 100644 index e21a47306..000000000 --- a/src/app/layouts/default/welcome/welcome.scss +++ /dev/null @@ -1,51 +0,0 @@ -@import 'pattern-library/core/pattern-library/headers'; - -.welcome-dialog { - .title-bar { - background-color: ui-color(page-bg); - } - - .main-region { - display: flex; - flex-direction: column; - gap: 2rem; - max-width: 54rem; - - @include width-up-to($phone-max) { - padding: $normal-margin; - } - - @include wider-than($phone-max) { - padding: 3rem; - } - - p:first-child { - margin-top: 0; - } - p:last-child { - margin-bottom: 0; - } - } - - .button-row { - display: flex; - gap: 2rem; - - @include width-up-to($phone-max) { - flex-direction: column; - } - - @include wider-than($phone-max) { - align-self: flex-end; - flex-direction: row; - } - } - - button:not(.put-away) { - @include button(); - - &.primary { - @extend %primary; - } - } -} diff --git a/src/app/main.js b/src/app/main.js index c7a0ce24f..21aa9405c 100644 --- a/src/app/main.js +++ b/src/app/main.js @@ -6,6 +6,16 @@ const GLOBAL_SETTINGS = ['piAId', 'piCId', 'piHostname']; window.SETTINGS = {}; +// Shim for incognito windows that disable localStorage +window.localStorage ??= { + getItem(key) {return window.localStorage[key];}, + setItem() {}, + removeItem() {}, + clear() {}, + key() {return null;}, + length: 0 +}; + (async () => { const settings = (await cmsFetch('webview-settings')).settings; diff --git a/src/app/models/accounts-model.ts b/src/app/models/accounts-model.ts index 20d36dfc1..45f2b7ebd 100644 --- a/src/app/models/accounts-model.ts +++ b/src/app/models/accounts-model.ts @@ -33,6 +33,7 @@ export type AccountsUserModel = { salesforce_contact_id: string; is_instructor_verification_stale: boolean; faculty_status: string; + using_openstax: boolean; contact_infos: { type: string; value: string; diff --git a/test/src/components/shell.test.tsx b/test/src/components/shell.test.tsx index f69822166..50f0441ba 100644 --- a/test/src/components/shell.test.tsx +++ b/test/src/components/shell.test.tsx @@ -10,7 +10,6 @@ import * as GP from '~/pages/general/general'; import * as LDH from '~/layouts/default/header/header'; import * as MM from '~/layouts/default/header/menus/menus'; import * as MSP from '~/layouts/default/microsurvey-popup/microsurvey-popup'; -import * as WC from '~/layouts/default/welcome/welcome-content'; import * as TD from '~/layouts/default/takeover-dialog/takeover-dialog'; import * as LSN from '~/layouts/default/lower-sticky-note/lower-sticky-note'; import * as DH from '~/helpers/use-document-head'; @@ -89,7 +88,6 @@ describe('shell', () => { jest.spyOn(LDH, 'default').mockReturnValue(<>); jest.spyOn(MM, 'default').mockReturnValue(<>); jest.spyOn(MSP, 'default').mockReturnValue(null); - jest.spyOn(WC, 'default').mockReturnValue(null); jest.spyOn(TD, 'default').mockReturnValue(null); jest.spyOn(LSN, 'default').mockReturnValue(null); jest.spyOn(DH, 'setPageDescription').mockReturnValue(undefined); diff --git a/test/src/contexts/layout.test.tsx b/test/src/contexts/layout.test.tsx index 3248dcd33..f8b833b4b 100644 --- a/test/src/contexts/layout.test.tsx +++ b/test/src/contexts/layout.test.tsx @@ -59,7 +59,6 @@ jest.useFakeTimers(); jest.mock('~/layouts/default/microsurvey-popup/microsurvey-popup', () => jest.fn()); jest.mock('~/layouts/default/header/header', () => jest.fn()); jest.mock('~/layouts/default/lower-sticky-note/lower-sticky-note', () => jest.fn()); -jest.mock('~/layouts/default/welcome/welcome', () => jest.fn()); jest.mock('~/layouts/default/footer/footer', () => jest.fn()); jest.mock('~/layouts/default/takeover-dialog/takeover-dialog', () => jest.fn()); diff --git a/test/src/layouts/default/data/footer.json b/test/src/layouts/default/data/footer.json new file mode 100644 index 000000000..9295be567 --- /dev/null +++ b/test/src/layouts/default/data/footer.json @@ -0,0 +1,8 @@ +{ + "supporters": "

OpenStax\u2019s mission is to make an amazing education accessible for all.

\r\n

\r\nOpenStax is part of Rice University, which is a 501(c)(3) nonprofit. Give today and help us reach more students.\r\n

", + "copyright": "\u00a9 1999-2022, Rice University. Except where otherwise noted,\u00a0textbooks on this site are licensed under a Creative Commons Attribution\u00a04.0 International License.", + "ap_statement": "Advanced Placement\u00ae and AP\u00ae are trademarks registered and/or\r\nowned by the College Board, which is not affiliated with, and does not endorse, this site.", + "facebook_link": "https://www.facebook.com/openstax", + "twitter_link": "https://twitter.com/openstax", + "linkedin_link": "https://www.linkedin.com/company/openstax" +} diff --git a/test/src/layouts/default/data/fundraiser.json b/test/src/layouts/default/data/fundraiser.json new file mode 100644 index 000000000..0206a0320 --- /dev/null +++ b/test/src/layouts/default/data/fundraiser.json @@ -0,0 +1,15 @@ +[ + { + "color_scheme": "orange", + "message_type": "message", + "headline": "Congratulations, OpenStax Class of 2025!", + "message": "Every year, millions of students use OpenStax resources to get an amazing education and launch the next phase of their careers. Now, let’s help the next generation reach the graduation stage.", + "button_text": "Celebrate grads. Support the future.", + "button_url": "https://riceconnect.rice.edu/donation/openstax-homepage-takeover", + "box_headline": "Education changes everything. Help it stay free.", + "box_html": "\"OpenStax reignited my passion for learning and opened doors to a future I once only dreamed of.” — Julie, OpenStax Class of 2025", + "fundraiser_image": "https://assets.openstax.org/oscms-prodcms/media/graduation_homepage_takeover-2.jpg", + "goal_amount": 0, + "goal_time": "2025-05-21T20:00:00-05:00" + } +] diff --git a/test/src/layouts/default/data/give-today.json b/test/src/layouts/default/data/give-today.json new file mode 100644 index 000000000..7bee4aefe --- /dev/null +++ b/test/src/layouts/default/data/give-today.json @@ -0,0 +1,8 @@ +{ + "give_link_text": "Give", + "give_link": "https://riceconnect.rice.edu/donation/support-openstax-header", + "start": "2023-04-04T05:00:00Z", + "expires": "2023-04-05T05:00:00Z", + "menu_start": "2023-12-14T06:00:00Z", + "menu_expires": "2026-04-01T23:00:00Z" +} diff --git a/test/src/layouts/default/data/osmenu.json b/test/src/layouts/default/data/osmenu.json new file mode 100644 index 000000000..397c5d1b8 --- /dev/null +++ b/test/src/layouts/default/data/osmenu.json @@ -0,0 +1,35 @@ +[ + { + "name": "Technology", + "menu": [ + {"label": "OpenStax Assignable", "partial_url": "/assignable"}, + {"label": "OpenStax Kinetic", "partial_url": "/kinetic"}, + { + "label": "OpenStax Technology Partners", + "partial_url": "/partners" + }, + { + "label": "SafeInsights", + "partial_url": "https://www.safeinsights.org/" + } + ] + }, + { + "name": "What we do", + "menu": [ + {"label": "About us", "partial_url": "/about"}, + {"label": "Team", "partial_url": "/team"}, + {"label": "Research", "partial_url": "/research"}, + {"label": "K12 Books & Resources", "partial_url": "/k12"}, + { + "label": "Institutional Partnerships", + "partial_url": "/institutional-partnership" + }, + { + "label": "Technology Partnerships", + "partial_url": "/openstax-ally-technology-partner-program" + }, + {"label": "Webinars", "partial_url": "/webinars"} + ] + } +] diff --git a/test/src/layouts/default/data/sticky.json b/test/src/layouts/default/data/sticky.json new file mode 100644 index 000000000..a8e76746a --- /dev/null +++ b/test/src/layouts/default/data/sticky.json @@ -0,0 +1,11 @@ +{ + "start": "2024-06-10T04:00:00Z", + "expires": "2027-03-31T04:00:00Z", + "show_popup": false, + "header": "Give Now!", + "body": "Imagine a world where every student has free access to education. By making a monthly gift, you help build that future. Start your recurring donation today.", + "link_text": "Give now!", + "link": "https://riceconnect.rice.edu/donation/support-openstax-banner", + "emergency_expires": "2024-05-31T23:00:00Z", + "emergency_content": "The OpenStax office will be closed on Friday, May 05. We will resume normal business hours on May 08. OpenStax textbooks and resources are always available anytime, anywhere." +} diff --git a/test/src/layouts/default/default.test.tsx b/test/src/layouts/default/default.test.tsx new file mode 100644 index 000000000..6a14eabde --- /dev/null +++ b/test/src/layouts/default/default.test.tsx @@ -0,0 +1,375 @@ +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import {useSeenCounter, usePutAway, useStickyData} from '~/layouts/default/shared'; +import {MenuItem} from '~/layouts/default/header/menus/main-menu/dropdown/dropdown'; +import DefaultLayout from '~/layouts/default/default'; +import stickyData from './data/sticky.json'; +import fundraiserData from './data/fundraiser.json'; +import footerData from './data/footer.json'; +import oxmenuData from './data/osmenu.json'; +import giveTodayData from './data/give-today.json'; +import '@testing-library/jest-dom'; + +// Mock external dependencies +jest.mock('~/helpers/jit-load', () => { + return function JITLoad({importFn}: {importFn: () => Promise}) { + importFn().catch(); + return
JIT Load Component
; + }; +}); +jest.mock('~/contexts/portal', () => ({ + __esModule: true, + default: () => ({portalPrefix: ''}) +})); +jest.mock('~/contexts/window', () => ({ + __esModule: true, + default: () => ({innerWidth: 1024}) +})); + +// Having it defined inline caused location updates on every call +let mockPathname = {pathname: '/test-path'}; + +jest.mock('react-router-dom', () => ({ + useLocation: () => mockPathname +})); +// jest.mock('~/layouts/default/microsurvey-popup/microsurvey-popup', () => ({ +// __esModule: true, +// default: () => <> +// })); + +const mockUseSharedDataContext = jest.fn().mockReturnValue({stickyFooterState: [false, () => undefined]}); + +jest.mock('~/contexts/shared-data', () => ({ + __esModule: true, + default: () => mockUseSharedDataContext() +})); + +// Mock localStorage +const mockLocalStorage = { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + clear: jest.fn(), + key: jest.fn(), + length: 0, + visitedGive: '0', + campaignId: '' +}; + +Reflect.defineProperty(window, 'localStorage', { + value: mockLocalStorage +}); + +const user = userEvent.setup(); + +describe('Layouts Default TypeScript Conversions', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockLocalStorage.getItem.mockReturnValue('0'); + }); + + describe('shared.tsx utilities', () => { + test('useSeenCounter hook works with TypeScript types', async () => { + const TestComponent = () => { + const [hasBeenSeenEnough, increment] = useSeenCounter(5); + + return ( +
+ {hasBeenSeenEnough.toString()} + +
+ ); + }; + + render(); + expect(screen.getByTestId('seen-enough')).toHaveTextContent('false'); + const saveLS = window.localStorage; + const saveWarn = console.warn; + + console.warn = jest.fn(); + Reflect.deleteProperty(window, 'localStorage'); + await user.click(screen.getByRole('button')); + expect(console.warn).toHaveBeenCalledWith('LocalStorage restricted'); + Reflect.defineProperty(window, 'localStorage', {value: saveLS}); + console.warn = saveWarn; + }); + + test('usePutAway hook returns proper TypeScript types', () => { + const TestComponent = () => { + const [closed, PutAwayComponent] = usePutAway(); + + return ( +
+ {closed.toString()} + +
+ ); + }; + + render(); + expect(screen.getByTestId('closed')).toHaveTextContent('false'); + user.click(screen.getByRole('button', {name: 'dismiss'})); + }); + + test('useStickyData hook handles null return type', () => { + const TestComponent = () => { + const data = useStickyData(); + + return ( +
+ {data ? 'has data' : 'no data'} +
+ ); + }; + + render(); + expect(screen.getByTestId('sticky-data')).toHaveTextContent('no data'); + }); + }); + +// describe('MenuItem component TypeScript props', () => { +// test('MenuItem accepts proper TypeScript props', () => { +// const props = { +// label: 'Test Label', +// url: '/test-url', +// local: 'test-local' +// }; + +// render(); + +// const link = screen.getByRole('link'); + +// expect(link).toHaveAttribute('href', '/test-url'); +// expect(link).toHaveAttribute('data-local', 'test-local'); +// }); + +// test('MenuItem works without optional local prop', () => { +// const props = { +// label: 'Test Label', +// url: '/test-url' +// }; + +// render(); + +// const link = screen.getByRole('link'); + +// expect(link).toHaveAttribute('href', '/test-url'); +// }); +// }); + +// describe('TypeScript type safety tests', () => { +// test('StickyData type structure is properly defined', () => { +// // This test verifies TypeScript compilation and type checking +// type BannerInfo = { +// id: number; +// heading: string; +// body: string; +// link_text: string; +// link_url: string; +// }; + +// type StickyDataRaw = { +// start: string; +// expires: string; +// emergency_expires?: string; +// show_popup: boolean; +// }; + +// /* eslint-disable camelcase */ +// const validBannerInfo: BannerInfo = { +// id: 1, +// heading: 'Test Heading', +// body: 'Test Body', +// link_text: 'Test Link', +// link_url: 'https://example.com' +// }; + +// const validStickyData: StickyDataRaw = { +// start: '2024-01-01', +// expires: '2024-12-31', +// emergency_expires: '2024-06-01', +// show_popup: true +// }; +// /* eslint-enable camelcase */ + +// expect(validBannerInfo.id).toBe(1); +// expect(validStickyData.show_popup).toBe(true); +// }); + +// test('localStorage type safety is maintained', () => { +// // Test that our localStorage shim types work correctly +// const testKey = 'testKey'; +// const testValue = 'testValue'; + +// if (window.localStorage) { +// window.localStorage.setItem(testKey, testValue); +// expect(mockLocalStorage.setItem).toHaveBeenCalledWith(testKey, testValue); +// } +// }); + +// test('Component prop types are properly enforced', () => { +// // This test ensures our prop types compile correctly +// type TestComponentProps = { +// label: string; +// url: string; +// local?: string; +// }; + +// const TestComponent = ({label, url, local}: TestComponentProps) => ( +//
+// {label} +// {url} +// {local || 'default'} +//
+// ); + +// const props: TestComponentProps = { +// label: 'Test', +// url: '/test' +// }; + +// render(); +// expect(screen.getByText('Test')).toBeInTheDocument(); +// }); +// }); + +// describe('Hook return type validation', () => { +// test('useSeenCounter returns correct tuple type', () => { +// const TestComponent = () => { +// const result = useSeenCounter(3); + +// // TypeScript should enforce this is a tuple with specific types +// const [hasBeenSeenEnough, increment]: [boolean, () => void] = result; + +// return ( +//
+// {typeof hasBeenSeenEnough} +// {typeof increment} +//
+// ); +// }; + +// render(); +// expect(screen.getByTestId('boolean-type')).toHaveTextContent('boolean'); +// expect(screen.getByTestId('function-type')).toHaveTextContent('function'); +// }); +// }); +}); + +describe('default layout', () => { + const saveFetch = global.fetch; + + function fetchResponse(data: object) { + return Promise.resolve({ + json: () => Promise.resolve(data) + }); + } + + beforeAll(() => { + global.fetch = jest.fn().mockImplementation((...args: Parameters) => { + const url = args[0] as string; + + if (url.includes('/sticky/')) { + return fetchResponse(stickyData); + } + if (url.endsWith('/fundraiser/?format=json')) { + return fetchResponse(fundraiserData); + } + if (url.endsWith('/footer/?format=json')) { + return fetchResponse(footerData); + } + if (url.endsWith('/oxmenus/?format=json')) { + return fetchResponse(oxmenuData); + } + if (url.endsWith('/give-today/')) { + return fetchResponse(giveTodayData); + } + // console.info('*** Fetch args:', args); + return saveFetch(...args); + }); + }); + it('renders; menu opens and closes', async () => { + render(); + expect(await screen.findAllByText('JIT Load Component')).toHaveLength(2); + const toggle = screen.getByRole('button', {name: 'Toggle Meta Navigation Menu'}); + + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + await user.click(toggle); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + // Clicking within the popover does not close the popover + await user.click(document.getElementById('menu-popover')!); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + // close on outside click + const overlay = document.querySelector('.menu-popover-overlay')!; + const menuContainer = overlay.closest('.menus')!; + + await user.click(overlay as Element); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + + // close on Escape (but not on other keypress) + await user.click(toggle); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + fireEvent.keyDown(menuContainer, {key: 'Enter'}); + expect(toggle.getAttribute('aria-expanded')).toBe('true'); + fireEvent.keyDown(menuContainer, {key: 'Escape'}); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + + // close on location change (the open is immediately reversed because the path has changed) + mockPathname = {pathname: '/test-path'}; + await user.click(toggle); + expect(toggle.getAttribute('aria-expanded')).toBe('false'); + }); +}); + +// Integration tests for complex TypeScript interactions +// describe('TypeScript Integration Tests', () => { +// test('Complex type interactions work correctly', async () => { +// // Test that complex types work together without compilation errors +// type ComplexData = { +// id: number; +// metadata: { +// created: string; +// modified?: string; +// }; +// items: Array<{ +// name: string; +// value: number; +// }>; +// }; + +// const testData: ComplexData = { +// id: 1, +// metadata: { +// created: '2024-01-01' +// }, +// items: [ +// {name: 'item1', value: 100}, +// {name: 'item2', value: 200} +// ] +// }; + +// expect(testData.id).toBe(1); +// expect(testData.items).toHaveLength(2); +// expect(testData.metadata.modified).toBeUndefined(); +// }); + +// test('Event handler types are properly defined', () => { +// type EventHandlerProps = { +// onClick: (event: React.MouseEvent) => void; +// onKeyDown: (event: React.KeyboardEvent) => void; +// }; + +// const TestComponent = ({onClick, onKeyDown}: EventHandlerProps) => ( +// +// ); + +// const mockClick = jest.fn(); +// const mockKeyDown = jest.fn(); + +// render(); +// expect(screen.getByRole('button')).toBeInTheDocument(); +// }); +// }); diff --git a/test/src/layouts/layouts.test.tsx b/test/src/layouts/layouts.test.tsx index 4936ee2aa..f161fbc14 100644 --- a/test/src/layouts/layouts.test.tsx +++ b/test/src/layouts/layouts.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; import {render, screen} from '@testing-library/preact'; -import {describe, it} from '@jest/globals'; -import { MemoryRouter } from 'react-router-dom'; +import MemoryRouter from '~/../../test/helpers/future-memory-router'; import LandingLayout from '~/layouts/landing/landing'; // @ts-expect-error does not exist on diff --git a/test/src/layouts/lower-sticky-note.test.tsx b/test/src/layouts/lower-sticky-note.test.tsx index e4abc07f8..49eab7b04 100644 --- a/test/src/layouts/lower-sticky-note.test.tsx +++ b/test/src/layouts/lower-sticky-note.test.tsx @@ -26,7 +26,7 @@ const stickyData = { banner_thumbnail: 'https://assets.openstax.org/oscms-dev/media/original_images/subj-icon-science.png' } -}; +} as any; // eslint-disable-line @typescript-eslint/no-explicit-any /* eslint-disable camelcase */ describe('lower-sticky-note', () => {