diff --git a/src/app/components/dialog/dialog.d.ts b/src/app/components/dialog/dialog.d.ts index 815f7fb70..eef79df82 100644 --- a/src/app/components/dialog/dialog.d.ts +++ b/src/app/components/dialog/dialog.d.ts @@ -1,6 +1,14 @@ import React from 'react'; import ReactModal from 'react-modal'; +type DialogProps = React.PropsWithChildren<{ + isOpen?: boolean; + title?: string; + onPutAway?: () => void; + className?: string; + closeOnOutsideClick?: boolean; +}> + export default function Dialog({ isOpen, title, @@ -8,20 +16,14 @@ export default function Dialog({ children, className, closeOnOutsideClick -}: React.PropsWithChildren<{ - isOpen: boolean; - title: string; - onPutAway: () => void; - className?: string; - closeOnOutsideClick?: boolean; -}>): React.ReactNode; +}: DialogProps): React.ReactNode; export function useDialog( initallyOpen?: boolean ): [ BoundDialog: ({ children - }: React.PropsWithChildren) => React.ReactNode, + }: DialogProps & {aria?: ReactModal.Aria}) => React.ReactNode, open: () => void, close: () => void, showDialog: boolean diff --git a/src/app/components/explore-by-subject/types.ts b/src/app/components/explore-by-subject/types.ts index 0a060e969..f8d1fc161 100644 --- a/src/app/components/explore-by-subject/types.ts +++ b/src/app/components/explore-by-subject/types.ts @@ -1,5 +1,5 @@ export type Category = { - id: string; + id: number; name: string; subjectIcon?: string; }; diff --git a/src/app/components/form-input/form-input.d.ts b/src/app/components/form-input/form-input.d.ts new file mode 100644 index 000000000..11b3fa8f6 --- /dev/null +++ b/src/app/components/form-input/form-input.d.ts @@ -0,0 +1,8 @@ +export default function FormInput({label, longLabel, inputProps, suggestions}: + { + label: string; + longLabel?: string; + inputProps: object; + suggestions?: boolean; + } +): React.ReactNode; diff --git a/src/app/components/toggle/toggle-control-bar.js b/src/app/components/toggle/toggle-control-bar.js index dbddac65b..232c1f05d 100644 --- a/src/app/components/toggle/toggle-control-bar.js +++ b/src/app/components/toggle/toggle-control-bar.js @@ -17,6 +17,7 @@ export default function ToggleControlBar({ Indicator, children }) { [isOpen] ); const focusRef = useRefToFocusAfterClose(); + const listboxId = `lbid-${Math.floor(Math.random() * 1010101)}`; React.useEffect(() => { if (isOpen) { @@ -41,8 +42,11 @@ export default function ToggleControlBar({ Indicator, children }) { onClick={() => toggle()} onKeyDown={onKeyDown} ref={focusRef} + role="combobox" + aria-controls={listboxId} + aria-haspopup={listboxId} > -
{children}
+
{children}
); diff --git a/src/app/helpers/use-document-head.ts b/src/app/helpers/use-document-head.ts index e47e5b7ab..8b7e1aaca 100644 --- a/src/app/helpers/use-document-head.ts +++ b/src/app/helpers/use-document-head.ts @@ -76,7 +76,7 @@ type ArticleSubject = {name: string} | {value: {subject: {name: string}}[]}; type Collection = {name: string} | {value: {collection: {name: string}}[]}; -type BookData = { +export type BookData = { meta?: object; description?: string; bookSubjects?: Subject[]; @@ -84,6 +84,9 @@ type BookData = { title?: string; articleSubjects?: ArticleSubject[]; collections?: Collection[]; + error?: { + message: string; + } }; // eslint-disable-next-line complexity diff --git a/src/app/pages/blog/article-summary/article-summary.tsx b/src/app/pages/blog/article-summary/article-summary.tsx index c75631209..128c79d74 100644 --- a/src/app/pages/blog/article-summary/article-summary.tsx +++ b/src/app/pages/blog/article-summary/article-summary.tsx @@ -2,35 +2,53 @@ import React from 'react'; import RawHTML from '~/components/jsx-helpers/raw-html'; import Byline from '~/components/byline/byline'; import ClippedImage from '~/components/clipped-image/clipped-image'; +import type { + ArticleSummary as BlurbDataBase, + CollectionEntry, + SubjectEntry +} from '~/pages/blog/blog-context'; -type Collection = { - name?: string; - value: {collection: Collection}[]; -}; +type CollectionVariant = + | CollectionEntry + | { + value: {collection: CollectionEntry}[]; + }; + +type ArticleSubjectVariant = + | SubjectEntry + | { + value: {subject: SubjectEntry}[]; + }; + +type BlurbData = + | null + | (Omit & { + meta?: {slug: string}; + collections: CollectionVariant[]; + articleSubjects: ArticleSubjectVariant[]; + }); -type ArticleSubject = { - name?: string; - value: {subject: ArticleSubject}[]; -}; -type BlurbData = null | { - id: string; - collections: Collection[]; - articleSubjects: ArticleSubject[]; - heading: string; +export type ArticleSummaryData = { + id?: number; + articleSlug: string; + image: string; + altText?: string; + headline: string; subheading: string; - articleImage: string; - articleImageAlt: string; - bodyBlurb: string; - author: string; + body: string; date: string; - slug?: string; - meta: {slug: string}; + author: string; + collectionNames: string[]; + articleSubjectNames: string[]; + openInNewWindow?: boolean; + HeadTag?: keyof JSX.IntrinsicElements; + setPath?: (href: string) => void; }; -export function blurbModel(data: BlurbData) { +export function blurbModel(data: BlurbData): ArticleSummaryData | Record { if (!data) { - return {}; + return {} as Record; } return { @@ -56,24 +74,14 @@ export function blurbModel(data: BlurbData) { body: data.bodyBlurb, author: data.author, date: data.date, - articleSlug: data.slug || data.meta.slug + articleSlug: data.slug || (data.meta?.slug as string) }; } -export type ArticleSummaryData = { - articleSlug: string; - image: string; - headline: string; - subheading: string; - body: string; - date: string; - author: string; - collectionNames: string[]; - articleSubjectNames: string[]; - setPath: (href: string) => void; - openInNewWindow: boolean; - HeadTag?: keyof JSX.IntrinsicElements; -}; +export type PopulatedBlurbModel = Exclude< + ReturnType, + Record +>; export default function ArticleSummary({ articleSlug, @@ -100,8 +108,10 @@ export default function ArticleSummary({ if (target.getAttribute('target') === '_blank') { return; } - event.preventDefault(); - setPath(target.href); + if (setPath) { + event.preventDefault(); + setPath(target.href); + } }, [setPath] ); diff --git a/src/app/pages/blog/article/article.js b/src/app/pages/blog/article/article.js deleted file mode 100644 index 61606a183..000000000 --- a/src/app/pages/blog/article/article.js +++ /dev/null @@ -1,216 +0,0 @@ -import BodyUnit from '~/components/body-units/body-units'; -import Byline from '~/components/byline/byline'; -import ProgressRing from '~/components/progress-ring/progress-ring'; -import useScrollProgress from './use-progress'; -import {ShareJsx} from '~/components/share/share'; -import React, {useState, useRef} from 'react'; -import usePageData from '~/helpers/use-page-data'; -import RawHTML from '~/components/jsx-helpers/raw-html'; -import {setPageTitleAndDescriptionFromBookData} from '~/helpers/use-document-head'; -import './article.scss'; - -function normalUnits(unit) {return unit.value.alignment !== 'bottom';} -function bottomUnits(unit) {return unit.value.alignment === 'bottom';} - -function ArticleBody({bodyData, setReadTime, bodyRef}) { - React.useEffect(() => { - const div = bodyRef.current; - const words = div.textContent.split(/\W+/); - const WORDS_PER_MINUTE = 225; - - setReadTime(Math.round(words.length / WORDS_PER_MINUTE)); - }, [bodyRef, setReadTime]); - - return ( -
- { - bodyData.filter(normalUnits) - .map((unit) => ) - } - { - bodyData.filter(bottomUnits) - .map((unit) => ) - } -
- ); -} - -function Tags({tagData=[]}) { - return tagData.length > 0 && -
- {tagData.map((tag) =>
{tag}
)} -
- ; -} - -function ShareButtons() { - return ( - - ); -} - -function FloatingSideBar({readTime, progress}) { - return ( -
-
- - -
-
- ); -} - -function TitleBlock({data}) { - const { - heading: title, - subheading, - date, - author - } = data; - - return ( -
-

{title}

-
- { - Boolean(subheading) && -

{subheading}

- } - -
-
- ); -} - -function NormalArticle({data}) { - const [readTime, setReadTime] = useState(); - const ref = useRef(); - const [progress, bodyRef] = useScrollProgress(ref); - const { - articleImage: image, - featuredImageAltText: imageAlt, - tags - } = data; - - return ( -
- -
-
- {image && {imageAlt}} - -
-
- - -
-
- ); -} - -function PdfArticle({data}) { - return ( -
-
- - -
-
- {data.body.map((unit) => )} -
-
- ); -} - -function VideoArticle({data}) { - const [readTime, setReadTime] = useState(); - const ref = useRef(); - const [progress, bodyRef] = useScrollProgress(ref); - const {featuredVideo: [{value: videoEmbed}], body, tags} = data; - - return ( -
- -
-
- - -
-
- - -
-
- ); -} - -export function Article({data}) { - const ArticleContent = React.useMemo( - () => { - const isPdf = data.body.some((block) => block.type === 'document'); - - if (isPdf) { - return PdfArticle; - } else if (data.featuredVideo?.length) { - return VideoArticle; - } - return NormalArticle; - }, - [data] - ); - - return (); -} - -function ArticleLoader({slug, onLoad}) { - const data = usePageData(slug, true); - - React.useEffect( - () => setPageTitleAndDescriptionFromBookData(data), - [data] - ); - - React.useEffect( - () => { - if (onLoad && data) { - onLoad(data); - } - }, - [data, onLoad] - ); - - if (!data) { - return null; - } - - if (data.error) { - return ( -
-

[Article not found]

-
{data.error.message} {slug}
-
- ); - } - - return (
); -} - -export function ArticleFromSlug({slug, onLoad}) { - return ( -
- -
- ); -} diff --git a/src/app/pages/blog/article/article.tsx b/src/app/pages/blog/article/article.tsx new file mode 100644 index 000000000..f7a97f3cf --- /dev/null +++ b/src/app/pages/blog/article/article.tsx @@ -0,0 +1,267 @@ +import BodyUnit from '~/components/body-units/body-units'; +import Byline from '~/components/byline/byline'; +import ProgressRing from '~/components/progress-ring/progress-ring'; +import useScrollProgress from './use-progress'; +import {ShareJsx} from '~/components/share/share'; +import React, {useState, useRef} from 'react'; +import usePageData from '~/helpers/use-page-data'; +import RawHTML from '~/components/jsx-helpers/raw-html'; +import { + setPageTitleAndDescriptionFromBookData, + BookData +} from '~/helpers/use-document-head'; +import './article.scss'; + +type ArticleArgs = { + slug: string; + onLoad: (data: ArticleData) => void; +}; + +export function ArticleFromSlug({slug, onLoad}: ArticleArgs) { + return ( +
+ +
+ ); +} + +type BodyData = { + type: string; + value: + | string + | { + alignment: string; + }; + id: string; +}; +export type ArticleData = BookData & { + error?: { + message: string; + }; + heading: string; + subheading: string; + date: string; + author: string; + body: BodyData[]; + featuredVideo: [{value: string}]; + articleImage: string; + featuredImageAltText: string; + tags: string[]; + gatedContent?: boolean; +}; + +function ArticleLoader({slug, onLoad}: ArticleArgs) { + const data = usePageData(slug, true); + + React.useEffect(() => setPageTitleAndDescriptionFromBookData(data), [data]); + + React.useEffect(() => { + if (onLoad && data) { + onLoad(data); + } + }, [data, onLoad]); + + if (!data) { + return null; + } + + if (data.error) { + return ( +
+

[Article not found]

+
+                    {data.error.message} {slug}
+                
+
+ ); + } + + return
; +} + +export function Article({data}: {data: ArticleData}) { + const ArticleContent = React.useMemo(() => { + const isPdf = data.body.some((block) => block.type === 'document'); + + if (isPdf) { + return PdfArticle; + } else if (data.featuredVideo?.length) { + return VideoArticle; + } + return NormalArticle; + }, [data]); + + return ; +} + +function NormalArticle({data}: {data: ArticleData}) { + const [readTime, setReadTime] = useState(); + const ref = useRef(null); + const [progress, bodyRef] = useScrollProgress(ref); + const {articleImage: image, featuredImageAltText: imageAlt, tags} = data; + + return ( +
+ +
+
+ {image && {imageAlt}} + +
+
+ + +
+
+ ); +} + +function PdfArticle({data}: {data: ArticleData}) { + return ( +
+
+ + +
+
+ {data.body.map((unit) => ( + + ))} +
+
+ ); +} + +function VideoArticle({data}: {data: ArticleData}) { + const [readTime, setReadTime] = useState(); + const ref = useRef(null); + const [progress, bodyRef] = useScrollProgress(ref); + const { + featuredVideo: [{value: videoEmbed}], + body, + tags + } = data; + + return ( +
+ +
+
+ + +
+
+ + +
+
+ ); +} + +function normalUnits(unit: BodyData) { + return typeof unit.value === 'string' || unit.value.alignment !== 'bottom'; +} +function bottomUnits(unit: BodyData) { + return typeof unit.value !== 'string' && unit.value.alignment === 'bottom'; +} + +function ArticleBody({ + bodyData, + setReadTime, + bodyRef +}: { + bodyData: BodyData[]; + setReadTime: (rt: number) => void; + bodyRef: React.MutableRefObject; +}) { + React.useEffect(() => { + const words = bodyRef.current?.textContent?.split(/\W+/) as string[]; + const WORDS_PER_MINUTE = 225; + + setReadTime(Math.round(words.length / WORDS_PER_MINUTE)); + }, [bodyRef, setReadTime]); + + return ( +
} + > + {bodyData.filter(normalUnits).map((unit) => ( + + ))} + {bodyData.filter(bottomUnits).map((unit) => ( + + ))} +
+ ); +} + +function Tags({tagData}: {tagData: ArticleData['tags']}) { + return ( + tagData.length > 0 && ( +
+ {tagData.map((tag) => ( +
+ {tag} +
+ ))} +
+ ) + ); +} + +function ShareButtons() { + return ( + + ); +} + +function FloatingSideBar({ + readTime, + progress +}: { + readTime: number | undefined; + progress: number | React.MutableRefObject; +}) { + return ( +
+
+ + +
+
+ ); +} + +function TitleBlock({data}: {data: ArticleData}) { + const {heading: title, subheading, date, author} = data; + + return ( +
+

{title}

+
+ {Boolean(subheading) &&

{subheading}

} + +
+
+ ); +} diff --git a/src/app/pages/blog/article/use-progress.js b/src/app/pages/blog/article/use-progress.js deleted file mode 100644 index 7d4f42b44..000000000 --- a/src/app/pages/blog/article/use-progress.js +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import {useRefreshable} from '~/helpers/data'; -import useWindowContext from '~/contexts/window'; - -function getProgress(divRect, viewportBottom) { - if (!divRect) { - return 0; - } - const visibleHeight = viewportBottom - divRect.top; - - if (visibleHeight <= 0) { - return 0; - } - if (viewportBottom >= divRect.bottom) { - return 100; - } - - return Math.round(100 * visibleHeight / divRect.height); -} - -export default function useScrollProgress(ref) { - const bodyRef = React.useRef(); - const [rect, refresh] = useRefreshable(() => bodyRef.current?.getBoundingClientRect()); - const {innerHeight, scrollY} = useWindowContext(); - const progress = React.useMemo( - () => getProgress(rect, innerHeight), - [rect, innerHeight] - ); - - React.useEffect( - () => { - Array.from(ref.current.querySelectorAll('img')) - .forEach((img) => { - img.onload = refresh; - }); - }, - [ref, refresh] - ); - - // Wait a tick so the bodyRef assignment happens - React.useEffect(() => window.requestAnimationFrame(refresh), [innerHeight, scrollY, refresh]); - - return [progress, bodyRef]; -} diff --git a/src/app/pages/blog/article/use-progress.tsx b/src/app/pages/blog/article/use-progress.tsx new file mode 100644 index 000000000..8a381a428 --- /dev/null +++ b/src/app/pages/blog/article/use-progress.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import {useRefreshable} from '~/helpers/data'; +import useWindowContext from '~/contexts/window'; + +function getProgress(divRect: DOMRect | undefined, viewportBottom: number) { + if (!divRect) { + return 0; + } + const visibleHeight = viewportBottom - divRect.top; + + if (visibleHeight <= 0) { + return 0; + } + if (viewportBottom >= divRect.bottom) { + return 100; + } + + return Math.round((100 * visibleHeight) / divRect.height); +} + +export default function useScrollProgress( + ref: React.RefObject +): [ + progress: number, + bodyRef: React.MutableRefObject +] { + const bodyRef = React.useRef(); + const [rect, refresh] = useRefreshable(() => + bodyRef.current?.getBoundingClientRect() + ); + const {innerHeight, scrollY} = useWindowContext(); + const progress = React.useMemo( + () => getProgress(rect, innerHeight), + [rect, innerHeight] + ); + + React.useEffect(() => { + Array.from(ref.current?.querySelectorAll('img') as NodeListOf).forEach( + (img) => { + img.onload = refresh; + } + ); + }, [ref, refresh]); + + // Wait a tick so the bodyRef assignment happens + React.useEffect(() => { + window.requestAnimationFrame(refresh); + }, [innerHeight, scrollY, refresh]); + + return [progress, bodyRef]; +} diff --git a/src/app/pages/blog/blog-context.js b/src/app/pages/blog/blog-context.js deleted file mode 100644 index c01829774..000000000 --- a/src/app/pages/blog/blog-context.js +++ /dev/null @@ -1,129 +0,0 @@ -import React from 'react'; -import {useNavigate} from 'react-router-dom'; -import usePageData from '~/helpers/use-page-data'; -import {useDataFromSlug, camelCaseKeys} from '~/helpers/page-data-utils'; -import buildContext from '~/components/jsx-helpers/build-context'; -import useLatestBlogEntries from '~/models/blog-entries'; -import useData from '~/helpers/use-data'; -const stayHere = {path: '/blog'}; - -function useEnglishSubjects() { - return useData({ - slug: 'snippets/subjects?format=json&locale=en', - camelCase: true - }, []); -} - -function useCollections() { - return useData({ - slug: 'snippets/blogcollection?format=json', - camelCase: true - }, []); -} - -function useTopicStories() { - const [topicType, setTopicType] = React.useState(); - const [topic, setTopic] = React.useState(); - const setTypeAndTopic = React.useCallback( - (typ, top) => { - setTopicType(typ); - setTopic(top); - }, - [] - ); - const slug = React.useMemo( - () => { - if (!topicType) { - return null; - } - if (topicType.startsWith('subj')) { - return `search/?subjects=${topic}`; - } - return `search/?collection=${topic}`; - }, - [topic, topicType] - ); - const topicStories = camelCaseKeys(useDataFromSlug(slug) || []); - - // Until search returns the heading field - topicStories.forEach((s) => { - if (!s.heading) { - s.heading = s.title; - } - }); - - const topicFeatured = React.useMemo( - () => { - const fieldFromType = topicType === 'subject' ? 'articleSubjects' : 'collections'; - const findFeatures = (story) => - story[fieldFromType].some((s) => s.name === topic && s.featured); - - return topicStories.find(findFeatures); - }, - [topicStories, topic, topicType] - ); - const topicPopular = React.useMemo( - () => topicStories.filter( - (story) => story.collections.some((c) => c.popular) - ), - [topicStories] - ); - - return ({topic, setTypeAndTopic, topicStories, topicFeatured, topicPopular}); -} - -function useContextValue({footerText, footerButtonText, footerLink, meta}) { - const navigate = useNavigate(); - const {topic, setTypeAndTopic, topicStories, topicFeatured, topicPopular} = useTopicStories(); - const pinnedData = useLatestBlogEntries(1); - const pinnedStory = topicFeatured || (pinnedData && pinnedData[0]); - const totalCount = pinnedData?.totalCount; - const subjectSnippet = useEnglishSubjects(); - const collectionSnippet = useCollections(); - const setPath = React.useCallback( - (href) => { - const {pathname, search, hash} = new window.URL(href, window.location.href); - - navigate(`${pathname}${search}${hash}`, stayHere); - window.scrollTo(0, 0); - }, - [navigate] - ); - const searchFor = React.useCallback( - (searchString) => setPath(`/blog/?q=${searchString}`), - [setPath] - ); - - if (pinnedStory && !pinnedStory.slug) { - pinnedStory.slug = pinnedStory.meta.slug; - } - - return { - setPath, pinnedStory, totalCount, subjectSnippet, collectionSnippet, - topic, setTypeAndTopic, topicStories, topicFeatured, topicPopular, - pageDescription: meta.searchDescription, - footerText, footerButtonText, footerLink, - searchFor - }; -} - -const {useContext, ContextProvider} = buildContext({useContextValue}); - -function BlogContextProvider({children}) { - const data = usePageData('news'); - - if (!data) { - return null; - } - - return ( - - {children} - - ); -} - -export { - useContext as default, - BlogContextProvider -}; diff --git a/src/app/pages/blog/blog-context.tsx b/src/app/pages/blog/blog-context.tsx new file mode 100644 index 000000000..83580270f --- /dev/null +++ b/src/app/pages/blog/blog-context.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import {useNavigate} from 'react-router-dom'; +import usePageData from '~/helpers/use-page-data'; +import {useDataFromSlug, camelCaseKeys} from '~/helpers/page-data-utils'; +import buildContext from '~/components/jsx-helpers/build-context'; +import useLatestBlogEntries from '~/models/blog-entries'; +import useData from '~/helpers/use-data'; + +type NewsPageData = { + title: string; + interestBlock: { + type: string; + values: string; + id: string; + }; + footerText: string; + footerButtonText: string; + footerLink: string; + meta: { + slug: string; + searchDescription: string; + }; + displayFooter: boolean; +}; + +export type SubjectEntry = { + name: string; + featured: boolean; +}; + +export type CollectionEntry = { + name: string; + featured: boolean; + popular: boolean; +}; + +export type ArticleSummary = { + articleImage: string; + articleImageAlt?: string; + articleSubjects: SubjectEntry[]; + author: string; + bodyBlurb: string; + collections: CollectionEntry[]; + contentTypes: string[]; + date: string; + heading: string; + id: number; + pinToTop: boolean; + searchDescription: string; + seoTitle: string; + slug: string; + title: string; + subheading: string; + tags: string[]; +}; + +export type SubjectSnippet = { + id: number; + name: string; + pageContent: string; + seoTitle: string; + searchDescription: string; + subjectIcon: string; + subjectColor: string; +}; + +function useEnglishSubjects() { + return useData( + { + slug: 'snippets/subjects?format=json&locale=en', + camelCase: true + }, + [] + ); +} + +function useCollections() { + return useData( + { + slug: 'snippets/blogcollection?format=json', + camelCase: true + }, + [] + ); +} + +export type TType = undefined | 'subjects' | 'collections'; + +export function assertTType(s: string | undefined) { + if (s === undefined || ['subjects', 'collections'].includes(s)) { + return s as TType; + } + throw new Error(`Topic type is invalid: ${s}`); +} + +function useTopicStories() { + const [topicType, setTopicType] = React.useState(); + const [topic, setTopic] = React.useState(); + const setTypeAndTopic = React.useCallback((typ: TType, top?: string) => { + setTopicType(typ); + setTopic(top); + }, []); + const slug = React.useMemo(() => { + if (!topicType) { + return null; + } + if (topicType.startsWith('subj')) { + return `search/?subjects=${topic}`; + } + return `search/?collection=${topic}`; + }, [topic, topicType]); + const topicStories: ArticleSummary[] = camelCaseKeys( + useDataFromSlug(slug) || [] + ); + + // Until search returns the heading field + topicStories.forEach((s) => { + if (!s.heading) { + s.heading = s.title; + } + }); + + const topicFeatured = React.useMemo(() => { + const fieldFromType = + topicType === 'subjects' ? 'articleSubjects' : 'collections'; + const findFeatures = (story: ArticleSummary) => + story[fieldFromType].some((s) => s.name === topic && s.featured); + + return topicStories.find(findFeatures); + }, [topicStories, topic, topicType]); + const topicPopular = React.useMemo( + () => + topicStories.filter((story: ArticleSummary) => + story.collections.some((c) => c.popular) + ), + [topicStories] + ); + + return {topic, setTypeAndTopic, topicStories, topicFeatured, topicPopular}; +} + +function useContextValue({ + footerText, + footerButtonText, + footerLink, + meta +}: NewsPageData) { + const navigate = useNavigate(); + const {topic, setTypeAndTopic, topicStories, topicFeatured, topicPopular} = + useTopicStories(); + const pinnedData = useLatestBlogEntries(1); + const pinnedStory = topicFeatured || (pinnedData && pinnedData[0]); + const totalCount = pinnedData?.totalCount; + const subjectSnippet = useEnglishSubjects(); + const collectionSnippet = useCollections(); + const setPath = React.useCallback( + (href: string) => { + const {pathname, search, hash} = new window.URL( + href, + window.location.href + ); + + navigate(`${pathname}${search}${hash}`); + window.scrollTo(0, 0); + }, + [navigate] + ); + const searchFor = React.useCallback( + (searchString: string) => setPath(`/blog/?q=${searchString}`), + [setPath] + ); + + if (pinnedStory && !pinnedStory.slug) { + pinnedStory.slug = pinnedStory.meta.slug; + } + + return { + setPath, + pinnedStory, + totalCount, + subjectSnippet, + collectionSnippet, + topic, + setTypeAndTopic, + topicStories, + topicFeatured, + topicPopular, + pageDescription: meta.searchDescription, + footerText, + footerButtonText, + footerLink, + searchFor + }; +} + +const {useContext, ContextProvider} = buildContext({useContextValue}); + +function BlogContextProvider({children}: React.PropsWithChildren) { + const data = usePageData('news'); + + if (!data) { + return null; + } + + return ( + + {children} + + ); +} + +export {useContext as default, BlogContextProvider}; diff --git a/src/app/pages/blog/blog-pages.tsx b/src/app/pages/blog/blog-pages.tsx new file mode 100644 index 000000000..9a092a0a6 --- /dev/null +++ b/src/app/pages/blog/blog-pages.tsx @@ -0,0 +1,106 @@ +import React, {useEffect} from 'react'; +import useBlogContext from './blog-context'; +import { useParams} from 'react-router-dom'; +import {WindowContextProvider} from '~/contexts/window'; +import useDocumentHead from '~/helpers/use-document-head'; +import RawHTML from '~/components/jsx-helpers/raw-html'; +import ExploreBySubject from '~/components/explore-by-subject/explore-by-subject'; +import ExploreByCollection from '~/components/explore-by-collection/explore-by-collection'; +import PinnedArticle from './pinned-article/pinned-article'; +import DisqusForm from './disqus-form/disqus-form'; +import MoreStories from './more-stories/more-stories'; +import SearchBar, {HeadingAndSearchBar} from '~/components/search-bar/search-bar'; +import SearchResults from './search-results/search-results'; +import {ArticleData, ArticleFromSlug} from './article/article'; +import GatedContentDialog from './gated-content-dialog/gated-content-dialog'; +import './blog.scss'; + +function WriteForUs({descriptionHtml, text, link}: { + descriptionHtml: string; + text: string; + link: string; +}) { + return ( +
+ + {text} +
+ ); +} + +export function SearchResultsPage() { + const {pageDescription, searchFor} = useBlogContext(); + + useDocumentHead({ + title: 'OpenStax Blog Search', + description: pageDescription + }); + + return ( + +
+ +
+ +
+ ); +} + +// Exported so it can be tested +// eslint-disable-next-line complexity +export function MainBlogPage() { + const { + pinnedStory, pageDescription, searchFor, + subjectSnippet: categories, + collectionSnippet: collections, + footerText, footerButtonText, footerLink + } = useBlogContext(); + const writeForUsData = { + descriptionHtml: footerText || 'Interested in sharing your story?', + text: footerButtonText || 'Write for us', + link: footerLink || '/write-for-us' + }; + + useDocumentHead({ + title: 'OpenStax News', + description: pageDescription + }); + + return ( + +
+ +

OpenStax Blog

+
+ + + + +
+
+ +
+
+ ); +} + +export function ArticlePage() { + const {slug} = useParams(); + const [articleData, setArticleData] = React.useState(); + + useEffect( + () => window.scrollTo(0, 0), + [slug] + ); + + return ( + + + +
+ +
+ +
+ ); +} diff --git a/src/app/pages/blog/blog.js b/src/app/pages/blog/blog.js index 940471ab3..72f65a7a9 100644 --- a/src/app/pages/blog/blog.js +++ b/src/app/pages/blog/blog.js @@ -1,108 +1,10 @@ -import React, {useEffect} from 'react'; -import useBlogContext, {BlogContextProvider} from './blog-context'; -import {Routes, Route, useLocation, useParams} from 'react-router-dom'; -import {WindowContextProvider} from '~/contexts/window'; -import useDocumentHead, {useCanonicalLink} from '~/helpers/use-document-head'; -import RawHTML from '~/components/jsx-helpers/raw-html'; -import ExploreBySubject from '~/components/explore-by-subject/explore-by-subject'; -import ExploreByCollection from '~/components/explore-by-collection/explore-by-collection'; -import ExplorePage from './explore-page/explore-page'; -import PinnedArticle from './pinned-article/pinned-article'; -import DisqusForm from './disqus-form/disqus-form'; -import MoreStories from './more-stories/more-stories'; -import SearchBar, {HeadingAndSearchBar} from '~/components/search-bar/search-bar'; -import SearchResults from './search-results/search-results'; +import React from 'react'; +import {Routes, Route, useLocation} from 'react-router-dom'; import LatestBlogPosts from './latest-blog-posts/latest-blog-posts'; -import {ArticleFromSlug} from './article/article'; -import GatedContentDialog from './gated-content-dialog/gated-content-dialog'; -import './blog.scss'; - -function WriteForUs({descriptionHtml, text, link}) { - return ( -
- - {text} -
- ); -} - -export function SearchResultsPage() { - const {pageDescription, searchFor} = useBlogContext(); - - useDocumentHead({ - title: 'OpenStax Blog Search', - description: pageDescription - }); - - return ( - -
- -
- -
- ); -} - -// Exported so it can be tested -// eslint-disable-next-line complexity -export function MainBlogPage() { - const { - pinnedStory, pageDescription, searchFor, - subjectSnippet: categories, - collectionSnippet: collections, - footerText, footerButtonText, footerLink - } = useBlogContext(); - const writeForUsData = { - descriptionHtml: footerText || 'Interested in sharing your story?', - text: footerButtonText || 'Write for us', - link: footerLink || '/write-for-us' - }; - - useDocumentHead({ - title: 'OpenStax News', - description: pageDescription - }); - - return ( - -
- -

OpenStax Blog

-
- - - - -
-
- -
-
- ); -} - -// Export so it can be tested -export function ArticlePage() { - const {slug} = useParams(); - const [articleData, setArticleData] = React.useState(); - - useEffect( - () => window.scrollTo(0, 0), - [slug] - ); - - return ( - - - -
- -
- -
- ); -} +import {useCanonicalLink} from '~/helpers/use-document-head'; +import {SearchResultsPage, MainBlogPage, ArticlePage} from './blog-pages'; +import ExplorePage from './explore-page/explore-page'; +import {BlogContextProvider} from './blog-context'; export default function LoadBlog() { const location = useLocation(); diff --git a/src/app/pages/blog/blog.scss b/src/app/pages/blog/blog.scss index 4342fe38d..8f11b1d12 100644 --- a/src/app/pages/blog/blog.scss +++ b/src/app/pages/blog/blog.scss @@ -5,6 +5,7 @@ background-color: ui-color(white); justify-items: center; width: 100%; + overflow: clip; section { width: 100%; diff --git a/src/app/pages/blog/explore-page/explore-page.js b/src/app/pages/blog/explore-page/explore-page.tsx similarity index 59% rename from src/app/pages/blog/explore-page/explore-page.js rename to src/app/pages/blog/explore-page/explore-page.tsx index b158b88fc..6c9fa19c9 100644 --- a/src/app/pages/blog/explore-page/explore-page.js +++ b/src/app/pages/blog/explore-page/explore-page.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import useBlogContext from '../blog-context'; +import useBlogContext, {assertTType, SubjectSnippet} from '../blog-context'; import {useParams} from 'react-router-dom'; import Breadcrumb from '~/components/breadcrumb/breadcrumb'; import {WindowContextProvider} from '~/contexts/window'; @@ -8,84 +8,108 @@ import PinnedArticle from '../pinned-article/pinned-article'; import {HeadingAndSearchBar} from '~/components/search-bar/search-bar'; import MoreStories from '../more-stories/more-stories'; import Section from '~/components/explore-page/section/section'; -import ArticleSummary, {blurbModel} from '../article-summary/article-summary'; - -// If it returns null, the topic is not a Subject -function useSubjectSnippetForTopic(topic) { - const {subjectSnippet} = useBlogContext(); - - return subjectSnippet.find((s) => s.name === topic); -} - -function useTopicHeading(topic, subject) { - const subjectHeading = React.useMemo( - () => `OpenStax ${topic} Textbooks`, - [topic] - ); - - return subject ? subjectHeading : topic; -} - -function HeadingForExplorePage({subject, heading}) { - return ( -

- {subject?.subjectIcon && } - {heading} -

- ); -} - -function useParamsToSetTopic() { - const {exploreType, topic} = useParams(); - const {setTypeAndTopic} = useBlogContext(); - - React.useEffect( - () => { - setTypeAndTopic(exploreType, topic); - return () => setTypeAndTopic(); - }, - [exploreType, topic, setTypeAndTopic] - ); -} +import ArticleSummary, { + blurbModel, + PopulatedBlurbModel +} from '../article-summary/article-summary'; export default function ExplorePage() { useParamsToSetTopic(); - const {topic, pinnedStory, topicPopular, setPath, searchDescription, searchFor} = useBlogContext(); + const {topic, pinnedStory, pageDescription, searchFor} = useBlogContext(); const subject = useSubjectSnippetForTopic(topic); const heading = useTopicHeading(topic, subject); useDocumentHead({ title: `${topic} blog posts - OpenStax`, - description: searchDescription + description: pageDescription }); return (
- +
{subject?.pageContent}
-
+
- { - topicPopular.map(blurbModel).map((article) => -
- -
- ) - } +
+
); } + +function PopularPosts() { + const {topicPopular, setPath} = useBlogContext(); + + return topicPopular.map(blurbModel).map((article) => ( +
+ +
+ )); +} + +// If it returns null, the topic is not a Subject +function useSubjectSnippetForTopic(topic?: string) { + const {subjectSnippet} = useBlogContext(); + + return subjectSnippet.find((s) => s.name === topic); +} + +function useTopicHeading(topic?: string, subject?: SubjectSnippet) { + const subjectHeading = React.useMemo( + () => `OpenStax ${topic} Textbooks`, + [topic] + ); + + return subject ? subjectHeading : topic; +} + +function HeadingForExplorePage({ + subject, + heading +}: { + subject?: SubjectSnippet; + heading?: string; +}) { + return ( +

+ {subject?.subjectIcon && } + {heading} +

+ ); +} + +function useParamsToSetTopic() { + const {exploreType, topic} = useParams(); + const {setTypeAndTopic} = useBlogContext(); + + React.useEffect(() => { + setTypeAndTopic(assertTType(exploreType), topic); + return () => setTypeAndTopic(undefined, undefined); + }, [exploreType, topic, setTypeAndTopic]); +} diff --git a/src/app/pages/blog/gated-content-dialog/gated-content-dialog.js b/src/app/pages/blog/gated-content-dialog/gated-content-dialog.tsx similarity index 71% rename from src/app/pages/blog/gated-content-dialog/gated-content-dialog.js rename to src/app/pages/blog/gated-content-dialog/gated-content-dialog.tsx index 1d000624b..b6f31d2b9 100644 --- a/src/app/pages/blog/gated-content-dialog/gated-content-dialog.js +++ b/src/app/pages/blog/gated-content-dialog/gated-content-dialog.tsx @@ -1,26 +1,91 @@ import React from 'react'; -import { useDialog } from '~/components/dialog/dialog'; +import {useDialog} from '~/components/dialog/dialog'; import linkHelper from '~/helpers/link'; import useUserContext from '~/contexts/user'; -import { useDataFromSlug, camelCaseKeys } from '~/helpers/page-data-utils'; +import {useDataFromSlug, camelCaseKeys} from '~/helpers/page-data-utils'; import ContactInfo from '~/components/contact-info/contact-info'; -import { RoleDropdown } from '~/components/role-selector/role-selector'; +import {RoleDropdown} from '~/components/role-selector/role-selector'; import FormInput from '~/components/form-input/form-input'; import DropdownSelect from '~/components/select/drop-down/drop-down'; import useFormTarget from '~/components/form-target/form-target'; +import type {ArticleData} from '../article/article'; +import useBlogContext from '../blog-context'; import cn from 'classnames'; import './gated-content-dialog.scss'; -const formSubmitUrl = window.SETTINGS.gatedContentEndpoint; +type WindowWithSettings = typeof window & { + SETTINGS: { + gatedContentEndpoint: string; + }; +}; + +const formSubmitUrl = (window as WindowWithSettings).SETTINGS + .gatedContentEndpoint; + +export default function WaitForData({articleData}: {articleData?: ArticleData}) { + return articleData?.gatedContent ? : null; +} + +function GatedContentDialog() { + const [Dialog, open, close] = useDialog(); + const {userModel} = useUserContext(); + const [submitted, setSubmitted] = React.useState(false); + + React.useEffect(() => { + if (userModel?.id || submitted) { + close(); + } else { + open(); + } + }, [userModel, open, close, submitted]); + + return ( + + + + ); +} + +function GatedContentBody({ + submitted, + setSubmitted +}: { + submitted: boolean; + setSubmitted: React.Dispatch>; +}) { + const loginLocation = new window.URL(linkHelper.loginLink()); + + return ( +
+
+ This post is exclusive. Please login or fill out the form to + access the article. +
+ + Login to my account + + +
+ ); +} function SubjectSelector() { - const data = useDataFromSlug('snippets/subjects?locale=en'); + const {subjectSnippet: data} = useBlogContext(); const options = React.useMemo( () => - camelCaseKeys(data || []).map((obj) => ({ - label: obj.name, - value: obj.name - })), + camelCaseKeys( + data.map((obj) => ({ + label: obj.name, + value: obj.name + })) + ), [data] ); const [value, setValue] = React.useState(); @@ -43,42 +108,26 @@ function SubjectSelector() { ); } -function RoleSelector({ value, setValue }) { - const roleData = useDataFromSlug('snippets/roles'); - const roleOptions = React.useMemo( - () => camelCaseKeys(roleData) || [], - [roleData] - ); - const message = value ? '' : 'Please select one'; - - return ( -
-
- - -
{message}
-
-
- ); -} - -function GatedContentForm({ submitted, setSubmitted }) { +function GatedContentForm({ + submitted, + setSubmitted +}: { + submitted: boolean; + setSubmitted: React.Dispatch>; +}) { const afterSubmit = React.useCallback( () => setSubmitted(true), [setSubmitted] ); - const { onSubmit, submitting, FormTarget } = useFormTarget(afterSubmit); - const [selectedRole, setSelectedRole] = React.useState(); + + const {onSubmit, FormTarget} = useFormTarget(afterSubmit); + const [selectedRole, setSelectedRole] = React.useState(); return ( - +
; +}) { + const roleData = useDataFromSlug('snippets/roles'); + const roleOptions = React.useMemo( + () => camelCaseKeys(roleData) || [], + [roleData] + ); + const message = value ? '' : 'Please select one'; return ( -
-
- This post is exclusive. Please login or fill out the form to - access the article. +
+
+ + +
{message}
- - Login to my account - -
); } - -function GatedContentDialog() { - const [Dialog, open, close] = useDialog(); - const { userModel } = useUserContext(); - const [submitted, setSubmitted] = React.useState(false); - - React.useEffect(() => { - if (userModel?.id || submitted) { - close(); - } else { - open(); - } - }, [userModel, open, close, submitted]); - - return ( - - - - ); -} - -export default function WaitForData({ articleData }) { - return articleData?.gatedContent ? : null; -} diff --git a/src/app/pages/blog/latest-blog-posts/latest-blog-posts.js b/src/app/pages/blog/latest-blog-posts/latest-blog-posts.tsx similarity index 100% rename from src/app/pages/blog/latest-blog-posts/latest-blog-posts.js rename to src/app/pages/blog/latest-blog-posts/latest-blog-posts.tsx diff --git a/src/app/pages/blog/more-stories/more-stories.js b/src/app/pages/blog/more-stories/more-stories.tsx similarity index 74% rename from src/app/pages/blog/more-stories/more-stories.js rename to src/app/pages/blog/more-stories/more-stories.tsx index 19f646d22..946681bc4 100644 --- a/src/app/pages/blog/more-stories/more-stories.js +++ b/src/app/pages/blog/more-stories/more-stories.tsx @@ -1,11 +1,16 @@ import React from 'react'; -import ArticleSummary, {blurbModel} from '../article-summary/article-summary'; +import ArticleSummary, {blurbModel, PopulatedBlurbModel} from '../article-summary/article-summary'; import useLatestBlogEntries from '~/models/blog-entries'; import useBlogContext from '../blog-context'; import './more-stories.scss'; import Section from '~/components/explore-page/section/section'; -export function LatestBlurbs({page, pageSize, exceptSlug='', openInNewWindow}) { +export function LatestBlurbs({page, pageSize, exceptSlug='', openInNewWindow}: { + page: number; + pageSize: number; + exceptSlug?: string; + openInNewWindow?: boolean; +}) { const numberNeeded = page * pageSize; const latestStories = useLatestBlogEntries(numberNeeded); const {setPath, topicStories} = useBlogContext(); @@ -16,7 +21,7 @@ export function LatestBlurbs({page, pageSize, exceptSlug='', openInNewWindow}) { const articles = (topicStories.length ? topicStories : latestStories) .map(blurbModel) - .filter((article) => exceptSlug !== article.articleSlug) + .filter((article: PopulatedBlurbModel) => exceptSlug !== article.articleSlug) .slice((page - 1) * pageSize, numberNeeded); return ( @@ -25,7 +30,7 @@ export function LatestBlurbs({page, pageSize, exceptSlug='', openInNewWindow}) { data-analytics-content-list="Latest Blog Posts" > { - articles.map((article) => + articles.map((article: PopulatedBlurbModel) =>
@@ -35,7 +40,12 @@ export function LatestBlurbs({page, pageSize, exceptSlug='', openInNewWindow}) { ); } -export default function MoreStories({exceptSlug, subhead}) { +export default function MoreStories({exceptSlug, subhead}: + { + exceptSlug: string; + subhead?: Parameters[0]['topicHeading']; + } +) { return (
diff --git a/src/app/pages/blog/pinned-article/pinned-article.js b/src/app/pages/blog/pinned-article/pinned-article.tsx similarity index 63% rename from src/app/pages/blog/pinned-article/pinned-article.js rename to src/app/pages/blog/pinned-article/pinned-article.tsx index 38811cd66..f1d78de9d 100644 --- a/src/app/pages/blog/pinned-article/pinned-article.js +++ b/src/app/pages/blog/pinned-article/pinned-article.tsx @@ -1,16 +1,19 @@ import React from 'react'; -import ArticleSummary, {blurbModel} from '../article-summary/article-summary'; +import ArticleSummary, { + ArticleSummaryData, + blurbModel +} from '../article-summary/article-summary'; import useBlogContext from '../blog-context'; import Section from '~/components/explore-page/section/section'; import './pinned-article.scss'; -export default function PinnedArticle({subhead=undefined}) { +export default function PinnedArticle({subhead}: {subhead?: string}) { const {pinnedStory, setPath} = useBlogContext(); if (!pinnedStory) { return null; } - const model = {...blurbModel(pinnedStory), setPath}; + const model = {...blurbModel(pinnedStory), setPath} as ArticleSummaryData; return (
@@ -18,7 +21,7 @@ export default function PinnedArticle({subhead=undefined}) { className="pinned-article" data-analytics-content-list="Featured Blog Posts" > - +
); diff --git a/src/app/pages/blog/search-results/use-all-articles.js b/src/app/pages/blog/search-results/use-all-articles.js deleted file mode 100644 index 52934f503..000000000 --- a/src/app/pages/blog/search-results/use-all-articles.js +++ /dev/null @@ -1,28 +0,0 @@ -import {useState, useEffect} from 'react'; -import {useLocation} from 'react-router-dom'; -import {fetchFromCMS, camelCaseKeys} from '~/helpers/page-data-utils'; -import {blurbModel} from '../article-summary/article-summary'; -import uniqBy from 'lodash/uniqBy'; - -export default function useAllArticles() { - const {search} = useLocation(); - const searchParam = new window.URLSearchParams(search).get('q'); - const [allArticles, setAllArticles] = useState([]); - - useEffect(() => { - const slug = `search/?q=${searchParam}`; - - setAllArticles([]); - fetchFromCMS(slug, true).then((results) => { - setAllArticles( - uniqBy(results, 'id').map((data) => { - data.heading = data.title; - delete data.subheading; - return blurbModel(camelCaseKeys(data)); - }) - ); - }); - }, [searchParam]); - - return allArticles; -} diff --git a/src/app/pages/blog/search-results/use-all-articles.tsx b/src/app/pages/blog/search-results/use-all-articles.tsx new file mode 100644 index 000000000..1265cd152 --- /dev/null +++ b/src/app/pages/blog/search-results/use-all-articles.tsx @@ -0,0 +1,33 @@ +import {useState, useEffect} from 'react'; +import {useLocation} from 'react-router-dom'; +import {fetchFromCMS, camelCaseKeys} from '~/helpers/page-data-utils'; +import { + blurbModel, + PopulatedBlurbModel +} from '../article-summary/article-summary'; +import uniqBy from 'lodash/uniqBy'; + +type PopulatedBlurbData = Exclude[0], null>; + +export default function useAllArticles() { + const {search} = useLocation(); + const searchParam = new window.URLSearchParams(search).get('q'); + const [allArticles, setAllArticles] = useState([]); + + useEffect(() => { + const slug = `search/?q=${searchParam}`; + + setAllArticles([]); + fetchFromCMS(slug, true).then((results: PopulatedBlurbData[]) => { + const articles = uniqBy(results, 'id').map((data) => { + data.heading = data.title; + data.subheading = ''; + return blurbModel(camelCaseKeys(data)) as PopulatedBlurbModel; + }); + + setAllArticles(articles); + }); + }, [searchParam]); + + return allArticles; +} diff --git a/test/helpers/fetch-mocker.js b/test/helpers/fetch-mocker.js index 24af594ee..336f2b23f 100644 --- a/test/helpers/fetch-mocker.js +++ b/test/helpers/fetch-mocker.js @@ -34,6 +34,8 @@ import rolesData from '../src/data/roles'; import salesforceData from '../src/data/salesforce'; import salesforcePartnerData from '../src/data/salesforce-partners'; import schoolsData from '../src/data/schools'; +import searchCollection from '../src/data/search-collection'; +import searchSubject from '../src/data/search-subject'; import sfapiUser from '../src/data/sfapi-user'; import sfapiLists from '../src/data/sfapi-lists'; import sfapiSchoolTrinity from '../src/data/sfapi-school-trinity'; @@ -44,6 +46,7 @@ import teamData from '../src/data/team'; import userData from '../src/data/user'; import archiveData from '../src/data/archive'; +// eslint-disable-next-line no-undef global.fetch = jest.fn().mockImplementation((...args) => { const isAdoption = (/pages\/adoption-form/).test(args[0]); const isAlgebra = (/v2\/pages\/39/).test(args[0]); @@ -79,6 +82,8 @@ global.fetch = jest.fn().mockImplementation((...args) => { const isRenewal = args[0].includes('renewal?account_uuid'); const isRoles = (/snippets\/roles/).test(args[0]); const isSchools = (/salesforce\/schools/).test(args[0]); + const isSearchCollection = args[0].includes('/search/?collection='); + const isSearchSubject = args[0].includes('/search/?subjects='); const isSfapiUser = (/api\/v1\/users/).test(args[0]); const isSfapiLists = (/api\/v1\/lists/).test(args[0]); const isSfapiSchoolTrinity = (/0017h00000YXEBzAAP/).test(args[0]); @@ -183,6 +188,10 @@ global.fetch = jest.fn().mockImplementation((...args) => { payload = salesforcePartnerData; } else if (isSfapiUser) { payload = sfapiUser; + } else if (isSearchCollection) { + payload = searchCollection; + } else if (isSearchSubject) { + payload = searchSubject; } else if (isSfapiLists) { payload = sfapiLists; } else if (isSfapiSchoolTrinity) { @@ -210,4 +219,4 @@ global.fetch = jest.fn().mockImplementation((...args) => { ); }); -window.ga = () => {}; +window.ga = () => {}; // eslint-disable-line @typescript-eslint/no-empty-function diff --git a/test/src/data/article-page-data.js b/test/src/data/article-page-data.js new file mode 100644 index 000000000..e6ec41cc8 --- /dev/null +++ b/test/src/data/article-page-data.js @@ -0,0 +1,186 @@ +// Blog article data as returned by usePageData +/* eslint-disable max-len */ +export default { + id: 754, + meta: { + slug: 'openstax-textbook-workplace-skills', + seoTitle: + 'Newest OpenStax textbook provides essential workplace skills to students', + searchDescription: + 'OpenStax’s newest textbook, “Workplace Software and Skills,” addresses ...', + type: 'news.NewsArticle', + detailUrl: 'https://openstax.org/apps/cms/api/v2/pages/754/', + htmlUrl: 'https://openstax.org/blog/openstax-textbook-workplace-skills', + showInMenus: false, + firstPublishedAt: '2023-11-30T12:36:38.165090-06:00', + aliasOf: null, + parent: { + id: 90, + meta: { + type: 'news.NewsIndex', + detailUrl: 'https://openstax.org/apps/cms/api/v2/pages/90/', + htmlUrl: 'https://openstax.org/openstax-news/' + }, + title: 'Openstax News' + }, + locale: 'en' + }, + title: 'Hot off the press: Workplace Software and Skills!', + date: '2023-11-30', + heading: 'Hot off the press: Workplace Software and Skills!', + subheading: + 'Latest textbook provides additional support for academic success and career preparation', + author: 'The OpenStax Team', + articleImage: + 'https://assets.openstax.org/oscms-prodcms/media/original_images/WSaS_image.jpg', + featuredImageSmall: { + url: 'https://assets.openstax.org/oscms-prodcms/media/images/WSaS_image.width-420.webp', + fullUrl: + 'https://assets.openstax.org/oscms-prodcms/media/images/WSaS_image.width-420.webp', + width: 420, + height: 420, + alt: 'WSaSGraphic_PressReleaseNewsArticle_2024' + }, + featuredImageAltText: + 'Graphic depiction of the cover of Workplace Software and Skills by OpenStax', + featuredVideo: [], + tags: ['business', 'higher education', 'textbooks'], + bodyBlurb: + '

Like all textbooks freely available in the OpenStax library, our latest publication, Workplace Software and Skills, is openly licensed and peer-reviewed. This long-awaited textbook addresses the evolving needs of college and university students preparing for their careers.

', + body: [ + { + type: 'paragraph', + value: '

Like all textbooks freely available in the OpenStax library, our latest publication, Workplace Software and Skills, is openly licensed and peer-reviewed. This long-awaited textbook addresses the evolving needs of college and university students preparing for their careers.

Workplace Software and Skills is poised to be a valuable asset for students seeking to build or enhance their computer skills in both Microsoft Office and Google Suite applications. The textbook is designed to support learners with the knowledge and proficiencies needed in today’s evolving job market, including real-world applications, guided walk-throughs and conceptual understanding questions, all with an emphasis on ethics. The textbook builds the learner’s understanding from internet basics to computer software programs then supports them as they apply those skills to various situations and environments. 

“The publication of ‘Workplace Software and Skills’ addresses the evolving needs of students, and frankly, of education systems in general,” said Anthony Palmiotto, director of higher education for OpenStax. “Students need quality educational resources to prepare for and thrive in a dynamic and evolving higher education landscape and job market.”

', + id: '536070df-f4c3-4475-9204-0558ff8b94ff' + }, + { + type: 'aligned_image', + value: { + image: { + id: 2070, + title: 'WorkplaceSoftwareSkillsGraphic_NewsArticle_2024', + original: { + src: 'https://assets.openstax.org/oscms-prodcms/media/images/Workplace_Software_and_Skills_-_2.original.webp', + width: 1080, + height: 1080, + alt: 'WorkplaceSoftwareSkillsGraphic_NewsArticle_2024' + } + }, + caption: + '

Workplace Software and Skills by OpenStax is available for free, online on November 30, 2023

', + alignment: 'left', + altText: + 'Graphic depiction of the cover of Workplace Software and Skills by OpenStax' + }, + id: '142d8a8c-22a0-4444-bd4a-d1ed83bea789' + }, + { + type: 'aligned_image', + value: { + image: { + id: 2070, + title: 'WorkplaceSoftwareSkillsGraphic_NewsArticle_2024', + original: { + src: 'https://assets.openstax.org/oscms-prodcms/media/images/Workplace_Software_and_Skills_-_2.original.webp', + width: 1080, + height: 1080, + alt: 'WorkplaceSoftwareSkillsGraphic_NewsArticle_2024' + } + }, + caption: + '

Workplace Software and Skills by OpenStax is available for free, online on November 30, 2023

', + alignment: 'bottom', + altText: + 'Graphic depiction of the cover of Workplace Software and Skills by OpenStax' + }, + id: 'bottom-aligned-image-stub' + }, + { + type: 'paragraph', + value: '

This publication covers hard and soft skills that are applicable to a broad range of academic majors and fields. The introduction of “Workplace Software and Skills” underscores OpenStax’s commitment to its mission of expanding access to high-quality educational materials for all learners — this book, in particular, is likely to be valuable for learners both within and without traditional academic environments. 

Since the publication of its first textbook in 2012, OpenStax has rapidly expanded its list of textbooks and technology offerings, and its minimalist book covers have become a familiar sight on campuses across the country. OpenStax resources have been used by 70% of colleges and universities across the United States and over 6,300 K-12 schools and districts.

To read the full press release, please click here.

', + id: '7c6ae377-18d5-4223-883b-a5e304dd084c' + }, + { + type: 'aligned_html', + value: 'Access the textbook now!', + id: '24138321-22a1-414e-ad05-96585c6b266d' + } + ], + pinToTop: false, + gatedContent: false, + collections: [ + { + type: 'collection', + value: [ + { + collection: { + name: 'Resources and features' + }, + featured: false, + popular: false + } + ], + id: 'c1ddcbb9-3968-49b7-b383-d31c33c3248c' + } + ], + articleSubjects: [ + { + type: 'subject', + value: [ + { + subject: { + name: 'Business' + }, + featured: false + } + ], + id: '0da558bc-9605-4573-be09-97d649629028' + } + ], + contentTypes: [ + { + type: 'content_type', + value: [ + { + contentType: { + contentType: 'Press Releases' + } + } + ], + id: 'e156388c-8e9e-460e-8d3f-34b17ece16b2' + } + ], + promoteImage: { + id: 2069, + meta: { + width: 1080, + height: 1080, + type: 'wagtailimages.Image', + detailUrl: 'https://openstax.org/apps/cms/api/v2/images/2069/', + downloadUrl: + 'https://assets.openstax.org/oscms-prodcms/media/original_images/WSaS_image.jpg' + }, + title: 'WSaSGraphic_PressReleaseNewsArticle_2024' + }, + slug: 'news/openstax-textbook-workplace-skills' +}; + +export const pdfBody = [ + { + type: 'document', + value: { + title: 'HS Physics Lab Manual Student', + downloadUrl: + '/documents/983/STUDENT_HS_Physics_Lab_Manual_Full_1.pdf' + }, + id: 'c1ceb1b9-6aba-40ce-a215-2723038d7367' + } +]; + +export const featuredVideo = [ + { + type: 'video', + value: '', + id: '63663ded-6ddf-4fca-a147-baeb19bcb9b5' + } +]; diff --git a/test/src/data/search-collection.js b/test/src/data/search-collection.js new file mode 100644 index 000000000..42db63be6 --- /dev/null +++ b/test/src/data/search-collection.js @@ -0,0 +1,97 @@ +/* eslint-disable max-len, camelcase */ +export default [ + { + id: 510, + title: 'Preparing K12 Students for a Global Economy through International Education', + subheading: + 'International education can facilitate college and career readiness', + body_blurb: + '

While international schools aren’t new, accelerating global trade and cultural exchanges have resulted in these institutions becoming more popular than ever before. While international schools are typically more challenging to enroll in than comparable mainstream institutions, a growing number of parents are doing what it takes to be able to send their children to them.

', + article_image: null, + article_image_alt: null, + date: '2023-07-19', + author: 'Anon', + pin_to_top: false, + tags: [], + collections: [ + { + name: 'Teaching and Learning', + featured: false, + popular: true + } + ], + article_subjects: [ + { + name: 'K12', + featured: false + } + ], + content_types: [], + slug: 'preparing-k12-students-for-a-global-economy-through-international-education', + seo_title: '', + search_description: '' + }, + { + id: 434, + title: 'Test Blog Post', + subheading: 'This is a test of gated content and blog cta', + body_blurb: + '

Google is turning to Taiwan’s Compal Electronics to manufacture the Pixel Watch, while we also learned today that it will have a USB-C charger.

', + article_image: + 'https://assets.openstax.org/oscms-dev/media/original_images/Promote_finance.jpeg', + article_image_alt: 'Money', + date: '2022-05-23', + author: 'Ed', + pin_to_top: false, + tags: [], + collections: [ + { + name: 'Teaching and Learning', + featured: true, + popular: false + } + ], + article_subjects: [ + { + name: 'Math', + featured: false + } + ], + content_types: ['Case study'], + slug: 'test-blog-post', + seo_title: '', + search_description: '' + }, + { + id: 109, + title: 'Jimmieka Mills part 1: Meet Jimmieka', + subheading: + "Part one in a series about how being raised in poverty affects a student's ability to go to college and graduate on time", + body_blurb: + '

My name is Jimmieka Mills. I was raised in the San Francisco bay area in a neighborhood where poverty, violence, and drug use are prominent. I was always an eager student and ready to learn, but my family’s low income created significant barriers to my ability to obtain an education. Today, I am a student at Houston Community College majoring in communications. My dream is to start a non-profit organization called The Beautiful Struggle which will provide financial, housing, and educational assistance to homeless and low income families and students with the ultimate goal of ensuring that fewer families fall victim to the vicious cycle of poverty.

', + article_image: + 'https://assets.openstax.org/oscms-dev/media/original_images/DSC_3863.jpg', + article_image_alt: null, + date: '2017-02-15', + author: 'Jimmieka Mills', + pin_to_top: false, + tags: ['studentlife', 'JimmiekaMills'], + collections: [ + { + name: 'Teaching and Learning', + featured: true, + popular: true + } + ], + article_subjects: [ + { + name: 'Math', + featured: true + } + ], + content_types: ['Case study'], + slug: 'beautiful-struggle-too-broke-learn', + seo_title: 'Too Broke To Learn', + search_description: '' + } +]; diff --git a/test/src/data/search-subject.js b/test/src/data/search-subject.js new file mode 100644 index 000000000..5ba9ebc31 --- /dev/null +++ b/test/src/data/search-subject.js @@ -0,0 +1,168 @@ +/* eslint-disable max-len, camelcase */ +export default [ + { + id: 434, + title: 'Test Blog Post', + subheading: 'This is a test of gated content and blog cta', + body_blurb: + '

Google is turning to Taiwan’s Compal Electronics to manufacture the Pixel Watch, while we also learned today that it will have a USB-C charger.

', + article_image: + 'https://assets.openstax.org/oscms-dev/media/original_images/Promote_finance.jpeg', + article_image_alt: 'Money', + date: '2022-05-23', + author: 'Ed', + pin_to_top: false, + heading: 'future-test', + tags: [], + collections: [ + { + name: 'Teaching and Learning', + featured: true, + popular: false + } + ], + article_subjects: [ + { + name: 'Math', + featured: false + } + ], + content_types: ['Case study'], + slug: 'test-blog-post', + seo_title: '', + search_description: '' + }, + { + id: 254, + title: 'And the winner is...', + subheading: + 'Three intern teams competed. One team won, and OpenStax On The Go was born.', + body_blurb: + '

Each summer, OpenStax interns work together in teams in a summer-long project, competing to create a feature that will help OpenStax expand accessibility and improve learning. The competition is stiff, as each team is comprised of interns across teams and bringing a variety of skills to the project.

', + article_image: + 'https://assets.openstax.org/oscms-dev/media/original_images/08-2018-openstax-intern-project.jpg', + article_image_alt: 'OpenStax interns 2018', + date: '2018-08-24', + author: 'Courtney Raymond', + pin_to_top: false, + tags: [], + collections: [], + article_subjects: [ + { + name: 'Math', + featured: false + } + ], + content_types: [], + slug: 'and-winner', + seo_title: '', + search_description: '' + }, + { + id: 222, + title: 'Kate Fletcher’s 100-mile run for college affordability', + subheading: + 'How one teacher raised over $10,000 in college scholarships for students in need', + body_blurb: + '

Several months ago, Kate Fletcher set a goal: to run 100 miles around her high school’s track in the span of a day to raise scholarship money for students in need.

', + article_image: + 'https://assets.openstax.org/oscms-dev/media/original_images/06-2018-kate-fletcher.jpg', + article_image_alt: null, + date: '2018-06-12', + author: 'Lindsay Josephs', + pin_to_top: false, + tags: [], + collections: [], + article_subjects: [ + { + name: 'Math', + featured: false + } + ], + content_types: [], + slug: 'kate-fletchers-100-mile-run-for-college-affordability', + seo_title: '', + search_description: '' + }, + { + id: 176, + title: 'Announcing OpenStax Creator Fest', + subheading: 'OpenStax launches a collaborative new conference', + body_blurb: '

Dear Textbook Heroes,

', + article_image: + 'https://assets.openstax.org/oscms-dev/media/original_images/openstax-creator-fest-2018-announcement.png', + article_image_alt: null, + date: '2017-10-11', + author: 'Daniel Williamson, ed. Mary K. Allen', + pin_to_top: false, + tags: [], + collections: [], + article_subjects: [ + { + name: 'Math', + featured: false + } + ], + content_types: [], + slug: 'announcing-openstax-creator-fest', + seo_title: '', + search_description: '' + }, + { + id: 134, + title: 'Be an OpenStax Intern', + subheading: 'Grow in your career while making an impact with OpenStax', + body_blurb: + '

OpenStax is hiring full-time, paid interns for Summer 2017! If you’re a Rice student looking for a way to gain career experience while making an impact on students like yourself, consider applying now.

', + article_image: + 'https://assets.openstax.org/oscms-dev/media/original_images/EstebanAndrea1200x600.jpg', + article_image_alt: null, + date: '2017-03-02', + author: 'Jessica Fuquay', + pin_to_top: false, + tags: ['openstax', 'education'], + collections: [], + article_subjects: [ + { + name: 'Math', + featured: false + } + ], + content_types: [], + slug: 'be-openstax-intern', + seo_title: '', + search_description: '' + }, + { + id: 109, + title: 'Jimmieka Mills part 1: Meet Jimmieka', + subheading: + "Part one in a series about how being raised in poverty affects a student's ability to go to college and graduate on time", + body_blurb: + '

My name is Jimmieka Mills. I was raised in the San Francisco bay area in a neighborhood where poverty, violence, and drug use are prominent. I was always an eager student and ready to learn, but my family’s low income created significant barriers to my ability to obtain an education. Today, I am a student at Houston Community College majoring in communications. My dream is to start a non-profit organization called The Beautiful Struggle which will provide financial, housing, and educational assistance to homeless and low income families and students with the ultimate goal of ensuring that fewer families fall victim to the vicious cycle of poverty.

', + article_image: + 'https://assets.openstax.org/oscms-dev/media/original_images/DSC_3863.jpg', + article_image_alt: null, + date: '2017-02-15', + author: 'Jimmieka Mills', + pin_to_top: false, + tags: ['studentlife', 'JimmiekaMills'], + collections: [ + { + name: 'Teaching and Learning', + featured: true, + popular: true + } + ], + article_subjects: [ + { + name: 'Math', + featured: true + } + ], + content_types: ['Case study'], + slug: 'beautiful-struggle-too-broke-learn', + seo_title: 'Too Broke To Learn', + search_description: '' + } +]; diff --git a/test/src/pages/adoption/adoption.test.js b/test/src/pages/adoption/adoption.test.js index 3570012b0..e3787d7b0 100644 --- a/test/src/pages/adoption/adoption.test.js +++ b/test/src/pages/adoption/adoption.test.js @@ -29,10 +29,10 @@ test('creates with role selector', () => expect(screen.queryAllByRole('option', {hidden: true})).toHaveLength(8)); test('form appears when role is selected', async () => { - const listBox = screen.queryByRole('listbox'); + const listBoxes = screen.queryAllByRole('listbox'); const user = userEvent.setup(); - await user.click(listBox); + await user.click(listBoxes[1]); const options = await screen.findAllByRole('option', {hidden: true}); const instructorOption = options.find( (o) => o.textContent === 'Instructor' diff --git a/test/src/pages/blog/article-summary.test.tsx b/test/src/pages/blog/article-summary.test.tsx index 842ac1ee9..a6bf73e55 100644 --- a/test/src/pages/blog/article-summary.test.tsx +++ b/test/src/pages/blog/article-summary.test.tsx @@ -1,7 +1,9 @@ import React from 'react'; import {render, screen} from '@testing-library/preact'; import {describe, it, expect} from '@jest/globals'; -import ArticleSummary, {blurbModel} from '~/pages/blog/article-summary/article-summary'; +import ArticleSummary, { + blurbModel +} from '~/pages/blog/article-summary/article-summary'; import userEvent from '@testing-library/user-event'; describe('article-summary', () => { @@ -11,13 +13,13 @@ describe('article-summary', () => { render( { const links = await screen.findAllByRole('link'); expect(links).toHaveLength(2); - expect(links.filter((l) => l.getAttribute('target') === '_blank')).toHaveLength(2); + expect( + links.filter((l) => l.getAttribute('target') === '_blank') + ).toHaveLength(2); await user.click(links[0]); expect(setPath).not.toBeCalled(); @@ -38,13 +42,13 @@ describe('article-summary', () => { render( { const links = await screen.findAllByRole('link'); expect(links).toHaveLength(2); - expect(links.filter((l) => l.getAttribute('target') === '_blank')).toHaveLength(0); + expect( + links.filter((l) => l.getAttribute('target') === '_blank') + ).toHaveLength(0); await user.click(links[0]); expect(setPath).toBeCalled(); }); + it('is ok with no setPath', async () => { + const user = userEvent.setup(); + const originalError = console.error; + + console.error = jest.fn(); + + render( + + ); + const links = await screen.findAllByRole('link'); + + expect(links).toHaveLength(2); + expect( + links.filter((l) => l.getAttribute('target') === '_blank') + ).toHaveLength(0); + + await user.click(links[0]); + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining('Error: Not implemented: navigation'), + undefined + ); + console.error = originalError; + }); }); describe('blurbModel', () => { @@ -70,16 +110,20 @@ describe('blurbModel', () => { slug: 'required', collections: [ { - value: [{ - collection: {name: 'first collection', value: []} - }] + value: [ + { + collection: {name: 'first collection', value: []} + } + ] } ], articleSubjects: [ { - value: [{ - subject: {name: 'first articleSubject', value: []} - }] + value: [ + { + subject: {name: 'first articleSubject', value: []} + } + ] } ] } as unknown as Parameters[0]); diff --git a/test/src/pages/blog/article.test.tsx b/test/src/pages/blog/article.test.tsx new file mode 100644 index 000000000..2818afbc4 --- /dev/null +++ b/test/src/pages/blog/article.test.tsx @@ -0,0 +1,141 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import {describe, it, expect} from '@jest/globals'; +import {ArticleFromSlug} from '~/pages/blog/article/article'; +import useScrollProgress from '~/pages/blog/article/use-progress'; +import pageData, {featuredVideo, pdfBody} from '~/../../test/src/data/article-page-data'; +import * as HelpersData from '~/helpers/data'; +import * as WCtx from '~/contexts/window'; + +const mockUsePageData = jest.fn(); +const onload = jest.fn(); + +jest.mock('~/helpers/use-page-data', () => ({ + __esModule: true, + default: () => mockUsePageData() +})); + +const mockJITLoad = jest.fn(); + +jest.mock('~/helpers/jit-load', () => ({ + __esModule: true, + default: () => mockJITLoad() +})); + +describe('blog/article', () => { + afterEach(() => jest.resetAllMocks()); + it('loads article', () => { + mockUsePageData.mockReturnValue(pageData); + render(); + + expect(onload).toHaveBeenCalled(); + screen.getByText('2 min read'); + }); + it('handles no data', () => { + mockUsePageData.mockReturnValue(undefined); + render(); + + expect(onload).not.toHaveBeenCalled(); + }); + it('handles data load error', () => { + mockUsePageData.mockReturnValue({error: {message: 'whoops'}}); + render(); + + screen.getByText('whoops', {exact: false}); + }); + it('handls PDF article', () => { + mockUsePageData.mockReturnValue({...pageData, body: pdfBody}); + const {container} = render(); + + expect(mockJITLoad).toHaveBeenCalled(); + expect(container.querySelector('.pdf-title-block')).toBeTruthy(); + }); + it('handles Video article', () => { + mockUsePageData.mockReturnValue({...pageData, featuredVideo}); + const {container} = render(); + + expect(mockJITLoad).not.toHaveBeenCalled(); + expect(container.querySelector('.video-block')).toBeTruthy(); + }); + it('handles empty body', () => { + mockUsePageData.mockReturnValue({...pageData, body: []}); + render(); + + expect(onload).toHaveBeenCalled(); + screen.getByText('0 min read'); + }); +}); + +const mockUseRefreshable = jest.fn(); +const mockUseWindowContext = jest.fn(); + +describe('blog/article/use-progress', () => { + const Component = () => { + const ref = React.useRef(null); + const p = useScrollProgress(ref); + + return ( +
+ {p[0]} +
+ ); + }; + + beforeEach( + () => { + jest.spyOn(HelpersData, 'useRefreshable').mockImplementation(() => mockUseRefreshable()); + jest.spyOn(WCtx, 'default').mockImplementation(() => mockUseWindowContext()); + } + ); + + afterEach( + () => jest.clearAllMocks() + ); + + it('returns 0 for negative', () => { + mockUseRefreshable.mockReturnValue([ + { + top: 100, + bottom: 150 + }, + () => null + ]); + mockUseWindowContext.mockReturnValue({ + innerHeight: 50, + scrollY: 0 + }); + render(); + screen.getByText('0'); + }); + it('returns 100 for viewport > rect bottom', () => { + mockUseRefreshable.mockReturnValue([ + { + top: 0, + bottom: 50 + }, + () => null + ]); + mockUseWindowContext.mockReturnValue({ + innerHeight: 150, + scrollY: 0 + }); + render(); + screen.getByText('100'); + }); + it('returns 100 for viewport > rect bottom', () => { + mockUseRefreshable.mockReturnValue([ + { + top: 0, + bottom: 150, + height: 150 + }, + () => null + ]); + mockUseWindowContext.mockReturnValue({ + innerHeight: 70, + scrollY: 0 + }); + render(); + screen.getByText('47'); + }); +}); diff --git a/test/src/pages/blog/blog.test.js b/test/src/pages/blog/blog.test.js deleted file mode 100644 index 414249046..000000000 --- a/test/src/pages/blog/blog.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import {render, screen} from '@testing-library/preact'; -import {BrowserRouter, MemoryRouter, Routes, Route} from 'react-router-dom'; -import {BlogContextProvider} from '~/pages/blog/blog-context'; -import {MainBlogPage, ArticlePage} from '~/pages/blog/blog'; -import {MainClassContextProvider} from '~/contexts/main-class'; -import {test, expect} from '@jest/globals'; - -test('blog default page', async () => { - render( - - - - - - - - ); - expect(await screen.findAllByText('Read more')).toHaveLength(3); - expect(screen.queryAllByRole('textbox')).toHaveLength(1); -}); - -test('blog Article page', async () => { - render( - - - - } /> - - - - ); - expect(await screen.findAllByText('Read more')).toHaveLength(3); - expect(screen.queryAllByRole('link')).toHaveLength(7); -}); diff --git a/test/src/pages/blog/blog.test.tsx b/test/src/pages/blog/blog.test.tsx new file mode 100644 index 000000000..e84098db5 --- /dev/null +++ b/test/src/pages/blog/blog.test.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import {render, screen, waitFor} from '@testing-library/preact'; +import {BrowserRouter, MemoryRouter, Routes, Route} from 'react-router-dom'; +import useBlogContext, { + BlogContextProvider, + assertTType +} from '~/pages/blog/blog-context'; +import { + MainBlogPage, + ArticlePage, + SearchResultsPage +} from '~/pages/blog/blog-pages'; +import {MainClassContextProvider} from '~/contexts/main-class'; +import {describe, test, expect} from '@jest/globals'; +import * as PDU from '~/helpers/page-data-utils'; + +describe('blog pages', () => { + beforeAll(() => { + const description = document.createElement('meta'); + + description.setAttribute('name', 'description'); + document.head.appendChild(description); + }); + test('Main page', async () => { + render( + + + + + + + + ); + expect(await screen.findAllByText('Read more')).toHaveLength(3); + expect(screen.queryAllByRole('textbox')).toHaveLength(1); + }); + + test('Article page', async () => { + window.scrollTo = jest.fn(); + + render( + + + + } /> + + + + ); + expect(await screen.findAllByText('Read more')).toHaveLength(3); + expect(screen.queryAllByRole('link')).toHaveLength(7); + expect(window.scrollTo).toHaveBeenCalledWith(0, 0); + }); + + test('Search Results page', async () => { + /* eslint-disable camelcase, max-len */ + jest.spyOn(PDU, 'fetchFromCMS').mockResolvedValueOnce( + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((id) => ({ + id, + slug: `slug-${id}`, + collections: [], + article_subjects: [] + })) + ); + + render( + + + + ); + expect(document.head.querySelector('title')?.textContent).toBe( + 'OpenStax Blog Search' + ); + }); + + test('assertTType throws for invalid value', () => { + expect(() => assertTType('invalid')).toThrowError(); + }); + + test('blog-context searchFor', async () => { + function Inner() { + const {searchFor} = useBlogContext(); + + React.useEffect(() => searchFor('education'), [searchFor]); + + return null; + } + + function Outer() { + return ( + + + + + + ); + } + + window.scrollTo = jest.fn(); + + render(); + await waitFor(() => expect(window.scrollTo).toHaveBeenCalledWith(0, 0)); + }); +}); diff --git a/test/src/pages/blog/explore-page.test.tsx b/test/src/pages/blog/explore-page.test.tsx new file mode 100644 index 000000000..ac9f2f935 --- /dev/null +++ b/test/src/pages/blog/explore-page.test.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import {describe, it, expect} from '@jest/globals'; +import ExplorePage from '~/pages/blog/explore-page/explore-page'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { BlogContextProvider } from '~/pages/blog/blog-context'; + +function Component({path}: {path: string}) { + return ( + + + + } + /> + + + + ); +} + +describe('blog/explore-page', () => { + it('renders Explore collection page layout', async () => { + render(); + + expect(await screen.findAllByText('Teaching and Learning')).toHaveLength(3); + }); + it('renders Explore subject page layout', async () => { + render(); + + expect(await screen.findAllByText('OpenStax Math Textbooks')).toHaveLength(4); + }); +}); diff --git a/test/src/pages/blog/gated-content-dialog.test.tsx b/test/src/pages/blog/gated-content-dialog.test.tsx new file mode 100644 index 000000000..09b9cba11 --- /dev/null +++ b/test/src/pages/blog/gated-content-dialog.test.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import {render, screen, waitFor, fireEvent} from '@testing-library/preact'; +import {describe, it, expect} from '@jest/globals'; +import ShellContextProvider from '~/../../test/helpers/shell-context'; +import {MainClassContextProvider} from '~/contexts/main-class'; +import {BlogContextProvider} from '~/pages/blog/blog-context'; +import {MemoryRouter} from 'react-router-dom'; +import GatedContentDialog from '~/pages/blog/gated-content-dialog/gated-content-dialog'; +import type {ArticleData} from '~/pages/blog/article/article'; +import userEvent from '@testing-library/user-event'; + +const articleData: ArticleData = { + gatedContent: true, + heading: 'heading', + subheading: 'subhead', + date: 'a date', + author: 'author', + body: [], + featuredVideo: [{value: ''}], + articleImage: 'article-image', + featuredImageAltText: 'alt text', + tags: [] +}; + +const mockUseDialog = jest.fn(); +const mockOpen = jest.fn(); +const mockClose = jest.fn(); +const mockUseUserContext = jest.fn(); +const userData = { + userModel: { + id: 123, + instructorEligible: false + } +}; +const mockUseFormTarget = jest.fn(); + +jest.mock('~/components/dialog/dialog', () => ({ + ...jest.requireActual('~/components/dialog/dialog'), + useDialog: () => mockUseDialog() +})); + +jest.mock('~/contexts/user', () => ({ + ...jest.requireActual('~/contexts/user'), + __esModule: true, + default: () => mockUseUserContext() +})); + +mockUseUserContext.mockReturnValue(userData); + +jest.mock('~/components/form-target/form-target', () => ({ + __esModule: true, + default: (...args: unknown[]) => mockUseFormTarget(...args) +})); + +mockUseFormTarget.mockImplementation((fn) => { + return { + onSubmit: (e: Event) => { + e.preventDefault(); + fn(); + }, + FormTarget: () =>
+ }; +}); + +function Component({path}: {path: string}) { + return ( + + + + + + + + + + ); +} + +mockUseDialog.mockReturnValue([ + ({children}: React.PropsWithChildren>) => ( +
{children}
+ ), + mockOpen, + mockClose +]); + +describe('blog/gated-content-dialog', () => { + const user = userEvent.setup(); + + beforeEach(() => { + mockOpen.mockReset(); + mockClose.mockReset(); + }); + + it('displays when gatedContent is set in article data', async () => { + render(); + + expect(await screen.findAllByText('Please select one')).toHaveLength(4); + expect(mockOpen).not.toHaveBeenCalled(); + const comboBoxes = screen.getAllByRole('combobox'); + + // Make selection in Subjects combobox + expect(comboBoxes).toHaveLength(2); + await user.click(comboBoxes[0]); + const options = screen.getAllByRole('option'); + + expect(options).toHaveLength(5); + + await user.click(options[1]); + // Select Faculty role + await user.click(comboBoxes[1]); + const facultyOption = screen + .getAllByRole('option') + .find((o) => o.textContent === 'Instructor'); + + await user.click(facultyOption as Element); + // Fill in other fields + const fields = screen.getAllByRole('textbox'); + + fields.forEach((i) => + fireEvent.change(i, {target: {value: 'something'}}) + ); + + // Click submit + await user.click(screen.getByRole('button')); + }); + it('calls open when there is no user id', async () => { + mockUseUserContext.mockReturnValue({}); + + render(); + await waitFor(() => expect(mockOpen).toHaveBeenCalled()); + expect(mockClose).not.toHaveBeenCalled(); + mockUseUserContext.mockClear(); + }); +}); diff --git a/test/src/pages/blog/latest-blog-posts.test.tsx b/test/src/pages/blog/latest-blog-posts.test.tsx new file mode 100644 index 000000000..ab8837871 --- /dev/null +++ b/test/src/pages/blog/latest-blog-posts.test.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import {render, screen} from '@testing-library/preact'; +import {describe, it, expect} from '@jest/globals'; +import {BrowserRouter, MemoryRouter, Routes, Route} from 'react-router-dom'; +import {BlogContextProvider} from '~/pages/blog/blog-context'; + +import LatestBlogPosts from '~/pages/blog/latest-blog-posts/latest-blog-posts'; + +function Component() { + return ( + + + + + + ); +} + +describe('blog/latest-blog-posts', () => { + it('renders Latest Blog Posts page', async () => { + render(); + + await screen.findAllByText('Search all blog posts'); + screen.getByText('showing 1-9 of', {exact: false}); + expect(screen.getAllByRole('button')).toHaveLength(4); + }); +}); diff --git a/test/src/pages/blog/search-results.test.tsx b/test/src/pages/blog/search-results.test.tsx index ab2a3e3cc..6763ceaad 100644 --- a/test/src/pages/blog/search-results.test.tsx +++ b/test/src/pages/blog/search-results.test.tsx @@ -1,17 +1,17 @@ import React from 'react'; import {describe, expect, it} from '@jest/globals'; -import {render} from '@testing-library/preact'; +import {render, screen, waitFor} from '@testing-library/preact'; import PinnedArticle from '~/pages/blog/pinned-article/pinned-article'; import {LatestBlurbs} from '~/pages/blog/more-stories/more-stories'; -import useAllArticles from '~/pages/blog/search-results/use-all-articles'; import {MemoryRouter} from 'react-router-dom'; import SearchResults from '~/pages/blog/search-results/search-results'; +import * as PDU from '~/helpers/page-data-utils'; +import * as AS from '~/pages/blog/article-summary/article-summary'; jest.mock('~/pages/blog/pinned-article/pinned-article', () => jest.fn()); jest.mock('~/pages/blog/more-stories/more-stories', () => ({ LatestBlurbs: jest.fn() })); -jest.mock('~/pages/blog/search-results/use-all-articles', () => jest.fn()); function Component() { return ( @@ -22,27 +22,34 @@ function Component() { } describe('search-results', () => { - it('renders no results when there are no articles', () => { - (useAllArticles as jest.Mock).mockReturnValueOnce([]); + it('renders no results when there are no articles', async () => { + jest.spyOn(PDU, 'fetchFromCMS').mockResolvedValueOnce([]); render(); expect(PinnedArticle).toHaveBeenCalled(); expect(LatestBlurbs).toHaveBeenCalled(); + await screen.findByText('No matching blog posts found'); jest.clearAllMocks(); }); - it('renders with paginator context when there are articles', () => { - (useAllArticles as jest.Mock).mockReturnValueOnce([ - { - articleSlug: 'whatever', - collectionNames: [], - articleSubjectNames: [] - }, - { - articleSlug: 'second', - collectionNames: [], - articleSubjectNames: [] - } - ]); + it('renders with paginator context when there are articles', async () => { + /* eslint-disable camelcase, max-len */ + jest.spyOn(PDU, 'fetchFromCMS').mockResolvedValueOnce( + [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((id) => ({ + id, + slug: `slug-${id}`, + collections: [], + article_subjects: [] + })) + ); + const spyArticleSummary = jest.spyOn(AS, 'default'); + render(); - expect(PinnedArticle).not.toHaveBeenCalled(); + + expect(await screen.findAllByText('Read more')).toHaveLength(10); + screen.getByText('1-10 of 12', {exact: false}); + expect(spyArticleSummary).toHaveBeenCalledTimes(10); + // Focus is set to the first article + await waitFor( + () => expect(document.activeElement?.tagName).toBe('A') + ); }); });