diff --git a/jest.setup.ts b/jest.setup.ts index e6270290d..c89ecdd02 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -496,7 +496,29 @@ const mockDataServices: MockDataServices = { student_rating: {}, learning_element_rating: {} }) - }) + }), + fetchNews: jest.fn(() => + Promise.resolve({ + news: [ + { + date: 'Thu, 13 Jul 2023 16:00:00 GMT', + expiration_date: 'Sun, 20 Apr 2025 16:00:00 GMT', + id: 1, + language_id: 'en', + news_content: 'We are currently testing the site', + university: 'TH-AB' + }, + { + date: 'Thu, 13 Jul 2023 16:00:00 GMT', + expiration_date: 'Sun, 20 Apr 2025 16:00:00 GMT', + id: 2, + language_id: 'en', + news_content: 'We are currently testing the site', + university: 'TH-AB' + } + ] + }) + ) } /** * This object is used to store mocks. After each test, the object is cleaned up. diff --git a/src/common/components/DefaultCollapse/DefaultCollapse.tsx b/src/common/components/DefaultCollapse/DefaultCollapse.tsx new file mode 100644 index 000000000..25adcfd33 --- /dev/null +++ b/src/common/components/DefaultCollapse/DefaultCollapse.tsx @@ -0,0 +1,3 @@ +import DefaultCollapse from '@mui/material/Collapse' + +export { DefaultCollapse as Collapse } diff --git a/src/common/components/index.ts b/src/common/components/index.ts index 281707d2f..38dcd46ab 100644 --- a/src/common/components/index.ts +++ b/src/common/components/index.ts @@ -18,6 +18,7 @@ export { Stack } from './DefaultStack/DefaultStack' export { Skeleton } from './DefaultSkeleton/DefaultSkeleton' export { Backdrop } from './DefaultBackdrop/DefaultBackdrop' export { CircularProgress } from './DefaultCircularProgress/DefaultCircularProgress' +export { Collapse } from './DefaultCollapse/DefaultCollapse' export { InputAdornment } from './DefaultInputAdornment/DefaultInputAdornment' export { Paper } from './DefaultPaper/DefaultPaper' export { Fade } from './DefaultFade/DefaultFade' diff --git a/src/common/hooks/University/University.hooks.tsx b/src/common/hooks/University/University.hooks.tsx new file mode 100644 index 000000000..06b363d8e --- /dev/null +++ b/src/common/hooks/University/University.hooks.tsx @@ -0,0 +1,31 @@ +import log from 'loglevel' +import { usePersistedStore } from '@store' +import { useTranslation } from 'react-i18next' +import { useState, useEffect, useMemo } from 'react' + +export type UniversityHookReturn = { + readonly university: string +} + +export const useUniversity = (): UniversityHookReturn => { + const { t } = useTranslation() + const [university, setUniversity] = useState('') + const getUser = usePersistedStore((state) => state.getUser) + useEffect(() => { + //fetch the university from the current user and return university + getUser() + .then((user) => { + setUniversity(user.university) + }) + .catch((error) => { + log.error(t('error.getUser') + ' ' + error) + setUniversity('') + }) + }, []) + return useMemo( + () => ({ + university + }), + [university] + ) +} diff --git a/src/common/hooks/University/University.test.tsx b/src/common/hooks/University/University.test.tsx new file mode 100644 index 000000000..6f96fe1ac --- /dev/null +++ b/src/common/hooks/University/University.test.tsx @@ -0,0 +1,39 @@ +import '@testing-library/jest-dom' +import { renderHook, waitFor } from '@testing-library/react' +import { mockServices } from 'jest.setup' +import { MemoryRouter } from 'react-router-dom' +import { useUniversity } from '@common/hooks' + +test('useUniversity returns valid value', async () => { + mockServices.fetchUser = jest.fn().mockImplementationOnce(() => + Promise.resolve({ + id: 1, + lms_user_id: 1, + name: 'Thaddäus Tentakel', + role: 'Tester', + role_id: 1, + settings: { + id: 1, + user_id: 1, + pswd: '1234', + theme: 'test' + }, + university: 'TH-AB' + }) + ) + + const { result } = renderHook(() => useUniversity(), { + wrapper: ({ children }) => {children} + }) + await waitFor(() => { + expect(result.current.university).toStrictEqual('TH-AB') + }) +}) + +test('useUniversity returns empty string when fetch fails', async () => { + mockServices.fetchUser = jest.fn().mockImplementationOnce(() => Promise.reject(new Error('error'))) + const { result } = renderHook(() => useUniversity(), { + wrapper: ({ children }) => {children} + }) + expect(await result.current.university).toBe('') +}) diff --git a/src/common/hooks/index.ts b/src/common/hooks/index.ts index 9940b5494..a85f2eccc 100644 --- a/src/common/hooks/index.ts +++ b/src/common/hooks/index.ts @@ -1,3 +1,4 @@ export { useTheme } from './DefaultUseTheme/DefaultUseTheme' export { useMediaQuery } from './DefaultMediaQuery/DefaultMediaQuery' export { useLearningPathTopicProgress } from './LearningPathTopicProgress/LearningPathTopicProgress.hooks' +export * from './University/University.hooks' diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index a4ac20c38..757f8b179 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -1,5 +1,4 @@ import { HaskiTheme } from './Theme/HaskiTheme' import { Theme } from './Theme/Theme' import { reportWebVitals, sendToAnalytics } from './Webvitals/Webvitals' - export { reportWebVitals, sendToAnalytics, Theme, HaskiTheme } diff --git a/src/components/Newsbanner/Newsbanner.hooks.tsx b/src/components/Newsbanner/Newsbanner.hooks.tsx new file mode 100644 index 000000000..62d01edd9 --- /dev/null +++ b/src/components/Newsbanner/Newsbanner.hooks.tsx @@ -0,0 +1,63 @@ +import log from 'loglevel' +import { useMemo, useState, useContext, useEffect } from 'react' +import { SnackbarContext } from '@services' +import { useSessionStore } from '@store' +import { useTranslation } from 'react-i18next' +import { useUniversity } from '@common/hooks' +import i18next from 'i18next' + +/** + * @prop sets the newsItem if there is atleast one news + * and returns a string of all news + * @prop hasItem - check if there are any news + * @category Hooks + * @interface + */ + +export type NewsbannerHookReturn = { + readonly isNewsAvailable: boolean + readonly newsText: string +} + +/** + * useNewsbanner hook. + * @remarks + * Hook for the Newsbanner logic. + * first checks if there are any news and then returns a string with all news items. + * also changes news if the language gets changed + * + * @returns - hasItem and all news as a string + * + * @category Hooks + */ + +export const useNewsbanner = (): NewsbannerHookReturn => { + const { t, i18n } = useTranslation() + const { addSnackbar } = useContext(SnackbarContext) + const getNews = useSessionStore((state) => state.getNews) + const { university } = useUniversity() + const [isNewsAvailable, setIsNewsAvailable] = useState(false) + const [newsText, setNewsText] = useState('') + + //** Logic **/ + //returns combined string of all the news + //and checks if there are news + useEffect(() => { + getNews(i18n.language, university) + .then((response) => { + setIsNewsAvailable(response.news.length != 0) + setNewsText(response.news.map(({ news_content }) => news_content).join(', ')) + }) + .catch((error) => { + addSnackbar({ + message: t('error.getNews'), + severity: 'error', + autoHideDuration: 3000 + }) + log.error(t('error.getNews') + ' ' + error) + setNewsText('') + }) + }, [i18next.language]) + + return useMemo(() => ({ isNewsAvailable, newsText }), [isNewsAvailable, newsText]) +} diff --git a/src/components/Newsbanner/Newsbanner.test.tsx b/src/components/Newsbanner/Newsbanner.test.tsx new file mode 100644 index 000000000..d8676ad4d --- /dev/null +++ b/src/components/Newsbanner/Newsbanner.test.tsx @@ -0,0 +1,131 @@ +import '@testing-library/jest-dom' +import { fireEvent, render, act, renderHook } from '@testing-library/react' +import { mockServices } from 'jest.setup' +import Newsbanner from './Newsbanner' +import { MemoryRouter } from 'react-router-dom' +import { useUniversity } from '@common/hooks' + +describe('Newsbanner tests', () => { + beforeEach(() => { + window.sessionStorage.clear() + }) + + test('Newsbanner is open', async () => { + mockServices.fetchNews = jest.fn().mockImplementation(() => + Promise.resolve({ + news: [ + { + date: 'Thu, 13 Jul 2023 16:00:00 GMT', + expiration_date: 'Sun, 20 Apr 2025 16:00:00 GMT', + id: 1, + language_id: 'de', + news_content: 'Wir testen die Seite', + university: 'TH-AB' + } + ] + }) + ) + + const { getByTestId, rerender } = render( + + + + ) + + await new Promise(process.nextTick) + + rerender( + + + + ) + + await new Promise(process.nextTick) + + rerender( + + + + ) + await act(async () => { + //expect(rerender).toContain('Wir testen die Seite') + const closeButton = getByTestId('NewsBannerCloseButton') + expect(closeButton).toBeInTheDocument() + }) + }) + + test('Close the open Newsbanner', async () => { + mockServices.fetchNews.mockImplementation(() => + Promise.resolve({ + news: [ + { + date: 'Thu, 13 Jul 2023 16:00:00 GMT', + expiration_date: 'Sun, 20 Apr 2025 16:00:00 GMT', + id: 1, + language_id: 'en', + news_content: 'We are currently testing the site', + university: 'TH-AB' + } + ] + }) + ) + + const { container, getByTestId, rerender } = render( + + + + ) + + await new Promise(process.nextTick) + + rerender( + + + + ) + + await new Promise(process.nextTick) + + rerender( + + + + ) + + await act(async () => { + const closeButton = getByTestId('NewsBannerCloseButton') + fireEvent.click(closeButton) + }) + + expect(container).toBeEmptyDOMElement() + }) + + test('Newsbanner has an error when fetching the News', () => { + mockServices.fetchNews.mockImplementationOnce(() => { + throw new Error('Error') + }) + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) + + test('Newsbanner has an error when fetching the University', async () => { + mockServices.fetchUser.mockImplementationOnce(() => { + throw new Error('Error') + }) + + const { result } = renderHook(() => useUniversity(), { + wrapper: ({ children }) => {children} + }) + expect(await result.current.university).toBe('') + }) + + test('Newsbanner doesnt open because no news', () => { + mockServices.fetchNews.mockImplementationOnce(() => + Promise.resolve({ + news: [{}] + }) + ) + const { container } = render() + expect(container).toBeEmptyDOMElement() + }) +}) diff --git a/src/components/Newsbanner/Newsbanner.tsx b/src/components/Newsbanner/Newsbanner.tsx new file mode 100644 index 000000000..55e53101b --- /dev/null +++ b/src/components/Newsbanner/Newsbanner.tsx @@ -0,0 +1,80 @@ +import { Alert, Box, Collapse, IconButton, Typography } from '@common/components' +import { Close } from '@common/icons' +import { memo } from 'react' +import { keyframes } from '@emotion/react' +import { NewsbannerHookReturn, useNewsbanner as _useNewsbanner } from './Newsbanner.hooks' +import { useSessionStore } from '@store' + +export type NewsbannerProps = { + useNewsbanner?: () => NewsbannerHookReturn +} + +/** + * Newsbanner component + * @remarks + * Newsbanner shows a banner between the menubar and the breadcrumbs, + * but only if there are news. The news get fitted by their charakter length. + * The closed state gets saved in the sessionStorage and removed when the window closes. + * @category Components + */ + +const Newsbanner = ({ useNewsbanner = _useNewsbanner }: NewsbannerProps) => { + const { isNewsAvailable, newsText } = useNewsbanner() + const setIsBannerOpen = useSessionStore((state) => state.setIsBannerOpen) + const isBannerOpen = useSessionStore((state) => state.isBannerOpen) + + //Animation logic + const textLength = newsText.length * 10 + const windowWidth = window.innerWidth + const textPercent = textLength / windowWidth + + const scrolling = keyframes` + from { + transform: translateX(100%) + }, + to { + transform: translateX(-${textPercent * 100}%) + } +` + + return ( + <> + {isNewsAvailable && isBannerOpen && ( + + + { + setIsBannerOpen(false) + }}> + + + }> + + {newsText} + + + + + )} + + ) +} +export default memo(Newsbanner) diff --git a/src/components/PrivacyModal/PrivacyModal.hooks.tsx b/src/components/PrivacyModal/PrivacyModal.hooks.tsx index 5752f4bfd..6c2dae546 100644 --- a/src/components/PrivacyModal/PrivacyModal.hooks.tsx +++ b/src/components/PrivacyModal/PrivacyModal.hooks.tsx @@ -3,7 +3,6 @@ import { useCallback, useContext, useMemo } from 'react' import { CookiesProvider, useCookies } from 'react-cookie' import { useTranslation } from 'react-i18next' import { SnackbarContext } from '@services' -import { usePersistedStore } from '@store' /** * @prop privacyPolicyCookie - The currently set cookie @@ -16,7 +15,6 @@ import { usePersistedStore } from '@store' export type PrivacyModalHookReturn = { readonly privacyPolicyCookie: CookiesProvider readonly handleAccept: (isAccepted: boolean) => void - readonly checkUniversity: () => Promise } /** @@ -35,21 +33,8 @@ export const usePrivacyModal = (): PrivacyModalHookReturn => { const { addSnackbar } = useContext(SnackbarContext) const [cookies, setCookie] = useCookies(['privacy_accept_token']) const privacyPolicyCookie = cookies['privacy_accept_token'] - const getUser = usePersistedStore((state) => state.getUser) //**Logic **// - //fetch the university from the current user and return university - const checkUniversity = async () => { - return getUser() - .then((user) => { - return user.university - }) - .catch((reason) => { - log.error(reason) - return '' - }) - } - const handleAccept = useCallback( (isAccepted: boolean) => { if (isAccepted) { @@ -78,9 +63,8 @@ export const usePrivacyModal = (): PrivacyModalHookReturn => { return useMemo( () => ({ privacyPolicyCookie, - handleAccept, - checkUniversity + handleAccept }), - [privacyPolicyCookie, handleAccept, checkUniversity] + [privacyPolicyCookie, handleAccept] ) } diff --git a/src/components/PrivacyModal/PrivacyModal.test.tsx b/src/components/PrivacyModal/PrivacyModal.test.tsx index ed559de84..ed98bdbb2 100644 --- a/src/components/PrivacyModal/PrivacyModal.test.tsx +++ b/src/components/PrivacyModal/PrivacyModal.test.tsx @@ -1,13 +1,16 @@ import '@testing-library/jest-dom' -import { fireEvent, render, renderHook } from '@testing-library/react' +import { fireEvent, render, act } from '@testing-library/react' import { mockServices } from 'jest.setup' import * as router from 'react-router' import { MemoryRouter } from 'react-router-dom' import PrivacyModal from './PrivacyModal' -import { usePrivacyModal } from './PrivacyModal.hooks' const navigate = jest.fn() +Object.defineProperty(window, 'location', { + value: { assign: jest.fn() } +}) + beforeEach(() => { jest.spyOn(router, 'useNavigate').mockImplementation(() => navigate) document.cookie = 'privacy_accept_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;' @@ -61,8 +64,10 @@ describe('Test PrivacyModal', () => { ) - const declineButton = form.getByRole('button', { name: /components.PrivacyModal.returnToMoodle/i }) - fireEvent.click(declineButton) + act(() => { + const declineButton = form.getByRole('button', { name: /components.PrivacyModal.returnToMoodle/i }) + fireEvent.click(declineButton) + }) }) test('Modal does not render if on privacypolicy page', () => { @@ -73,17 +78,9 @@ describe('Test PrivacyModal', () => { ) }) - test('Modal does not render if cookie is set', () => { - const form = render( - - - - ) - }) - //Tests for decline and redirect test('Redirects the user to TH-AB', async () => { - mockServices.fetchUser = jest.fn().mockImplementation(() => + mockServices.fetchUser.mockImplementation(() => Promise.resolve({ id: 1, lms_user_id: 1, @@ -99,42 +96,29 @@ describe('Test PrivacyModal', () => { university: 'TH-AB' }) ) - const { getByRole } = render( - + + const { getByRole, rerender } = render( + ) - const declineButton = getByRole('button', { name: /components.PrivacyModal.returnToMoodle/i }) - fireEvent.click(declineButton) - }) - - //Prior Test with an expect - test('checkUniversity returns valid value', async () => { - mockServices.fetchUser = jest.fn().mockImplementationOnce(() => - Promise.resolve({ - id: 1, - lms_user_id: 1, - name: 'Thaddäus Tentakel', - role: 'Tester', - role_id: 1, - settings: { - id: 1, - user_id: 1, - pswd: '1234', - theme: 'test' - }, - university: 'TH-AB' - }) + await new Promise(process.nextTick) + rerender( + + + ) - - const { result } = renderHook(() => usePrivacyModal(), { - wrapper: ({ children }) => {children} + act(() => { + const declineButton = getByRole('button', { name: /components.PrivacyModal.returnToMoodle/i }) + expect(declineButton).toBeInTheDocument() + fireEvent.click(declineButton) }) - expect(await result.current.checkUniversity()).toBe('TH-AB') + expect(window.location.assign).toHaveBeenCalled() + expect(window.location.assign).toHaveBeenCalledWith('https://moodle.th-ab.de/') }) test('Redirects the user to HS-KE', async () => { - mockServices.fetchUser = jest.fn().mockImplementation(() => + mockServices.fetchUser.mockImplementation(() => Promise.resolve({ id: 1, lms_user_id: 1, @@ -150,21 +134,25 @@ describe('Test PrivacyModal', () => { university: 'HS-KE' }) ) - const { getByRole } = render( + const { getByRole, rerender } = render( ) - const declineButton = getByRole('button', { name: /components.PrivacyModal.returnToMoodle/i }) - fireEvent.click(declineButton) - }) + await new Promise(process.nextTick) - test('checkUniversity returns empty string when fetch fails', async () => { - mockServices.fetchUser = jest.fn().mockImplementationOnce(() => Promise.reject(new Error('error'))) - const { result } = renderHook(() => usePrivacyModal(), { - wrapper: ({ children }) => {children} + rerender( + + + + ) + + act(() => { + const declineButton = getByRole('button', { name: /components.PrivacyModal.returnToMoodle/i }) + fireEvent.click(declineButton) }) - expect(await result.current.checkUniversity()).toBe('') + expect(window.location.assign).toHaveBeenCalled() + expect(window.location.assign).toHaveBeenCalledWith('https://moodle.hs-kempten.de/') }) test('decline returns the user two pages prior', async () => { @@ -189,7 +177,17 @@ describe('Test PrivacyModal', () => { ) - const declineButton = getByRole('button', { name: /components.PrivacyModal.returnToMoodle/i }) - fireEvent.click(declineButton) + act(() => { + const declineButton = getByRole('button', { name: /components.PrivacyModal.returnToMoodle/i }) + fireEvent.click(declineButton) + }) }) }) + +test('Modal does not render if cookie is set', () => { + const form = render( + + + + ) +}) diff --git a/src/components/PrivacyModal/PrivacyModal.tsx b/src/components/PrivacyModal/PrivacyModal.tsx index c6251df7d..fc46d2618 100644 --- a/src/components/PrivacyModal/PrivacyModal.tsx +++ b/src/components/PrivacyModal/PrivacyModal.tsx @@ -13,6 +13,7 @@ import { Typography } from '@common/components' import { PrivacyModalHookReturn, usePrivacyModal as _usePrivacyModal } from './PrivacyModal.hooks' +import { useUniversity } from '@common/hooks' const style = { position: 'absolute', @@ -54,7 +55,8 @@ const PrivacyModal = ({ usePrivacyModal = _usePrivacyModal }: PrivacyModalProps) const navigate = useNavigate() const [open, setOpen] = useState(true) const [checked, setChecked] = useState(false) - const { privacyPolicyCookie, handleAccept, checkUniversity } = usePrivacyModal() + const { privacyPolicyCookie, handleAccept } = usePrivacyModal() + const { university } = useUniversity() const currentLocation = useLocation() //Disable backdropClick so the Modal only closes via the buttons @@ -132,19 +134,18 @@ const PrivacyModal = ({ usePrivacyModal = _usePrivacyModal }: PrivacyModalProps) aria-multiline={'true'} onClick={() => { handleModal(false) - checkUniversity().then((university) => { - if (university == 'TH-AB') { - window.location.assign('https://moodle.th-ab.de/') - } else if (university == 'HS-KE') { - window.location.assign('https://moodle.hs-kempten.de/') + + if (university == 'TH-AB') { + window.location.assign('https://moodle.th-ab.de/') + } else if (university == 'HS-KE') { + window.location.assign('https://moodle.hs-kempten.de/') + } else { + if (currentLocation.pathname == '/') { + history.back() } else { - if (currentLocation.pathname == '/') { - history.back() - } else { - history.go(-2) - } + history.go(-2) } - }) + } }}> {t('appGlobal.decline')} diff --git a/src/components/index.ts b/src/components/index.ts index 85bac73b8..d3164e23b 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -49,6 +49,7 @@ export { default as LinearProgressWithLabel } from './StyledLinearProgress/Linea export { StyledLinearProgress } from './StyledLinearProgress/StyledLinearProgress' export { default as LabeledSwitch } from './LabeledSwitch/LabeledSwitch' export { default as ResponsiveMiniMap } from './ResponsiveMiniMap/ResponsiveMiniMap' +export { default as Newsbanner } from './Newsbanner/Newsbanner' export { default as TopicCard } from './TopicCard/TopicCard' export * from './GlobalNav' diff --git a/src/core/News/News.ts b/src/core/News/News.ts new file mode 100644 index 000000000..015b9706a --- /dev/null +++ b/src/core/News/News.ts @@ -0,0 +1,11 @@ +type NewsReturn = (languageId?: string, university?: string) => Promise + +type NewsResponse = { + news: News[] +} + +type News = { + news_content: string +} +export default News +export type { NewsResponse, NewsReturn } diff --git a/src/core/index.ts b/src/core/index.ts index 5c0bfdfe5..6b8d27652 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -30,6 +30,7 @@ import LearningPathLearningElement from './LearningPathLearningElement/LearningP import LearningPathTopic from './LearningPathTopic/LearningPathTopic' import ILS from './QuestionnaireResults/ILS' import ListK from './QuestionnaireResults/ListK' +import News, { NewsResponse } from './News/News' import StudentLearningElement from './StudentLearningElement/StudentLearningElement' import Topic from './Topic/Topic' import User from './User/User' @@ -46,6 +47,8 @@ export type { LearningPathElement, LearningPathLearningElement, LearningPathElementStatus, + News, + NewsResponse, Topic, StudentLearningElement, ILS, @@ -59,3 +62,4 @@ export type { ListKReturn } from './QuestionnaireResults/ListK' export type { CourseReturn } from './Course/Course' export type { ILSReturn } from './QuestionnaireResults/ILS' export * from './Rating' +export type { NewsReturn } from './News/News' diff --git a/src/pages/MainFrame/MainFrame.tsx b/src/pages/MainFrame/MainFrame.tsx index dc828eb13..aa2f8fd0f 100644 --- a/src/pages/MainFrame/MainFrame.tsx +++ b/src/pages/MainFrame/MainFrame.tsx @@ -1,7 +1,15 @@ import { Outlet, useParams } from 'react-router-dom' import { Box, Divider, Grid } from '@common/components' import { useMediaQuery, useTheme } from '@common/hooks' -import { BreadcrumbsContainer, Footer, LocalNavBar, MenuBar, OpenQuestionnaire, PrivacyModal } from '@components' +import { + BreadcrumbsContainer, + Footer, + LocalNavBar, + MenuBar, + OpenQuestionnaire, + PrivacyModal, + Newsbanner +} from '@components' /** * # MainFrame Page @@ -27,6 +35,7 @@ export const MainFrame = () => { <> + {isLocalNavOpen && ( diff --git a/src/services/News/fetchNews.test.tsx b/src/services/News/fetchNews.test.tsx new file mode 100644 index 000000000..9e4c73448 --- /dev/null +++ b/src/services/News/fetchNews.test.tsx @@ -0,0 +1,75 @@ +import { getConfig } from '@shared' +import { fetchNews } from './fetchNews' + +global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve({ status: 200 }), + ok: true, + headers: { + get: () => 'application/json' + } + }) +) as jest.Mock + +describe('Test the fetchNews functionalities', () => { + it('should return the news when the response is successful', async () => { + const expectedData = { news: 'News in english' } + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(expectedData) + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + fetch.mockResolvedValue(mockResponse) + + const languageId = 'en' + const university = 'TH-AB' + + const result = await fetchNews(languageId, university) + + expect(fetch).toHaveBeenCalledWith(`${getConfig().BACKEND}/news/language/${languageId}/university/${university}`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + } + }) + expect(result).toEqual(expectedData) + }) + + it('should throw a specific error when the response has an error variable', async () => { + const expectedError = 'Error: HTTP error undefined' + const expectedMessage = 'Error: HTTP error undefined' + + const mockResponse = { + ok: false, + json: jest.fn().mockResolvedValue({ error: expectedError, message: expectedMessage }) + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + fetch.mockResolvedValue(mockResponse) + + const languageId = 'en' + const university = 'TH-AB' + + await expect(fetchNews(languageId, university)).rejects.toThrow(`${expectedMessage}`) + }) + + it('should throw an unknown error when the response does not have an error variable', async () => { + const mockResponse = { + ok: false, + json: jest.fn().mockResolvedValue({}) + } + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + fetch.mockResolvedValue(mockResponse) + + const languageId = 'en' + const university = 'TH-AB' + + await expect(fetchNews(languageId, university)).rejects.toThrow('') + }) +}) diff --git a/src/services/News/fetchNews.tsx b/src/services/News/fetchNews.tsx new file mode 100644 index 000000000..78393ade1 --- /dev/null +++ b/src/services/News/fetchNews.tsx @@ -0,0 +1,17 @@ +import { getConfig } from '@shared' +import { fetchData } from '../RequestResponse' +import { NewsResponse } from '@core' + +/** + * + * @returns {@link News} object + */ +export const fetchNews = async (languageId?: string, university?: string): Promise => { + return fetchData(getConfig().BACKEND + `/news/language/${languageId}/university/${university}`, { + method: 'GET', + credentials: 'include', + headers: { + 'Content-Type': 'application/json' + } + }) +} diff --git a/src/services/News/index.ts b/src/services/News/index.ts new file mode 100644 index 000000000..f9de3cc49 --- /dev/null +++ b/src/services/News/index.ts @@ -0,0 +1 @@ +export * from './fetchNews' diff --git a/src/services/index.ts b/src/services/index.ts index bf0f2b090..186ebc03d 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -20,5 +20,6 @@ export * from './xAPI' export { fetchILS, fetchListK, postILS, postListK } from './Questionnaire' export * from './debounce' export * from './Viewport' +export { fetchNews } from './News' export { postCalculateLearningPathILS } from './LearningPath' export * from './Rating' diff --git a/src/shared/translation/translationEnglish.json b/src/shared/translation/translationEnglish.json index d73428594..30fa75fb4 100644 --- a/src/shared/translation/translationEnglish.json +++ b/src/shared/translation/translationEnglish.json @@ -1187,6 +1187,7 @@ "error.getCourses": "There was an Error fetching the courses data.", "error.getLearningElementStatus": "There was an Error Learning Element Status.", "error.getLearningElementsWithStatus": "There was an Error fetching Learning Elements with Status.", + "error.getNews": "There was an Error fetching the news data.", "error.getSortedLearningPath": "There was an Error fetching and sorting the LearningPathLearningElement data.", "error.getUser": "There was an Error fetching the user data.", "error.mapNodes": "There was an Error mapping the learning path Nodes.", @@ -1197,6 +1198,7 @@ "error.setLearningPathElementSpecificStatus": "There was an Error setting Learning Path Element Specific Status.", "error.setTopics": "There was an Error setting Topics.", "error.setTopicsPath": "There was an Error setting Topics Path.", + "error.useUniversity": "There was an Error getting the University of the User.", "info": "Info", "info.postCalculateLearningPathILS": "A POST request was successfully sent to calculate the learning path based on the ILS questionnaire data.", "log.learningPathTopicProgressTimeout": "A timeout occured while loading the LearningPathTopicProgress hook.", diff --git a/src/shared/translation/translationGerman.json b/src/shared/translation/translationGerman.json index e5482782c..5c9756aa5 100644 --- a/src/shared/translation/translationGerman.json +++ b/src/shared/translation/translationGerman.json @@ -1187,6 +1187,7 @@ "error.getCourses": "Es gab einen Fehler beim Abrufen der Kursdaten.", "error.getLearningElementStatus": "Es gab einen Fehler beim Abrufen des Lernelementstatus.", "error.getLearningElementsWithStatus": "Es gab einen Fehler beim Abrufen der Lernelemente (mit Status).", + "error.getNews": "Es gab einen Fehler beim Abrufen der News.", "error.getSortedLearningPath": "Es gab einen Fehler beim Abrufen und Sortieren der LearningPathLearningElement-Daten.", "error.getUser": "Es gab einen Fehler beim Abrufen der Benutzerdaten.", "error.mapNodes": "Es gab einen Fehler beim Zuordnen der Lernpfad Nodes.", @@ -1197,6 +1198,7 @@ "error.setLearningPathElementSpecificStatus": "Es gab einen Fehler beim Festlegen des spezifischen Status eines Lernpfadelements.", "error.setTopics": "Es gab einen Fehler beim Festlegen der Themen.", "error.setTopicsPath": "Es gab einen Fehler beim Festlegen des Themenpfads.", + "error.useUniversity": "Es gab einen Fehler beim Abrufen der Universität von dem Benutzer.", "info": "Info", "info.postCalculateLearningPathILS": "Eine POST-Anfrage wurde erfolgreich gesendet, um den Lernpfad auf der Grundlage der Daten des ILS-Fragebogens zu berechnen.", "log.learningPathTopicProgressTimeout": "Beim Laden des LearningPathTopicProgress-Hook ist ein Timout aufgetreten.", diff --git a/src/store/Slices/NewsSlice.test.ts b/src/store/Slices/NewsSlice.test.ts new file mode 100644 index 000000000..0781b5e51 --- /dev/null +++ b/src/store/Slices/NewsSlice.test.ts @@ -0,0 +1,74 @@ +import '@testing-library/jest-dom' +import { mockServices } from 'jest.setup' +import { useSessionStore } from '@store' + +describe('NewsSlice', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('should fetch news and cache them', async () => { + const { getNews } = useSessionStore.getState() + const news = { + news: [ + { + date: 'Thu, 13 Jul 2023 16:00:00 GMT', + expiration_date: 'Sun, 20 Apr 2025 16:00:00 GMT', + id: 1, + language_id: 'en', + news_content: 'We are currently testing the site', + university: 'TH-AB' + }, + { + date: 'Thu, 13 Jul 2023 16:00:00 GMT', + expiration_date: 'Sun, 20 Apr 2025 16:00:00 GMT', + id: 2, + language_id: 'en', + news_content: 'We are currently testing the site', + university: 'TH-AB' + } + ] + } + + const languageId = 'en' + const university = 'TH-AB' + + const result = await getNews(languageId, university) + + expect(result).toEqual(news) + expect(getNews).toBeDefined() + expect(getNews).toBeInstanceOf(Function) + expect(mockServices.fetchNews).toHaveBeenCalledTimes(1) + expect(mockServices.fetchNews).toHaveBeenCalledWith('en', 'TH-AB') + expect(useSessionStore.getState()._news[`${languageId}-${university}`]).toEqual(news) + expect(getNews).not.toThrow() // counts as function call (getCourses), here it would be Called 2 times instead of 1 + }) + + it('should return cached news if available', async () => { + const { getNews } = useSessionStore.getState() + const news = [ + { + date: 'Thu, 13 Jul 2023 16:00:00 GMT', + expiration_date: 'Sun, 20 Apr 2025 16:00:00 GMT', + id: 1, + language_id: 'de', + news_content: 'We are currently testing the site', + university: null + } + ] + mockServices.fetchNews = jest.fn().mockResolvedValueOnce(news) + + const languageId = 'en' + const university = 'TH-AB' + + await getNews(languageId, university) + + expect(useSessionStore.getState()._news[`${languageId}-${university}`]).toEqual(news) + + const cached = await getNews('en', 'TH-AB') + + expect(mockServices.fetchNews).toHaveBeenCalledTimes(1) + + expect(cached).toEqual(news) + }) +}) diff --git a/src/store/Slices/NewsSlice.ts b/src/store/Slices/NewsSlice.ts new file mode 100644 index 000000000..6e1d06c74 --- /dev/null +++ b/src/store/Slices/NewsSlice.ts @@ -0,0 +1,39 @@ +import { StateCreator } from 'zustand' +import { NewsResponse, NewsReturn } from '@core' +import { fetchNews } from '@services' +import { SessionStoreState } from '@store' +import { resetters } from '../Zustand/Store' + +export default interface NewsSlice { + _news: Record + getNews: NewsReturn + isBannerOpen: boolean + setIsBannerOpen: (open?: boolean) => void +} + +export const createNewsSlice: StateCreator = (set, get) => { + resetters.push(() => set({ _news: {}, isBannerOpen: true })) + return { + _news: {}, + isBannerOpen: true, + setIsBannerOpen: (open?: boolean) => { + set({ isBannerOpen: open }) + }, + getNews: async (...arg) => { + const [languageId, university] = arg + + const cached = get()._news[`${languageId}-${university}`] + + if (!cached) { + const news = await fetchNews(languageId, university) + set({ + _news: { + ...get()._news, + [`${languageId}-${university}`]: news + } + }) + return news + } else return cached + } + } +} diff --git a/src/store/Zustand/Store.ts b/src/store/Zustand/Store.ts index a7ec19277..f8a6a652c 100644 --- a/src/store/Zustand/Store.ts +++ b/src/store/Zustand/Store.ts @@ -13,6 +13,7 @@ import LearningPathElementStatusSlice, { } from '../Slices/LearningPathElementStatusSlice' import LearningPathTopicSlice, { createLearningPathTopicSlice } from '../Slices/LearningPathTopicSlice' import UserSlice, { createUserSlice } from '../Slices/UserSlice' +import NewsSlice, { createNewsSlice } from '../Slices/NewsSlice' import xAPISlice, { createXAPISlice } from '../Slices/xAPISlice' export type StoreState = LearningPathElementSlice & @@ -21,6 +22,7 @@ export type StoreState = LearningPathElementSlice & LearningPathTopicSlice & LearningPathElementSpecificStatusSlice export type PersistedStoreState = UserSlice & AuthSlice & LearningPathElementStatusSlice & xAPISlice +export type SessionStoreState = NewsSlice export const resetters: (() => void)[] = [] @@ -57,4 +59,23 @@ export const usePersistedStore = create()( ) ) ) +export const useSessionStore = create()( + devtools( + persist( + (...a) => ({ + ...createNewsSlice(...a) + }), + { + name: 'session_storage', + // Here we can whitelist the keys we want to persist + getStorage: () => sessionStorage, + partialize: (state) => ({ + isBannerOpen: state.isBannerOpen + }), + + version: 1.1 // When this changes, the persisted data will be discarded and the store reinitialized (Useful for migrations) + } + ) + ) +) export const resetAllSlices = () => resetters.forEach((reset) => reset()) diff --git a/src/store/index.ts b/src/store/index.ts index 590b2775e..8669229e4 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -1,2 +1,2 @@ -export { resetAllSlices, resetters, usePersistedStore, useStore } from './Zustand/Store' -export type { PersistedStoreState, StoreState } from './Zustand/Store' +export { resetAllSlices, resetters, usePersistedStore, useStore, useSessionStore } from './Zustand/Store' +export type { PersistedStoreState, StoreState, SessionStoreState } from './Zustand/Store'