diff --git a/src/app/contexts/user.ts b/src/app/contexts/user.ts index 7e340a7a8..214c1107c 100644 --- a/src/app/contexts/user.ts +++ b/src/app/contexts/user.ts @@ -49,7 +49,7 @@ function useContextValue() { const myOpenStaxUser = useMyOpenStaxUser(isVerified, fetchTime); const value = React.useMemo( () => - model?.last_name + model.last_name ? { accountId: model.id, userName: `${model.first_name} ${model.last_name.substr( diff --git a/src/app/layouts/default/microsurvey-popup/adoption-content.js b/src/app/layouts/default/microsurvey-popup/adoption-content.tsx similarity index 79% rename from src/app/layouts/default/microsurvey-popup/adoption-content.js rename to src/app/layouts/default/microsurvey-popup/adoption-content.tsx index b4b2d47b4..3ff0d9113 100644 --- a/src/app/layouts/default/microsurvey-popup/adoption-content.js +++ b/src/app/layouts/default/microsurvey-popup/adoption-content.tsx @@ -1,22 +1,23 @@ import React from 'react'; import useUserContext from '~/contexts/user'; import {useLocation} from 'react-router-dom'; +import {assertDefined} from '~/helpers/data'; import Cookies from 'js-cookie'; +import type {QueuedItemType} from './queue'; const DISMISSED_KEY = 'renewal_dialog_dismissed'; -// const YESTERDAY = Date.now() - 60 * 60 * 24 * 1000; -function useCookieKey(key) { +function useCookieKey(key: string) { return React.useReducer( - (_, value) => { + (_: string, value: string) => { Cookies.set(key, value); - return value ? value : '0'; + return value; }, - Cookies.get(key) + Cookies.get(key) ?? '' ); } -function useDismissalCookie() { +function useDismissalCookie(): [boolean, () => void] { const [cookieValue, setCookieValue] = useCookieKey(DISMISSED_KEY); const clicked = React.useMemo( () => +Number(cookieValue) > 0, @@ -31,7 +32,7 @@ function useDismissalCookie() { if (pathname === '/renewal-form') { return false; } - return !clicked && isFaculty && isAdopter; + return !clicked && isFaculty && Boolean(isAdopter); }, [clicked, isFaculty, isAdopter, pathname] ); @@ -55,12 +56,12 @@ function useDismissalCookie() { [pathname, disable, clicked] ); - return [ready, disable]; + return [ready, disable] as const; } -function AdoptionContentBase({children, disable}) { +function AdoptionContentBase({children, disable}: {children: React.ReactNode; disable: () => void}) { const {userModel} = useUserContext(); - const {first_name: name} = userModel || {}; + const {first_name: name} = assertDefined(userModel); // ready ensures this const {pathname} = useLocation(); const href = `${window.location.origin}${pathname}`; const renewalFormHref = `/renewal-form?from=popup&returnTo=${encodeURIComponent(href)}`; @@ -92,10 +93,10 @@ function AdoptionContentBase({children, disable}) { ); } -export default function useAdoptionMicrosurveyContent() { +export default function useAdoptionMicrosurveyContent(): [boolean, QueuedItemType] { const [ready, disable] = useDismissalCookie(); const AdoptionContent = React.useCallback( - ({children}) => ( + ({children}: {children: React.ReactNode}) => ( {children} diff --git a/src/app/layouts/default/microsurvey-popup/queue.d.ts b/src/app/layouts/default/microsurvey-popup/queue.d.ts deleted file mode 100644 index 39431b0ad..000000000 --- a/src/app/layouts/default/microsurvey-popup/queue.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -type NextItemFunction = () => null; -type OnDone = {onDone: NextItemFunction} | null; -type QueuedItemType = React.FC< - React.PropsWithChildren ->; - -export default function useMSQueue(): [QueuedItemType, NextItemFunction]; diff --git a/src/app/layouts/default/microsurvey-popup/queue.js b/src/app/layouts/default/microsurvey-popup/queue.js deleted file mode 100644 index b8230b531..000000000 --- a/src/app/layouts/default/microsurvey-popup/queue.js +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import useStickyMicrosurveyContent from './sticky-content'; -import useAdoptionMicrosurveyContent from './adoption-content'; - -function useEnqueueWhenReady(useContent, queue, setQueue) { - const [ready, Item] = useContent(); - const [hasQueued, setHasQueued] = React.useState(false); - - React.useEffect( - - () => { - if (!hasQueued && ready && !queue.includes(Item)) { - setQueue([...queue, Item]); - setHasQueued(true); - } - if (!ready && queue.includes(Item)) { - setQueue(queue.slice(1)); - } - }, - [ready, queue, setQueue, Item, hasQueued] - ); -} - -export default function useMSQueue() { - const [queue, setQueue] = React.useState([]); - const nextItem = React.useCallback( - () => setQueue(queue.slice(1)), - [queue] - ); - const QueuedItem = queue.length > 0 ? queue[0] : null; - - useEnqueueWhenReady(useStickyMicrosurveyContent, queue, setQueue); - useEnqueueWhenReady(useAdoptionMicrosurveyContent, queue, setQueue); - - return [QueuedItem, nextItem]; -} diff --git a/src/app/layouts/default/microsurvey-popup/queue.tsx b/src/app/layouts/default/microsurvey-popup/queue.tsx new file mode 100644 index 000000000..95679bac6 --- /dev/null +++ b/src/app/layouts/default/microsurvey-popup/queue.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import useStickyMicrosurveyContent from './sticky-content'; +import useAdoptionMicrosurveyContent from './adoption-content'; + +type NextItemFunction = () => void; +export type QueuedItemType = ({ + children, + onDone +}: { + children: React.ReactNode; + onDone?: NextItemFunction; +}) => React.JSX.Element; +type UseContentHook = () => [boolean, QueuedItemType]; + +function useEnqueueWhenReady( + useContent: UseContentHook, + queue: QueuedItemType[], + setQueue: (queue: QueuedItemType[]) => void +) { + const [ready, Item] = useContent(); + const [hasQueued, setHasQueued] = React.useState(false); + + React.useEffect(() => { + if (!hasQueued && ready && !queue.includes(Item)) { + setQueue([...queue, Item]); + setHasQueued(true); + } + // It doens't look like this (double-loading) is possible as it is set up now. + // if (!ready && queue.includes(Item)) { + // setQueue(queue.slice(1)); + // } + }, [ready, queue, setQueue, Item, hasQueued]); +} + +export default function useMSQueue(): [ + QueuedItemType | null, + NextItemFunction +] { + const [queue, setQueue] = React.useState([]); + const nextItem = React.useCallback(() => setQueue(queue.slice(1)), [queue]); + const QueuedItem = queue.length > 0 ? queue[0] : null; + + useEnqueueWhenReady(useStickyMicrosurveyContent, queue, setQueue); + useEnqueueWhenReady(useAdoptionMicrosurveyContent, queue, setQueue); + + return [QueuedItem, nextItem]; +} diff --git a/src/app/layouts/default/microsurvey-popup/sticky-content.js b/src/app/layouts/default/microsurvey-popup/sticky-content.js deleted file mode 100644 index 9d64bd9fe..000000000 --- a/src/app/layouts/default/microsurvey-popup/sticky-content.js +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import RawHTML from '~/components/jsx-helpers/raw-html'; -import {useStickyData, useSeenCounter} from '../shared'; - -const SEEN_ENOUGH = 3; - -function StickyContent({stickyData, children}) { - return ( -
- {children} - - - {stickyData.link_text} - -
- ); -} - -function useBoundStickyContent(stickyData, incrementSeenCount) { - // Increment seen count on each fresh load - React.useEffect( - () => incrementSeenCount(), - [incrementSeenCount] - ); - - return React.useCallback( - (props) => , - [stickyData] - ); -} - -export default function useStickyMicrosurveyContent() { - const stickyData = useStickyData(); - const [hasBeenSeenEnough, incrementSeenCount] = useSeenCounter(SEEN_ENOUGH); - const BoundStickyContent = useBoundStickyContent(stickyData, incrementSeenCount); - - const ready = Boolean( - stickyData?.mode === 'popup' && !hasBeenSeenEnough - ); - - return [ready, BoundStickyContent]; -} diff --git a/src/app/layouts/default/microsurvey-popup/sticky-content.tsx b/src/app/layouts/default/microsurvey-popup/sticky-content.tsx new file mode 100644 index 000000000..5b80e5502 --- /dev/null +++ b/src/app/layouts/default/microsurvey-popup/sticky-content.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import RawHTML from '~/components/jsx-helpers/raw-html'; +import {useStickyData, useSeenCounter, BannerInfo as StickyData} from '../shared'; +import {assertDefined} from '~/helpers/data'; +import type {QueuedItemType} from './queue'; + +const SEEN_ENOUGH = 3; + +function StickyContent({stickyData, children}: {stickyData: StickyData; children: React.ReactNode}) { + const {body, link_url: url, link_text: text} = stickyData; + + return ( +
+ {children} + + + {text} + +
+ ); +} + +function useBoundStickyContent(stickyData: StickyData | undefined, incrementSeenCount: () => void) { + // Increment seen count on each fresh load + React.useEffect( + () => incrementSeenCount(), + [incrementSeenCount] + ); + + // ready ensures it will not be called unless stickyData is defined + return React.useCallback( + (props: {children: React.ReactNode}) => , + [stickyData] + ); +} + +export default function useStickyMicrosurveyContent(): [boolean, QueuedItemType] { + const stickyData = useStickyData(); + const [hasBeenSeenEnough, incrementSeenCount] = useSeenCounter(SEEN_ENOUGH); + const BoundStickyContent = useBoundStickyContent(stickyData?.bannerInfo, incrementSeenCount); + const ready = Boolean( + stickyData?.mode === 'popup' && !hasBeenSeenEnough + ); + + return [ready, BoundStickyContent]; +} diff --git a/src/app/layouts/default/shared.tsx b/src/app/layouts/default/shared.tsx index afc982be0..25bc6cd73 100644 --- a/src/app/layouts/default/shared.tsx +++ b/src/app/layouts/default/shared.tsx @@ -4,7 +4,7 @@ import {useDataFromPromise} from '~/helpers/page-data-utils'; import PutAway from '~/components/put-away/put-away'; import './shared.scss'; -type BannerInfo = { +export type BannerInfo = { id: number; heading: string; body: string; diff --git a/src/app/models/accounts-model.ts b/src/app/models/accounts-model.ts index 20d36dfc1..68e863a7f 100644 --- a/src/app/models/accounts-model.ts +++ b/src/app/models/accounts-model.ts @@ -25,13 +25,13 @@ export type AccountsUserModel = { uuid: string; first_name: string; last_name: string; - email: string; + email?: string; school_name: string; self_reported_role: string; self_reported_school: string; is_not_gdpr_location: boolean; salesforce_contact_id: string; - is_instructor_verification_stale: boolean; + is_instructor_verification_stale?: boolean; faculty_status: string; contact_infos: { type: string; @@ -39,6 +39,7 @@ export type AccountsUserModel = { is_verified: boolean; is_guessed_preferred: boolean; }[]; + using_openstax: boolean; }; declare global { diff --git a/src/app/models/usermodel.ts b/src/app/models/usermodel.ts index 743ec3ae5..c94ca9be1 100644 --- a/src/app/models/usermodel.ts +++ b/src/app/models/usermodel.ts @@ -13,7 +13,7 @@ export type UserModelType = { last_name: string; instructorEligible: boolean; pending_verification: boolean; - stale_verification: boolean; + stale_verification?: boolean; incompleteSignup: boolean; pendingInstructorAccess: boolean; emailUnverified: boolean; diff --git a/src/app/pages/k12/k12-main/banner.tsx b/src/app/pages/k12/k12-main/banner.tsx index 1deca7b03..a5b35fcb3 100644 --- a/src/app/pages/k12/k12-main/banner.tsx +++ b/src/app/pages/k12/k12-main/banner.tsx @@ -52,7 +52,7 @@ export default function Banner({data}: {data: K12Data}) {
); diff --git a/test/src/components/shell/microsurvey-popup/adoption-content.test.tsx b/test/src/components/shell/microsurvey-popup/adoption-content.test.tsx new file mode 100644 index 000000000..504ea4cff --- /dev/null +++ b/test/src/components/shell/microsurvey-popup/adoption-content.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import userEvent from '@testing-library/user-event'; +import MemoryRouter from '~/../../test/helpers/future-memory-router'; +import useAdoptionMicrosurveyContent from '~/layouts/default/microsurvey-popup/adoption-content'; +import * as UM from '~/models/usermodel'; +import userModelData from '~/../../test/src/data/userModel'; +import { UserContextProvider } from '~/contexts/user'; + +/* eslint-disable camelcase */ +function userModelAdopter(isAdopter: boolean) { + const adopterFields = { + using_openstax: isAdopter, + faculty_status: isAdopter ? 'confirmed_faculty' : 'no_faculty_info' + }; + + return {...userModelData, accountsModel: {...userModelData.accountsModel, ...adopterFields}}; +} +/* eslint-enable camelcase */ + +describe('microsurvey-popup/adoption-content', () => { + const user = userEvent.setup(); + const saveError = console.error; + + function Component() { + const [ready, AdoptionContent] = useAdoptionMicrosurveyContent(); + + return ready + ?
the page
+ : null; + } + + function WrappedComponent({path}: {path: string}) { + return + + + + ; + } + it('renders nothing if user is not an adopter', async () => { + jest.spyOn(UM, 'useUserModel').mockReturnValue({...userModelAdopter(false)}); + render(); + expect(document.body.textContent).toBe(''); + }); + it('renders nothing on the renewal-form even if user is an adopter', () => { + jest.spyOn(UM, 'useUserModel').mockReturnValue({...userModelAdopter(true)}); + render(); + expect(document.body.textContent).toBe(''); + }); + it('renders the page on other paths if user is faculty/adopter', async () => { + jest.spyOn(UM, 'useUserModel').mockReturnValue({...userModelAdopter(true)}); + render(); + screen.getByText('the page'); + const link = screen.getByRole('link'); + + console.error = jest.fn(); + await user.click(link); + expect(console.error).toHaveBeenCalled(); // because navigation + console.error = saveError; + }); +}); diff --git a/test/src/components/shell/microsurvey-popup.test.tsx b/test/src/components/shell/microsurvey-popup/microsurvey-popup.test.tsx similarity index 89% rename from test/src/components/shell/microsurvey-popup.test.tsx rename to test/src/components/shell/microsurvey-popup/microsurvey-popup.test.tsx index 325b39112..2f83f0ee2 100644 --- a/test/src/components/shell/microsurvey-popup.test.tsx +++ b/test/src/components/shell/microsurvey-popup/microsurvey-popup.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import {describe, expect, it} from '@jest/globals'; import {render} from '@testing-library/preact'; -import ShellContextProvider from '../../../helpers/shell-context'; +import ShellContextProvider from '../../../../helpers/shell-context'; import MemoryRouter from '~/../../test/helpers/future-memory-router'; import MicroSurvey from '~/layouts/default/microsurvey-popup/microsurvey-popup'; import useMSQueue from '~/layouts/default/microsurvey-popup/queue'; diff --git a/test/src/components/shell/microsurvey-popup/queue.test.tsx b/test/src/components/shell/microsurvey-popup/queue.test.tsx new file mode 100644 index 000000000..a4b0d0209 --- /dev/null +++ b/test/src/components/shell/microsurvey-popup/queue.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import useMSQueue from '~/layouts/default/microsurvey-popup/queue'; +import MemoryRouter from '~/../../test/helpers/future-memory-router'; +import * as S from '~/layouts/default/shared'; +import * as AC from '~/layouts/default/microsurvey-popup/adoption-content'; +import stickyData from '~/../../test/src/data/sticky-data'; + +let itemsHandled = 0; + +function Component() { + const [QueuedItem, nextItem] = useMSQueue(); + + React.useEffect(() => { + if (QueuedItem) { + ++itemsHandled; + nextItem(); + } + }); + + if (!QueuedItem) { + return
{itemsHandled} item handled
; + } + + return
; +} + +function MockAdoptionContent() { + return
Adoption stuff
; +} + +describe('microsurvey queue', () => { + stickyData.mode = 'popup'; + jest.spyOn(S, 'useStickyData').mockReturnValue(stickyData); + jest.spyOn(AC, 'default').mockReturnValue([true, MockAdoptionContent]); + + it('enqueues sticky and adoption content', async () => { + render( + + ); + screen.getByText('1 item handled'); + }); +}); diff --git a/test/src/components/shell/microsurvey-popup/sticky-content.test.tsx b/test/src/components/shell/microsurvey-popup/sticky-content.test.tsx new file mode 100644 index 000000000..98c4a2d75 --- /dev/null +++ b/test/src/components/shell/microsurvey-popup/sticky-content.test.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import MemoryRouter from '~/../../test/helpers/future-memory-router'; +import useStickyMicrosurveyContent from '~/layouts/default/microsurvey-popup/sticky-content'; +import * as SH from '~/layouts/default/shared'; +import stickyData from '~/../../test/src/data/sticky-data'; + +describe('microsurvey-popup/sticky-content', () => { + function Component() { + const [ready, StickyContent] = useStickyMicrosurveyContent(); + + return ready + ?
the page
+ : null; + } + + function WrappedComponent({path}: {path: string}) { + return + + ; + } + + it('renders nothing unless stickydata mode is popup', () => { + jest.spyOn(SH, 'useStickyData').mockReturnValue({...stickyData, mode: 'banner'}); + render(); + expect(document.body.textContent).toBe(''); + }); + it('renders the page if stickydata mode is popup', () => { + jest.spyOn(SH, 'useStickyData').mockReturnValue({...stickyData, mode: 'popup'}); + render(); + screen.getByText('Make a difference now'); + }); +}); diff --git a/test/src/data/sticky-data.ts b/test/src/data/sticky-data.ts new file mode 100644 index 000000000..f9ba85427 --- /dev/null +++ b/test/src/data/sticky-data.ts @@ -0,0 +1,25 @@ +/* eslint-disable camelcase */ +export default { + mode: 'banner' as 'banner' | 'popup', + start: '2024-05-10T15:00:00Z', + expires: '2025-10-01T19:58:00Z', + show_popup: false, + header: 'Normal sticky', + body: 'By giving $1, $5, or $10 you can make a meaningful impact...', + link_text: 'Give now', + link: 'https://google.com', + emergency_expires: '2023-01-17T02:00:00Z', + emergency_content: + 'The OpenStax offices will be closed January 16 in observance of Martin Luther King, Jr. Day.', + bannerInfo: { + html_message: + 'Help students around the world succeed with contributions of $5, $10 or $20', + link_text: 'Make a difference now', + link_url: 'https://dev.openstax.org/give', + banner_thumbnail: + 'https://assets.openstax.org/oscms-dev/media/original_images/subj-icon-science.png', + id: 0, + heading: '', + body: '' + } +}; diff --git a/test/src/layouts/lower-sticky-note.test.tsx b/test/src/layouts/lower-sticky-note.test.tsx index 8f9d99565..d0c5066f1 100644 --- a/test/src/layouts/lower-sticky-note.test.tsx +++ b/test/src/layouts/lower-sticky-note.test.tsx @@ -3,34 +3,9 @@ import {render, screen} from '@testing-library/preact'; import * as S from '~/layouts/default/shared'; import LowerStickyNote from '~/layouts/default/lower-sticky-note/lower-sticky-note'; import userEvent from '@testing-library/user-event'; +import stickyData from '~/../../test/src/data/sticky-data'; import Cookies from 'js-cookie'; -/* eslint-disable camelcase */ -const stickyData = { - mode: 'banner' as const, - start: '2024-05-10T15:00:00Z', - expires: '2025-10-01T19:58:00Z', - show_popup: false, - header: 'Normal sticky', - body: 'By giving $1, $5, or $10 you can make a meaningful impact...', - link_text: 'Give now', - link: 'https://google.com', - emergency_expires: '2023-01-17T02:00:00Z', - emergency_content: - 'The OpenStax offices will be closed January 16 in observance of Martin Luther King, Jr. Day.', - bannerInfo: { - html_message: - 'Help students around the world succeed with contributions of $5, $10 or $20', - link_text: 'Make a difference now', - link_url: 'https://dev.openstax.org/give', - banner_thumbnail: - 'https://assets.openstax.org/oscms-dev/media/original_images/subj-icon-science.png', - id: 0, - heading: '', - body: '' - } -}; - /* eslint-disable camelcase */ describe('lower-sticky-note', () => { const user = userEvent.setup();