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'