mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-21 17:26:29 -05:00
feat(motd): read motd in RSC and provide via context
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
83c7f81a76
commit
a0bc8e98d0
11 changed files with 190 additions and 125 deletions
|
@ -5,29 +5,18 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
|
const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
|
||||||
const MOCK_LAST_MODIFIED = 'mockETag'
|
const motdMockHtml = 'This is the test motd text'
|
||||||
const motdMockContent = 'This is the **mock** Motd call'
|
|
||||||
const motdMockHtml = 'This is the <strong>mock</strong> Motd call'
|
|
||||||
|
|
||||||
describe('Motd', () => {
|
describe('Motd', () => {
|
||||||
it("shows, dismisses and won't show again a motd modal", () => {
|
it("shows, dismisses and won't show again a motd modal", () => {
|
||||||
localStorage.removeItem(MOTD_LOCAL_STORAGE_KEY)
|
localStorage.removeItem(MOTD_LOCAL_STORAGE_KEY)
|
||||||
cy.intercept('GET', '/public/motd.md', {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: { 'Last-Modified': MOCK_LAST_MODIFIED },
|
|
||||||
body: motdMockContent
|
|
||||||
})
|
|
||||||
|
|
||||||
cy.intercept('HEAD', '/public/motd.md', {
|
|
||||||
statusCode: 200,
|
|
||||||
headers: { 'Last-Modified': MOCK_LAST_MODIFIED }
|
|
||||||
})
|
|
||||||
cy.visitHistory()
|
cy.visitHistory()
|
||||||
cy.getSimpleRendererBody().should('contain.html', motdMockHtml)
|
cy.getSimpleRendererBody().should('contain.text', motdMockHtml)
|
||||||
cy.getByCypressId('motd-dismiss')
|
cy.getByCypressId('motd-dismiss')
|
||||||
.click()
|
.click()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).to.equal(MOCK_LAST_MODIFIED)
|
expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).not.to.be.eq(null)
|
||||||
})
|
})
|
||||||
cy.getByCypressId('motd-modal').should('not.exist')
|
cy.getByCypressId('motd-modal').should('not.exist')
|
||||||
cy.reload()
|
cy.reload()
|
||||||
|
|
|
@ -506,7 +506,8 @@
|
||||||
},
|
},
|
||||||
"instance": {
|
"instance": {
|
||||||
"header": "About this instance",
|
"header": "About this instance",
|
||||||
"versionInfo": "Running version"
|
"versionInfo": "Running version",
|
||||||
|
"motdModal": "Show instance announcement"
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"header": "Legal",
|
"header": "Legal",
|
||||||
|
@ -596,7 +597,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"motd": {
|
"motd": {
|
||||||
"title": "Information"
|
"title": "Announcement"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"notFound": {
|
"notFound": {
|
||||||
|
|
|
@ -7,7 +7,6 @@ import '../../../global-styles/index.scss'
|
||||||
import { ApplicationLoader } from '../../components/application-loader/application-loader'
|
import { ApplicationLoader } from '../../components/application-loader/application-loader'
|
||||||
import { BaseUrlContextProvider } from '../../components/common/base-url/base-url-context-provider'
|
import { BaseUrlContextProvider } from '../../components/common/base-url/base-url-context-provider'
|
||||||
import { FrontendConfigContextProvider } from '../../components/common/frontend-config-context/frontend-config-context-provider'
|
import { FrontendConfigContextProvider } from '../../components/common/frontend-config-context/frontend-config-context-provider'
|
||||||
import { MotdModal } from '../../components/global-dialogs/motd-modal/motd-modal'
|
|
||||||
import { DarkMode } from '../../components/layout/dark-mode/dark-mode'
|
import { DarkMode } from '../../components/layout/dark-mode/dark-mode'
|
||||||
import { ExpectedOriginBoundary } from '../../components/layout/expected-origin-boundary'
|
import { ExpectedOriginBoundary } from '../../components/layout/expected-origin-boundary'
|
||||||
import { UiNotificationBoundary } from '../../components/notifications/ui-notification-boundary'
|
import { UiNotificationBoundary } from '../../components/notifications/ui-notification-boundary'
|
||||||
|
@ -18,6 +17,9 @@ import type { Metadata } from 'next'
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { getConfig } from '../../api/config'
|
import { getConfig } from '../../api/config'
|
||||||
|
import { MotdProvider } from '../../components/motd/motd-context'
|
||||||
|
import { fetchMotd } from '../../components/global-dialogs/motd-modal/fetch-motd'
|
||||||
|
import { CachedMotdModal } from '../../components/global-dialogs/motd-modal/cached-motd-modal'
|
||||||
|
|
||||||
configureLuxon()
|
configureLuxon()
|
||||||
|
|
||||||
|
@ -28,6 +30,7 @@ interface RootLayoutProps extends PropsWithChildren {
|
||||||
export default async function RootLayout({ children, appBar }: RootLayoutProps) {
|
export default async function RootLayout({ children, appBar }: RootLayoutProps) {
|
||||||
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls()
|
const baseUrls = baseUrlFromEnvExtractor.extractBaseUrls()
|
||||||
const frontendConfig = await getConfig(baseUrls.editor)
|
const frontendConfig = await getConfig(baseUrls.editor)
|
||||||
|
const motd = await fetchMotd(baseUrls.internalApiUrl ?? baseUrls.editor)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang='en'>
|
<html lang='en'>
|
||||||
|
@ -37,20 +40,22 @@ export default async function RootLayout({ children, appBar }: RootLayoutProps)
|
||||||
<body>
|
<body>
|
||||||
<ExpectedOriginBoundary expectedOrigin={baseUrls.editor}>
|
<ExpectedOriginBoundary expectedOrigin={baseUrls.editor}>
|
||||||
<BaseUrlContextProvider baseUrls={baseUrls}>
|
<BaseUrlContextProvider baseUrls={baseUrls}>
|
||||||
<FrontendConfigContextProvider config={frontendConfig}>
|
<MotdProvider motd={motd}>
|
||||||
<StoreProvider>
|
<FrontendConfigContextProvider config={frontendConfig}>
|
||||||
<ApplicationLoader>
|
<StoreProvider>
|
||||||
<DarkMode />
|
<ApplicationLoader>
|
||||||
<MotdModal />
|
<DarkMode />
|
||||||
<UiNotificationBoundary>
|
<CachedMotdModal />
|
||||||
<div className={'d-flex flex-column vh-100'}>
|
<UiNotificationBoundary>
|
||||||
{appBar}
|
<div className={'d-flex flex-column vh-100'}>
|
||||||
{children}
|
{appBar}
|
||||||
</div>
|
{children}
|
||||||
</UiNotificationBoundary>
|
</div>
|
||||||
</ApplicationLoader>
|
</UiNotificationBoundary>
|
||||||
</StoreProvider>
|
</ApplicationLoader>
|
||||||
</FrontendConfigContextProvider>
|
</StoreProvider>
|
||||||
|
</FrontendConfigContextProvider>
|
||||||
|
</MotdProvider>
|
||||||
</BaseUrlContextProvider>
|
</BaseUrlContextProvider>
|
||||||
</ExpectedOriginBoundary>
|
</ExpectedOriginBoundary>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
'use client'
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { useMotdContextValue } from '../../motd/motd-context'
|
||||||
|
import { useLocalStorage } from 'react-use'
|
||||||
|
import { MOTD_LOCAL_STORAGE_KEY } from './fetch-motd'
|
||||||
|
import { MotdModal } from './motd-modal'
|
||||||
|
import { testId } from '../../../utils/test-id'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the motd from the context and shows it in a modal.
|
||||||
|
* If the modal gets dismissed by the user then the "last modified" identifier will be written into the local storage
|
||||||
|
* to prevent that the motd will be shown again until it gets changed.
|
||||||
|
*/
|
||||||
|
export const CachedMotdModal: React.FC = () => {
|
||||||
|
const contextValue = useMotdContextValue()
|
||||||
|
const [cachedLastModified, saveLocalStorage] = useLocalStorage<string>(MOTD_LOCAL_STORAGE_KEY, undefined)
|
||||||
|
|
||||||
|
const [dismissed, setDismissed] = useState(false)
|
||||||
|
|
||||||
|
const show = useMemo(() => {
|
||||||
|
const lastModified = contextValue?.lastModified
|
||||||
|
return cachedLastModified !== lastModified && lastModified !== undefined && !dismissed
|
||||||
|
}, [cachedLastModified, contextValue?.lastModified, dismissed])
|
||||||
|
|
||||||
|
const doDismiss = useCallback(() => {
|
||||||
|
const lastModified = contextValue?.lastModified
|
||||||
|
if (lastModified) {
|
||||||
|
saveLocalStorage(lastModified)
|
||||||
|
}
|
||||||
|
setDismissed(true)
|
||||||
|
}, [contextValue, saveLocalStorage])
|
||||||
|
|
||||||
|
if (contextValue?.lastModified === undefined && process.env.NODE_ENV === 'test') {
|
||||||
|
return <span {...testId('loaded not visible')} />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <MotdModal show={show} onDismiss={doDismiss}></MotdModal>
|
||||||
|
}
|
|
@ -7,7 +7,8 @@ import { fetchMotd } from './fetch-motd'
|
||||||
import { Mock } from 'ts-mockery'
|
import { Mock } from 'ts-mockery'
|
||||||
|
|
||||||
describe('fetch motd', () => {
|
describe('fetch motd', () => {
|
||||||
const motdUrl = '/public/motd.md'
|
const baseUrl = 'https://example.org/'
|
||||||
|
const motdUrl = `${baseUrl}public/motd.md`
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.localStorage.clear()
|
window.localStorage.clear()
|
||||||
|
@ -47,7 +48,7 @@ describe('fetch motd', () => {
|
||||||
jest.spyOn(global, 'fetch').mockImplementation(() =>
|
jest.spyOn(global, 'fetch').mockImplementation(() =>
|
||||||
Promise.resolve(
|
Promise.resolve(
|
||||||
Mock.of<Response>({
|
Mock.of<Response>({
|
||||||
status: 500
|
status: 404
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -56,7 +57,7 @@ describe('fetch motd', () => {
|
||||||
describe('date detection', () => {
|
describe('date detection', () => {
|
||||||
it('will return the last-modified value if available', async () => {
|
it('will return the last-modified value if available', async () => {
|
||||||
mockFetch('mocked motd', 'yesterday-modified', null)
|
mockFetch('mocked motd', 'yesterday-modified', null)
|
||||||
const result = fetchMotd()
|
const result = fetchMotd(baseUrl)
|
||||||
await expect(result).resolves.toStrictEqual({
|
await expect(result).resolves.toStrictEqual({
|
||||||
motdText: 'mocked motd',
|
motdText: 'mocked motd',
|
||||||
lastModified: 'yesterday-modified'
|
lastModified: 'yesterday-modified'
|
||||||
|
@ -64,7 +65,7 @@ describe('fetch motd', () => {
|
||||||
})
|
})
|
||||||
it('will return the etag if last-modified is not returned', async () => {
|
it('will return the etag if last-modified is not returned', async () => {
|
||||||
mockFetch('mocked motd', null, 'yesterday-etag')
|
mockFetch('mocked motd', null, 'yesterday-etag')
|
||||||
const result = fetchMotd()
|
const result = fetchMotd(baseUrl)
|
||||||
await expect(result).resolves.toStrictEqual({
|
await expect(result).resolves.toStrictEqual({
|
||||||
motdText: 'mocked motd',
|
motdText: 'mocked motd',
|
||||||
lastModified: 'yesterday-etag'
|
lastModified: 'yesterday-etag'
|
||||||
|
@ -72,7 +73,7 @@ describe('fetch motd', () => {
|
||||||
})
|
})
|
||||||
it('will prefer the last-modified header over the etag', async () => {
|
it('will prefer the last-modified header over the etag', async () => {
|
||||||
mockFetch('mocked motd', 'yesterday-last', 'yesterday-etag')
|
mockFetch('mocked motd', 'yesterday-last', 'yesterday-etag')
|
||||||
const result = fetchMotd()
|
const result = fetchMotd(baseUrl)
|
||||||
await expect(result).resolves.toStrictEqual({
|
await expect(result).resolves.toStrictEqual({
|
||||||
motdText: 'mocked motd',
|
motdText: 'mocked motd',
|
||||||
lastModified: 'yesterday-last'
|
lastModified: 'yesterday-last'
|
||||||
|
@ -80,34 +81,24 @@ describe('fetch motd', () => {
|
||||||
})
|
})
|
||||||
it('will return an empty value if neither the last-modified value nor the etag is returned', async () => {
|
it('will return an empty value if neither the last-modified value nor the etag is returned', async () => {
|
||||||
mockFetch('mocked motd', null, null)
|
mockFetch('mocked motd', null, null)
|
||||||
const result = fetchMotd()
|
const result = fetchMotd(baseUrl)
|
||||||
await expect(result).resolves.toStrictEqual({
|
await expect(result).resolves.toBe(undefined)
|
||||||
motdText: 'mocked motd',
|
|
||||||
lastModified: null
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('can fetch a motd if no last modified value has been memorized', async () => {
|
it('can fetch a motd if no last modified value has been memorized', async () => {
|
||||||
mockFetch('mocked motd', 'yesterday')
|
mockFetch('mocked motd', 'yesterday')
|
||||||
const result = fetchMotd()
|
const result = fetchMotd(baseUrl)
|
||||||
await expect(result).resolves.toStrictEqual({
|
await expect(result).resolves.toStrictEqual({
|
||||||
motdText: 'mocked motd',
|
motdText: 'mocked motd',
|
||||||
lastModified: 'yesterday'
|
lastModified: 'yesterday'
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it("can detect that the motd hasn't been updated", async () => {
|
|
||||||
mockFetch('mocked motd', 'yesterday')
|
|
||||||
window.localStorage.setItem('motd.lastModified', 'yesterday')
|
|
||||||
const result = fetchMotd()
|
|
||||||
await expect(result).resolves.toStrictEqual(undefined)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can detect that the motd has been updated', async () => {
|
it('can detect that the motd has been updated', async () => {
|
||||||
mockFetch('mocked motd', 'yesterday')
|
mockFetch('mocked motd', 'yesterday')
|
||||||
window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
|
window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
|
||||||
const result = fetchMotd()
|
const result = fetchMotd(baseUrl)
|
||||||
await expect(result).resolves.toStrictEqual({
|
await expect(result).resolves.toStrictEqual({
|
||||||
motdText: 'mocked motd',
|
motdText: 'mocked motd',
|
||||||
lastModified: 'yesterday'
|
lastModified: 'yesterday'
|
||||||
|
@ -116,14 +107,14 @@ describe('fetch motd', () => {
|
||||||
|
|
||||||
it("won't fetch a motd if no file was found", async () => {
|
it("won't fetch a motd if no file was found", async () => {
|
||||||
mockFileNotFoundFetch()
|
mockFileNotFoundFetch()
|
||||||
const result = fetchMotd()
|
const result = fetchMotd(baseUrl)
|
||||||
await expect(result).resolves.toStrictEqual(undefined)
|
await expect(result).resolves.toStrictEqual(undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("won't fetch a motd update if no file was found", async () => {
|
it("won't fetch a motd update if no file was found", async () => {
|
||||||
mockFileNotFoundFetch()
|
mockFileNotFoundFetch()
|
||||||
window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
|
window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
|
||||||
const result = fetchMotd()
|
const result = fetchMotd(baseUrl)
|
||||||
await expect(result).resolves.toStrictEqual(undefined)
|
await expect(result).resolves.toStrictEqual(undefined)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -4,14 +4,12 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { defaultConfig } from '../../../api/common/default-config'
|
import { defaultConfig } from '../../../api/common/default-config'
|
||||||
import { Logger } from '../../../utils/logger'
|
|
||||||
|
|
||||||
export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
|
export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
|
||||||
const log = new Logger('Motd')
|
|
||||||
|
|
||||||
export interface MotdApiResponse {
|
export interface MotdApiResponse {
|
||||||
motdText: string
|
motdText: string
|
||||||
lastModified: string | null
|
lastModified: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -21,36 +19,23 @@ export interface MotdApiResponse {
|
||||||
* will be compared to the saved value from the browser's local storage.
|
* will be compared to the saved value from the browser's local storage.
|
||||||
* @return A promise that gets resolved if the motd was fetched successfully.
|
* @return A promise that gets resolved if the motd was fetched successfully.
|
||||||
*/
|
*/
|
||||||
export const fetchMotd = async (): Promise<MotdApiResponse | undefined> => {
|
export const fetchMotd = async (baseUrl: string): Promise<MotdApiResponse | undefined> => {
|
||||||
const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)
|
const motdUrl = `${baseUrl}public/motd.md`
|
||||||
const motdUrl = `/public/motd.md`
|
|
||||||
|
|
||||||
if (cachedLastModified) {
|
|
||||||
const response = await fetch(motdUrl, {
|
|
||||||
...defaultConfig,
|
|
||||||
method: 'HEAD'
|
|
||||||
})
|
|
||||||
if (response.status !== 200) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
|
|
||||||
if (lastModified === cachedLastModified) {
|
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(motdUrl, {
|
const response = await fetch(motdUrl, {
|
||||||
...defaultConfig
|
...defaultConfig
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
return undefined
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
|
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
|
||||||
if (!lastModified) {
|
if (lastModified === null) {
|
||||||
log.warn("'Last-Modified' or 'Etag' not found for motd.md!")
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return { motdText: await response.text(), lastModified }
|
return {
|
||||||
|
lastModified,
|
||||||
|
motdText: await response.text()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,13 +9,12 @@ import { testId } from '../../../utils/test-id'
|
||||||
import type { CommonModalProps } from '../../common/modals/common-modal'
|
import type { CommonModalProps } from '../../common/modals/common-modal'
|
||||||
import * as CommonModalModule from '../../common/modals/common-modal'
|
import * as CommonModalModule from '../../common/modals/common-modal'
|
||||||
import * as RendererIframeModule from '../../common/renderer-iframe/renderer-iframe'
|
import * as RendererIframeModule from '../../common/renderer-iframe/renderer-iframe'
|
||||||
import * as fetchMotdModule from './fetch-motd'
|
|
||||||
import { MotdModal } from './motd-modal'
|
|
||||||
import { act, render, screen } from '@testing-library/react'
|
import { act, render, screen } from '@testing-library/react'
|
||||||
import type { PropsWithChildren } from 'react'
|
import type { PropsWithChildren } from 'react'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { CachedMotdModal } from './cached-motd-modal'
|
||||||
|
import { MotdProvider } from '../../motd/motd-context'
|
||||||
|
|
||||||
jest.mock('./fetch-motd')
|
|
||||||
jest.mock('../../common/modals/common-modal')
|
jest.mock('../../common/modals/common-modal')
|
||||||
jest.mock('../../common/renderer-iframe/renderer-iframe')
|
jest.mock('../../common/renderer-iframe/renderer-iframe')
|
||||||
jest.mock('../../../hooks/common/use-base-url')
|
jest.mock('../../../hooks/common/use-base-url')
|
||||||
|
@ -49,13 +48,15 @@ describe('motd modal', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it('renders a modal if a motd was fetched and can dismiss it', async () => {
|
it('renders a modal if a motd was fetched and can dismiss it', async () => {
|
||||||
jest.spyOn(fetchMotdModule, 'fetchMotd').mockImplementation(() => {
|
const motd = {
|
||||||
return Promise.resolve({
|
motdText: 'very important mock text!',
|
||||||
motdText: 'very important mock text!',
|
lastModified: 'yesterday'
|
||||||
lastModified: 'yesterday'
|
}
|
||||||
})
|
const view = render(
|
||||||
})
|
<MotdProvider motd={motd}>
|
||||||
const view = render(<MotdModal></MotdModal>)
|
<CachedMotdModal></CachedMotdModal>
|
||||||
|
</MotdProvider>
|
||||||
|
)
|
||||||
await screen.findByTestId('motd-renderer')
|
await screen.findByTestId('motd-renderer')
|
||||||
expect(view.container).toMatchSnapshot()
|
expect(view.container).toMatchSnapshot()
|
||||||
|
|
||||||
|
@ -67,10 +68,11 @@ describe('motd modal', () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("doesn't render a modal if no motd has been fetched", async () => {
|
it("doesn't render a modal if no motd has been fetched", async () => {
|
||||||
jest.spyOn(fetchMotdModule, 'fetchMotd').mockImplementation(() => {
|
const view = render(
|
||||||
return Promise.resolve(undefined)
|
<MotdProvider motd={undefined}>
|
||||||
})
|
<CachedMotdModal></CachedMotdModal>
|
||||||
const view = render(<MotdModal></MotdModal>)
|
</MotdProvider>
|
||||||
|
)
|
||||||
await screen.findByTestId('loaded not visible')
|
await screen.findByTestId('loaded not visible')
|
||||||
expect(view.container).toMatchSnapshot()
|
expect(view.container).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
|
|
|
@ -5,55 +5,41 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
import { cypressId } from '../../../utils/cypress-attribute'
|
import { cypressId } from '../../../utils/cypress-attribute'
|
||||||
import { Logger } from '../../../utils/logger'
|
|
||||||
import { testId } from '../../../utils/test-id'
|
import { testId } from '../../../utils/test-id'
|
||||||
import { CommonModal } from '../../common/modals/common-modal'
|
import { CommonModal } from '../../common/modals/common-modal'
|
||||||
import { RendererIframe } from '../../common/renderer-iframe/renderer-iframe'
|
import { RendererIframe } from '../../common/renderer-iframe/renderer-iframe'
|
||||||
import { EditorToRendererCommunicatorContextProvider } from '../../editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
import { EditorToRendererCommunicatorContextProvider } from '../../editor-page/render-context/editor-to-renderer-communicator-context-provider'
|
||||||
import { RendererType } from '../../render-page/window-post-message-communicator/rendering-message'
|
import { RendererType } from '../../render-page/window-post-message-communicator/rendering-message'
|
||||||
import { fetchMotd, MOTD_LOCAL_STORAGE_KEY } from './fetch-motd'
|
import React, { useMemo } from 'react'
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
||||||
import { Button, Modal } from 'react-bootstrap'
|
import { Button, Modal } from 'react-bootstrap'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { useAsync } from 'react-use'
|
import { useMotdContextValue } from '../../motd/motd-context'
|
||||||
|
|
||||||
const logger = new Logger('Motd')
|
export interface MotdModalProps {
|
||||||
|
show: boolean
|
||||||
|
onDismiss?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the motd from the global application state and shows it in a modal.
|
* Shows the MotD that is provided via context.
|
||||||
* If the modal gets dismissed by the user then the "last modified" identifier will be written into the local storage
|
*
|
||||||
* to prevent that the motd will be shown again until it gets changed.
|
* @param show defines if the modal should be shown
|
||||||
|
* @param onDismiss callback that is executed if the modal is dismissed
|
||||||
*/
|
*/
|
||||||
export const MotdModal: React.FC = () => {
|
export const MotdModal: React.FC<MotdModalProps> = ({ show, onDismiss }) => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
|
const contextValue = useMotdContextValue()
|
||||||
|
|
||||||
const { error, loading, value } = useAsync(fetchMotd)
|
const lines = useMemo(() => {
|
||||||
const [dismissed, setDismissed] = useState(false)
|
const rawLines = contextValue?.motdText.split('\n')
|
||||||
|
if (rawLines === undefined || rawLines.length === 0 || !show) {
|
||||||
const lines = useMemo(() => value?.motdText.split('\n') ?? [], [value?.motdText])
|
return []
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
|
||||||
if (value?.lastModified) {
|
|
||||||
window.localStorage.setItem(MOTD_LOCAL_STORAGE_KEY, value.lastModified)
|
|
||||||
}
|
}
|
||||||
setDismissed(true)
|
return rawLines
|
||||||
}, [value])
|
}, [contextValue?.motdText, show])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (error) {
|
|
||||||
logger.error('Error while fetching motd', error)
|
|
||||||
}
|
|
||||||
}, [error])
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'test' && !loading && !value) {
|
|
||||||
return <span {...testId('loaded not visible')}></span>
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommonModal
|
<CommonModal show={lines.length > 0} titleI18nKey={'motd.title'} onHide={onDismiss} {...cypressId('motd-modal')}>
|
||||||
show={lines.length > 0 && !loading && !error && !dismissed}
|
|
||||||
titleI18nKey={'motd.title'}
|
|
||||||
{...cypressId('motd-modal')}>
|
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
<EditorToRendererCommunicatorContextProvider>
|
<EditorToRendererCommunicatorContextProvider>
|
||||||
<RendererIframe
|
<RendererIframe
|
||||||
|
@ -66,7 +52,7 @@ export const MotdModal: React.FC = () => {
|
||||||
</EditorToRendererCommunicatorContextProvider>
|
</EditorToRendererCommunicatorContextProvider>
|
||||||
</Modal.Body>
|
</Modal.Body>
|
||||||
<Modal.Footer>
|
<Modal.Footer>
|
||||||
<Button variant={'success'} onClick={dismiss} {...testId('motd-dismiss')} {...cypressId('motd-dismiss')}>
|
<Button variant={'success'} onClick={onDismiss} {...testId('motd-dismiss')} {...cypressId('motd-dismiss')}>
|
||||||
<Trans i18nKey={'common.dismiss'} />
|
<Trans i18nKey={'common.dismiss'} />
|
||||||
</Button>
|
</Button>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
import { DropdownHeader } from '../dropdown-header'
|
import { DropdownHeader } from '../dropdown-header'
|
||||||
import { VersionInfoHelpMenuEntry } from './instance/version-info-help-menu-entry'
|
import { VersionInfoHelpMenuEntry } from './instance/version-info-help-menu-entry'
|
||||||
import React, { Fragment } from 'react'
|
import React, { Fragment } from 'react'
|
||||||
|
import { MotdModalHelpMenuEntry } from './instance/motd-modal-help-menu-entry'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the instance submenu for the help dropdown.
|
* Renders the instance submenu for the help dropdown.
|
||||||
|
@ -15,6 +16,7 @@ export const InstanceSubmenu: React.FC = () => {
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<DropdownHeader i18nKey={'appbar.help.instance.header'} />
|
<DropdownHeader i18nKey={'appbar.help.instance.header'} />
|
||||||
<VersionInfoHelpMenuEntry />
|
<VersionInfoHelpMenuEntry />
|
||||||
|
<MotdModalHelpMenuEntry />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,35 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
import React, { Fragment } from 'react'
|
||||||
|
import { TranslatedDropdownItem } from '../../translated-dropdown-item'
|
||||||
|
import { InfoCircleFill as IconInfoCircleFill } from 'react-bootstrap-icons'
|
||||||
|
import { useBooleanState } from '../../../../../../../hooks/common/use-boolean-state'
|
||||||
|
import { MotdModal } from '../../../../../../global-dialogs/motd-modal/motd-modal'
|
||||||
|
import { useMotdContextValue } from '../../../../../../motd/motd-context'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help menu entry for the motd modal.
|
||||||
|
* When no modal content is defined, the menu entry will not render.
|
||||||
|
*/
|
||||||
|
export const MotdModalHelpMenuEntry: React.FC = () => {
|
||||||
|
const [modalVisibility, showModal, closeModal] = useBooleanState(false)
|
||||||
|
const contextValue = useMotdContextValue()
|
||||||
|
|
||||||
|
if (!contextValue) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<TranslatedDropdownItem
|
||||||
|
icon={IconInfoCircleFill}
|
||||||
|
i18nKey={'appbar.help.instance.motdModal'}
|
||||||
|
onClick={showModal}
|
||||||
|
/>
|
||||||
|
<MotdModal show={modalVisibility} onDismiss={closeModal} />
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
}
|
25
frontend/src/components/motd/motd-context.tsx
Normal file
25
frontend/src/components/motd/motd-context.tsx
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
'use client'
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2023 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PropsWithChildren } from 'react'
|
||||||
|
import { createContext, useContext } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import type { MotdApiResponse } from '../global-dialogs/motd-modal/fetch-motd'
|
||||||
|
|
||||||
|
const motdContext = createContext<MotdApiResponse | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useMotdContextValue = () => {
|
||||||
|
return useContext(motdContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MotdProviderProps extends PropsWithChildren {
|
||||||
|
motd: MotdApiResponse | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MotdProvider: React.FC<MotdProviderProps> = ({ children, motd }) => {
|
||||||
|
return <motdContext.Provider value={motd}>{children}</motdContext.Provider>
|
||||||
|
}
|
Loading…
Reference in a new issue