mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2024-11-25 11:16:31 -05:00
fix(motd): move fetch into component
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
0dac59ed2d
commit
8a5f86a89e
13 changed files with 359 additions and 254 deletions
|
@ -10,101 +10,28 @@ const motdMockContent = 'This is the **mock** Motd call'
|
||||||
const motdMockHtml = 'This is the <strong>mock</strong> Motd call'
|
const motdMockHtml = 'This is the <strong>mock</strong> Motd call'
|
||||||
|
|
||||||
describe('Motd', () => {
|
describe('Motd', () => {
|
||||||
const mockExistingMotd = (useEtag?: boolean, content = motdMockContent) => {
|
it("shows, dismisses and won't show again a motd modal", () => {
|
||||||
|
localStorage.removeItem(MOTD_LOCAL_STORAGE_KEY)
|
||||||
cy.intercept('GET', 'public/motd.md', {
|
cy.intercept('GET', 'public/motd.md', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED },
|
headers: { 'Last-Modified': MOCK_LAST_MODIFIED },
|
||||||
body: content
|
body: motdMockContent
|
||||||
})
|
})
|
||||||
|
|
||||||
cy.intercept('HEAD', 'public/motd.md', {
|
cy.intercept('HEAD', 'public/motd.md', {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
headers: { [useEtag ? 'etag' : 'Last-Modified']: MOCK_LAST_MODIFIED }
|
headers: { 'Last-Modified': MOCK_LAST_MODIFIED }
|
||||||
})
|
})
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.removeItem(MOTD_LOCAL_STORAGE_KEY)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('shows the correct alert Motd text', () => {
|
|
||||||
mockExistingMotd()
|
|
||||||
cy.visitHome()
|
cy.visitHome()
|
||||||
cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
|
cy.getByCypressId('motd-modal').find('.markdown-body').should('contain.html', motdMockHtml)
|
||||||
})
|
|
||||||
|
|
||||||
it("doesn't allow html in the motd", () => {
|
|
||||||
mockExistingMotd(false, '<iframe></iframe>')
|
|
||||||
cy.visitHome()
|
|
||||||
cy.getByCypressId('motd').find('.markdown-body').should('have.html', '<p><iframe></iframe></p>\n')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('can be dismissed using etag', () => {
|
|
||||||
mockExistingMotd(true)
|
|
||||||
cy.visitHome()
|
|
||||||
cy.getByCypressId('motd').find('.markdown-body').should('contain.html', 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)).to.equal(MOCK_LAST_MODIFIED)
|
||||||
})
|
})
|
||||||
cy.getByCypressId('motd').should('not.exist')
|
cy.getByCypressId('motd-modal').should('not.exist')
|
||||||
})
|
|
||||||
|
|
||||||
it('can be dismissed', () => {
|
|
||||||
mockExistingMotd()
|
|
||||||
cy.visitHome()
|
|
||||||
cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
|
|
||||||
cy.getByCypressId('motd-dismiss')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).to.equal(MOCK_LAST_MODIFIED)
|
|
||||||
})
|
|
||||||
cy.getByCypressId('motd').should('not.exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("won't show again after dismiss and reload", () => {
|
|
||||||
mockExistingMotd()
|
|
||||||
cy.visitHome()
|
|
||||||
cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
|
|
||||||
cy.getByCypressId('motd-dismiss')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).to.equal(MOCK_LAST_MODIFIED)
|
|
||||||
})
|
|
||||||
cy.getByCypressId('motd').should('not.exist')
|
|
||||||
cy.reload()
|
cy.reload()
|
||||||
cy.get('main').should('exist')
|
cy.get('main').should('exist')
|
||||||
cy.getByCypressId('motd').should('not.exist')
|
cy.getByCypressId('motd-modal').should('not.exist')
|
||||||
})
|
|
||||||
|
|
||||||
it('will show again after reload without dismiss', () => {
|
|
||||||
mockExistingMotd()
|
|
||||||
cy.visitHome()
|
|
||||||
cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
|
|
||||||
cy.reload()
|
|
||||||
cy.get('main').should('exist')
|
|
||||||
cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
|
|
||||||
})
|
|
||||||
|
|
||||||
it("won't show again after dismiss and page navigation", () => {
|
|
||||||
mockExistingMotd()
|
|
||||||
cy.visitHome()
|
|
||||||
cy.getByCypressId('motd').find('.markdown-body').should('contain.html', motdMockHtml)
|
|
||||||
cy.getByCypressId('motd-dismiss')
|
|
||||||
.click()
|
|
||||||
.then(() => {
|
|
||||||
expect(localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)).to.equal(MOCK_LAST_MODIFIED)
|
|
||||||
})
|
|
||||||
cy.getByCypressId('motd').should('not.exist')
|
|
||||||
cy.getByCypressId('navLinkHistory').click()
|
|
||||||
cy.get('main').should('exist')
|
|
||||||
cy.getByCypressId('motd').should('not.exist')
|
|
||||||
})
|
|
||||||
|
|
||||||
it("won't show if no file exists", () => {
|
|
||||||
cy.visitHome()
|
|
||||||
cy.get('main').should('exist')
|
|
||||||
cy.getByCypressId('motd').should('not.exist')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
import { setUpI18n } from './setupI18n'
|
import { setUpI18n } from './setupI18n'
|
||||||
import { refreshHistoryState } from '../../../redux/history/methods'
|
import { refreshHistoryState } from '../../../redux/history/methods'
|
||||||
import { fetchMotd } from './fetch-motd'
|
|
||||||
import { fetchAndSetUser } from '../../login-page/auth/utils'
|
import { fetchAndSetUser } from '../../login-page/auth/utils'
|
||||||
import { fetchFrontendConfig } from './fetch-frontend-config'
|
import { fetchFrontendConfig } from './fetch-frontend-config'
|
||||||
import { loadDarkMode } from './load-dark-mode'
|
import { loadDarkMode } from './load-dark-mode'
|
||||||
|
@ -65,10 +64,6 @@ export const createSetUpTaskList = (): InitTask[] => {
|
||||||
name: 'Fetch user information',
|
name: 'Fetch user information',
|
||||||
task: fetchUserInformation
|
task: fetchUserInformation
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'Motd',
|
|
||||||
task: fetchMotd
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'Load history state',
|
name: 'Load history state',
|
||||||
task: refreshHistoryState
|
task: refreshHistoryState
|
||||||
|
|
|
@ -0,0 +1,88 @@
|
||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`motd modal doesn't allow html in the motd 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
This is a mock implementation of a Modal:
|
||||||
|
<dialog>
|
||||||
|
<div
|
||||||
|
class="modal-body"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="markdown-body"
|
||||||
|
data-testid="motd-renderer"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
<iframe></iframe>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="modal-footer"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
data-testid="motd-dismiss"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
common.dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`motd modal doesn't render a modal if no motd has been fetched 1`] = `
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
data-testid="loaded not visible"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`motd modal renders a modal if a motd was fetched and can dismiss it 1`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
This is a mock implementation of a Modal:
|
||||||
|
<dialog>
|
||||||
|
<div
|
||||||
|
class="modal-body"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="markdown-body"
|
||||||
|
data-testid="motd-renderer"
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
very important mock text!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="modal-footer"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
data-testid="motd-dismiss"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
common.dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`motd modal renders a modal if a motd was fetched and can dismiss it 2`] = `
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
This is a mock implementation of a Modal:
|
||||||
|
Modal is invisible
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
130
src/components/common/motd-modal/fetch-motd.test.ts
Normal file
130
src/components/common/motd-modal/fetch-motd.test.ts
Normal file
|
@ -0,0 +1,130 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Mock } from 'ts-mockery'
|
||||||
|
import { fetchMotd } from './fetch-motd'
|
||||||
|
|
||||||
|
describe('fetch motd', () => {
|
||||||
|
const motdUrl = 'public/motd.md'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.localStorage.clear()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
jest.resetModules()
|
||||||
|
})
|
||||||
|
beforeAll(() => {
|
||||||
|
global.fetch = jest.fn()
|
||||||
|
})
|
||||||
|
|
||||||
|
const mockFetch = (
|
||||||
|
responseText: string,
|
||||||
|
lastModified: string | null,
|
||||||
|
etag?: string | null
|
||||||
|
): jest.SpyInstance<Promise<Response>> => {
|
||||||
|
return jest.spyOn(global, 'fetch').mockImplementation((url: RequestInfo | URL) => {
|
||||||
|
if (url !== motdUrl) {
|
||||||
|
return Promise.reject('wrong url')
|
||||||
|
}
|
||||||
|
return Promise.resolve(
|
||||||
|
Mock.of<Response>({
|
||||||
|
headers: Mock.of<Headers>({
|
||||||
|
get: (name: string) => {
|
||||||
|
return name === 'Last-Modified' ? lastModified : name === 'etag' ? etag ?? null : null
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
text: () => Promise.resolve(responseText),
|
||||||
|
status: 200
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockFileNotFoundFetch = () => {
|
||||||
|
jest.spyOn(global, 'fetch').mockImplementation(() =>
|
||||||
|
Promise.resolve(
|
||||||
|
Mock.of<Response>({
|
||||||
|
status: 500
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('date detection', () => {
|
||||||
|
it('will return the last-modified value if available', async () => {
|
||||||
|
mockFetch('mocked motd', 'yesterday-modified', null)
|
||||||
|
const result = fetchMotd()
|
||||||
|
await expect(result).resolves.toStrictEqual({
|
||||||
|
motdText: 'mocked motd',
|
||||||
|
lastModified: 'yesterday-modified'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('will return the etag if last-modified is not returned', async () => {
|
||||||
|
mockFetch('mocked motd', null, 'yesterday-etag')
|
||||||
|
const result = fetchMotd()
|
||||||
|
await expect(result).resolves.toStrictEqual({
|
||||||
|
motdText: 'mocked motd',
|
||||||
|
lastModified: 'yesterday-etag'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('will prefer the last-modified header over the etag', async () => {
|
||||||
|
mockFetch('mocked motd', 'yesterday-last', 'yesterday-etag')
|
||||||
|
const result = fetchMotd()
|
||||||
|
await expect(result).resolves.toStrictEqual({
|
||||||
|
motdText: 'mocked motd',
|
||||||
|
lastModified: 'yesterday-last'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('will return an empty value if neither the last-modified value nor the etag is returned', async () => {
|
||||||
|
mockFetch('mocked motd', null, null)
|
||||||
|
const result = fetchMotd()
|
||||||
|
await expect(result).resolves.toStrictEqual({
|
||||||
|
motdText: 'mocked motd',
|
||||||
|
lastModified: null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can fetch a motd if no last modified value has been memorized', async () => {
|
||||||
|
mockFetch('mocked motd', 'yesterday')
|
||||||
|
const result = fetchMotd()
|
||||||
|
await expect(result).resolves.toStrictEqual({
|
||||||
|
motdText: 'mocked motd',
|
||||||
|
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 () => {
|
||||||
|
mockFetch('mocked motd', 'yesterday')
|
||||||
|
window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
|
||||||
|
const result = fetchMotd()
|
||||||
|
await expect(result).resolves.toStrictEqual({
|
||||||
|
motdText: 'mocked motd',
|
||||||
|
lastModified: 'yesterday'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("won't fetch a motd if no file was found", async () => {
|
||||||
|
mockFileNotFoundFetch()
|
||||||
|
const result = fetchMotd()
|
||||||
|
await expect(result).resolves.toStrictEqual(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("won't fetch a motd update if no file was found", async () => {
|
||||||
|
mockFileNotFoundFetch()
|
||||||
|
window.localStorage.setItem('motd.lastModified', 'the day before yesterday')
|
||||||
|
const result = fetchMotd()
|
||||||
|
await expect(result).resolves.toStrictEqual(undefined)
|
||||||
|
})
|
||||||
|
})
|
|
@ -4,13 +4,17 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { setMotd } from '../../../redux/motd/methods'
|
|
||||||
import { Logger } from '../../../utils/logger'
|
import { Logger } from '../../../utils/logger'
|
||||||
import { defaultConfig } from '../../../api/common/default-config'
|
import { defaultConfig } from '../../../api/common/default-config'
|
||||||
|
|
||||||
export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
|
export const MOTD_LOCAL_STORAGE_KEY = 'motd.lastModified'
|
||||||
const log = new Logger('Motd')
|
const log = new Logger('Motd')
|
||||||
|
|
||||||
|
export interface MotdApiResponse {
|
||||||
|
motdText: string
|
||||||
|
lastModified: string | null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches the current motd from the backend and sets the content in the global application state.
|
* Fetches the current motd from the backend and sets the content in the global application state.
|
||||||
* If the motd hasn't changed since the last time then the global application state won't be changed.
|
* If the motd hasn't changed since the last time then the global application state won't be changed.
|
||||||
|
@ -18,7 +22,7 @@ const log = new Logger('Motd')
|
||||||
* 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<void> => {
|
export const fetchMotd = async (): Promise<MotdApiResponse | undefined> => {
|
||||||
const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)
|
const cachedLastModified = window.localStorage.getItem(MOTD_LOCAL_STORAGE_KEY)
|
||||||
const motdUrl = `public/motd.md`
|
const motdUrl = `public/motd.md`
|
||||||
|
|
||||||
|
@ -28,11 +32,11 @@ export const fetchMotd = async (): Promise<void> => {
|
||||||
method: 'HEAD'
|
method: 'HEAD'
|
||||||
})
|
})
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
|
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
|
||||||
if (lastModified === cachedLastModified) {
|
if (lastModified === cachedLastModified) {
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +45,7 @@ export const fetchMotd = async (): Promise<void> => {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
return
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
|
const lastModified = response.headers.get('Last-Modified') || response.headers.get('etag')
|
||||||
|
@ -49,6 +53,5 @@ export const fetchMotd = async (): Promise<void> => {
|
||||||
log.warn("'Last-Modified' or 'Etag' not found for motd.md!")
|
log.warn("'Last-Modified' or 'Etag' not found for motd.md!")
|
||||||
}
|
}
|
||||||
|
|
||||||
const motdText = await response.text()
|
return { motdText: await response.text(), lastModified }
|
||||||
setMotd(motdText, lastModified)
|
|
||||||
}
|
}
|
73
src/components/common/motd-modal/motd-modal.test.tsx
Normal file
73
src/components/common/motd-modal/motd-modal.test.tsx
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { MotdModal } from './motd-modal'
|
||||||
|
import { act, render, screen } from '@testing-library/react'
|
||||||
|
import * as fetchMotdModule from './fetch-motd'
|
||||||
|
import * as CommonModalModule from '../modals/common-modal'
|
||||||
|
import type { PropsWithChildren } from 'react'
|
||||||
|
import React from 'react'
|
||||||
|
import type { CommonModalProps } from '../modals/common-modal'
|
||||||
|
import { mockI18n } from '../../markdown-renderer/test-utils/mock-i18n'
|
||||||
|
|
||||||
|
jest.mock('./fetch-motd')
|
||||||
|
jest.mock('../modals/common-modal')
|
||||||
|
|
||||||
|
describe('motd modal', () => {
|
||||||
|
beforeAll(mockI18n)
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
jest.resetAllMocks()
|
||||||
|
jest.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
jest.spyOn(CommonModalModule, 'CommonModal').mockImplementation((({ children, show }) => {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
This is a mock implementation of a Modal:
|
||||||
|
{show ? <dialog>{children}</dialog> : 'Modal is invisible'}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}) as React.FC<PropsWithChildren<CommonModalProps>>)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a modal if a motd was fetched and can dismiss it', async () => {
|
||||||
|
jest.spyOn(fetchMotdModule, 'fetchMotd').mockImplementation(() => {
|
||||||
|
return Promise.resolve({
|
||||||
|
motdText: 'very important mock text!',
|
||||||
|
lastModified: 'yesterday'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const view = render(<MotdModal></MotdModal>)
|
||||||
|
await screen.findByTestId('motd-renderer')
|
||||||
|
expect(view.container).toMatchSnapshot()
|
||||||
|
|
||||||
|
const button = await screen.findByTestId('motd-dismiss')
|
||||||
|
act(() => {
|
||||||
|
button.click()
|
||||||
|
})
|
||||||
|
expect(view.container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("doesn't render a modal if no motd has been fetched", async () => {
|
||||||
|
jest.spyOn(fetchMotdModule, 'fetchMotd').mockImplementation(() => {
|
||||||
|
return Promise.resolve(undefined)
|
||||||
|
})
|
||||||
|
const view = render(<MotdModal></MotdModal>)
|
||||||
|
await screen.findByTestId('loaded not visible')
|
||||||
|
expect(view.container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("doesn't allow html in the motd", async () => {
|
||||||
|
jest.spyOn(fetchMotdModule, 'fetchMotd').mockImplementation(() => {
|
||||||
|
return Promise.resolve({ motdText: '<iframe></iframe>', lastModified: 'yesterday' })
|
||||||
|
})
|
||||||
|
const view = render(<MotdModal></MotdModal>)
|
||||||
|
await screen.findByTestId('motd-renderer')
|
||||||
|
expect(view.container).toMatchSnapshot()
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,19 +1,22 @@
|
||||||
/*
|
/*
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { Suspense, useCallback } from 'react'
|
import React, { Suspense, useCallback, useEffect, useState } from 'react'
|
||||||
import { Button, Modal } from 'react-bootstrap'
|
import { Button, Modal } from 'react-bootstrap'
|
||||||
import { CommonModal } from '../modals/common-modal'
|
import { CommonModal } from '../modals/common-modal'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
|
||||||
import { dismissMotd } from '../../../redux/motd/methods'
|
|
||||||
import { cypressId } from '../../../utils/cypress-attribute'
|
|
||||||
import { WaitSpinner } from '../wait-spinner/wait-spinner'
|
import { WaitSpinner } from '../wait-spinner/wait-spinner'
|
||||||
|
import { fetchMotd, MOTD_LOCAL_STORAGE_KEY } from './fetch-motd'
|
||||||
|
import { useAsync } from 'react-use'
|
||||||
|
import { Logger } from '../../../utils/logger'
|
||||||
|
import { testId } from '../../../utils/test-id'
|
||||||
|
import { cypressId } from '../../../utils/cypress-attribute'
|
||||||
|
|
||||||
const MotdRenderer = React.lazy(() => import('./motd-renderer'))
|
const MotdRenderer = React.lazy(() => import('./motd-renderer'))
|
||||||
|
const logger = new Logger('Motd')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the motd from the global application state and shows it in a modal.
|
* Reads the motd from the global application state and shows it in a modal.
|
||||||
|
@ -22,31 +25,39 @@ const MotdRenderer = React.lazy(() => import('./motd-renderer'))
|
||||||
*/
|
*/
|
||||||
export const MotdModal: React.FC = () => {
|
export const MotdModal: React.FC = () => {
|
||||||
useTranslation()
|
useTranslation()
|
||||||
const motdState = useApplicationState((state) => state.motd)
|
|
||||||
|
const { error, loading, value } = useAsync(fetchMotd)
|
||||||
|
const [dismissed, setDismissed] = useState(false)
|
||||||
|
|
||||||
const dismiss = useCallback(() => {
|
const dismiss = useCallback(() => {
|
||||||
if (!motdState) {
|
if (value?.lastModified) {
|
||||||
return
|
window.localStorage.setItem(MOTD_LOCAL_STORAGE_KEY, value.lastModified)
|
||||||
}
|
}
|
||||||
dismissMotd()
|
setDismissed(true)
|
||||||
}, [motdState])
|
}, [value])
|
||||||
|
|
||||||
if (motdState === null || motdState.dismissed) {
|
useEffect(() => {
|
||||||
return null
|
if (error) {
|
||||||
} else {
|
logger.error('Error while fetching motd', error)
|
||||||
return (
|
}
|
||||||
<CommonModal {...cypressId('motd')} show={true} title={'motd.title'}>
|
}, [error])
|
||||||
<Modal.Body>
|
|
||||||
<Suspense fallback={<WaitSpinner />}>
|
if (process.env.NODE_ENV === 'test' && !loading && !value) {
|
||||||
<MotdRenderer />
|
return <span {...testId('loaded not visible')}></span>
|
||||||
</Suspense>
|
|
||||||
</Modal.Body>
|
|
||||||
<Modal.Footer>
|
|
||||||
<Button variant={'success'} onClick={dismiss} {...cypressId('motd-dismiss')}>
|
|
||||||
<Trans i18nKey={'common.dismiss'} />
|
|
||||||
</Button>
|
|
||||||
</Modal.Footer>
|
|
||||||
</CommonModal>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommonModal show={!!value && !loading && !error && !dismissed} title={'motd.title'} {...cypressId('motd-modal')}>
|
||||||
|
<Modal.Body>
|
||||||
|
<Suspense fallback={<WaitSpinner />}>
|
||||||
|
<MotdRenderer content={value?.motdText as string} />
|
||||||
|
</Suspense>
|
||||||
|
</Modal.Body>
|
||||||
|
<Modal.Footer>
|
||||||
|
<Button variant={'success'} onClick={dismiss} {...testId('motd-dismiss')} {...cypressId('motd-dismiss')}>
|
||||||
|
<Trans i18nKey={'common.dismiss'} />
|
||||||
|
</Button>
|
||||||
|
</Modal.Footer>
|
||||||
|
</CommonModal>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,18 +10,22 @@ import { useConvertMarkdownToReactDom } from '../../markdown-renderer/hooks/use-
|
||||||
import { LinkifyFixMarkdownExtension } from '../../markdown-renderer/markdown-extension/linkify-fix-markdown-extension'
|
import { LinkifyFixMarkdownExtension } from '../../markdown-renderer/markdown-extension/linkify-fix-markdown-extension'
|
||||||
import { EmojiMarkdownExtension } from '../../markdown-renderer/markdown-extension/emoji/emoji-markdown-extension'
|
import { EmojiMarkdownExtension } from '../../markdown-renderer/markdown-extension/emoji/emoji-markdown-extension'
|
||||||
import { DebuggerMarkdownExtension } from '../../markdown-renderer/markdown-extension/debugger-markdown-extension'
|
import { DebuggerMarkdownExtension } from '../../markdown-renderer/markdown-extension/debugger-markdown-extension'
|
||||||
import { useApplicationState } from '../../../hooks/common/use-application-state'
|
|
||||||
import { ProxyImageMarkdownExtension } from '../../markdown-renderer/markdown-extension/image/proxy-image-markdown-extension'
|
import { ProxyImageMarkdownExtension } from '../../markdown-renderer/markdown-extension/image/proxy-image-markdown-extension'
|
||||||
import { YoutubeMarkdownExtension } from '../../markdown-renderer/markdown-extension/youtube/youtube-markdown-extension'
|
import { YoutubeMarkdownExtension } from '../../markdown-renderer/markdown-extension/youtube/youtube-markdown-extension'
|
||||||
import { AlertMarkdownExtension } from '../../markdown-renderer/markdown-extension/alert-markdown-extension'
|
import { AlertMarkdownExtension } from '../../markdown-renderer/markdown-extension/alert-markdown-extension'
|
||||||
import { SpoilerMarkdownExtension } from '../../markdown-renderer/markdown-extension/spoiler-markdown-extension'
|
import { SpoilerMarkdownExtension } from '../../markdown-renderer/markdown-extension/spoiler-markdown-extension'
|
||||||
import { BlockquoteExtraTagMarkdownExtension } from '../../markdown-renderer/markdown-extension/blockquote/blockquote-extra-tag-markdown-extension'
|
import { BlockquoteExtraTagMarkdownExtension } from '../../markdown-renderer/markdown-extension/blockquote/blockquote-extra-tag-markdown-extension'
|
||||||
import { VimeoMarkdownExtension } from '../../markdown-renderer/markdown-extension/vimeo/vimeo-markdown-extension'
|
import { VimeoMarkdownExtension } from '../../markdown-renderer/markdown-extension/vimeo/vimeo-markdown-extension'
|
||||||
|
import { testId } from '../../../utils/test-id'
|
||||||
|
|
||||||
|
export interface MotdRendererProps {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the motd from the global application state and renders it as markdown with a subset of the usual features and without HTML support.
|
* Reads the motd from the global application state and renders it as markdown with a subset of the usual features and without HTML support.
|
||||||
*/
|
*/
|
||||||
export const MotdRenderer: React.FC = () => {
|
export const MotdRenderer: React.FC<MotdRendererProps> = ({ content }) => {
|
||||||
const extensions = useMemo(
|
const extensions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
new YoutubeMarkdownExtension(),
|
new YoutubeMarkdownExtension(),
|
||||||
|
@ -38,11 +42,14 @@ export const MotdRenderer: React.FC = () => {
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const motdState = useApplicationState((state) => state.motd)
|
const lines = useMemo(() => content.split('\n'), [content])
|
||||||
const lines = useMemo(() => (motdState ? motdState.text.split('\n') : []), [motdState])
|
|
||||||
const dom = useConvertMarkdownToReactDom(lines, extensions, true, false)
|
const dom = useConvertMarkdownToReactDom(lines, extensions, true, false)
|
||||||
|
|
||||||
return <div className={'markdown-body'}>{dom}</div>
|
return (
|
||||||
|
<div {...testId('motd-renderer')} className={'markdown-body'}>
|
||||||
|
{dom}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default MotdRenderer
|
export default MotdRenderer
|
||||||
|
|
2
src/redux/application-state.d.ts
vendored
2
src/redux/application-state.d.ts
vendored
|
@ -6,7 +6,6 @@
|
||||||
|
|
||||||
import type { OptionalUserState } from './user/types'
|
import type { OptionalUserState } from './user/types'
|
||||||
import type { Config } from '../api/config/types'
|
import type { Config } from '../api/config/types'
|
||||||
import type { OptionalMotdState } from './motd/types'
|
|
||||||
import type { EditorConfig } from './editor/types'
|
import type { EditorConfig } from './editor/types'
|
||||||
import type { DarkModeConfig } from './dark-mode/types'
|
import type { DarkModeConfig } from './dark-mode/types'
|
||||||
import type { NoteDetails } from './note-details/types/note-details'
|
import type { NoteDetails } from './note-details/types/note-details'
|
||||||
|
@ -17,7 +16,6 @@ import type { RealtimeState } from './realtime/types'
|
||||||
export interface ApplicationState {
|
export interface ApplicationState {
|
||||||
user: OptionalUserState
|
user: OptionalUserState
|
||||||
config: Config
|
config: Config
|
||||||
motd: OptionalMotdState
|
|
||||||
history: HistoryEntryWithOrigin[]
|
history: HistoryEntryWithOrigin[]
|
||||||
editorConfig: EditorConfig
|
editorConfig: EditorConfig
|
||||||
darkMode: DarkModeConfig
|
darkMode: DarkModeConfig
|
||||||
|
|
|
@ -1,32 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { store } from '..'
|
|
||||||
import type { DismissMotdAction, SetMotdAction } from './types'
|
|
||||||
import { MotdActionType } from './types'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets a not-dismissed motd message in the global application state.
|
|
||||||
*
|
|
||||||
* @param text The motd text content
|
|
||||||
* @param lastModified An identifier that describes when the motd was changed the last time.
|
|
||||||
*/
|
|
||||||
export const setMotd = (text: string, lastModified: string | null): void => {
|
|
||||||
store.dispatch({
|
|
||||||
type: MotdActionType.SET_MOTD,
|
|
||||||
text,
|
|
||||||
lastModified
|
|
||||||
} as SetMotdAction)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dismisses the currently saved motd message.
|
|
||||||
*/
|
|
||||||
export const dismissMotd = (): void => {
|
|
||||||
store.dispatch({
|
|
||||||
type: MotdActionType.DISMISS_MOTD
|
|
||||||
} as DismissMotdAction)
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Reducer } from 'redux'
|
|
||||||
import type { MotdActions, MotdState, OptionalMotdState } from './types'
|
|
||||||
import { MotdActionType } from './types'
|
|
||||||
import { MOTD_LOCAL_STORAGE_KEY } from '../../components/application-loader/initializers/fetch-motd'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A reducer that modifies the {@link OptionalMotdState motd state} in the global application state.
|
|
||||||
*/
|
|
||||||
export const MotdReducer: Reducer<OptionalMotdState, MotdActions> = (
|
|
||||||
state: OptionalMotdState = null,
|
|
||||||
action: MotdActions
|
|
||||||
) => {
|
|
||||||
switch (action.type) {
|
|
||||||
case MotdActionType.SET_MOTD:
|
|
||||||
return createNewMotdState(action.text, action.lastModified)
|
|
||||||
case MotdActionType.DISMISS_MOTD:
|
|
||||||
return createDismissedMotdState(state)
|
|
||||||
default:
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new {@link MotdState motd state} by copying the old state and setting the dismissed flag.
|
|
||||||
* It also writes the "last-modified" identifier into the browser's local storage.
|
|
||||||
*
|
|
||||||
* @param oldState The current motd state that should be copied
|
|
||||||
* @return The new state
|
|
||||||
*/
|
|
||||||
const createDismissedMotdState = (oldState: OptionalMotdState): OptionalMotdState => {
|
|
||||||
if (oldState === null) {
|
|
||||||
return null
|
|
||||||
} else {
|
|
||||||
if (oldState.lastModified) {
|
|
||||||
window.localStorage.setItem(MOTD_LOCAL_STORAGE_KEY, oldState.lastModified)
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...oldState,
|
|
||||||
dismissed: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new not-dismissed motd state.
|
|
||||||
* @param text The motd text
|
|
||||||
* @param lastModified An identifier that describes when the motd text was changed the last time
|
|
||||||
*/
|
|
||||||
const createNewMotdState = (text: string, lastModified: string | null): MotdState => {
|
|
||||||
return {
|
|
||||||
text,
|
|
||||||
lastModified,
|
|
||||||
dismissed: false
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,32 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
|
||||||
*
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Action } from 'redux'
|
|
||||||
|
|
||||||
export enum MotdActionType {
|
|
||||||
SET_MOTD = 'motd/set',
|
|
||||||
DISMISS_MOTD = 'motd/dismiss'
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MotdActions = SetMotdAction | DismissMotdAction
|
|
||||||
|
|
||||||
export interface SetMotdAction extends Action<MotdActionType> {
|
|
||||||
type: MotdActionType.SET_MOTD
|
|
||||||
text: string
|
|
||||||
lastModified: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DismissMotdAction extends Action<MotdActionType> {
|
|
||||||
type: MotdActionType.DISMISS_MOTD
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MotdState {
|
|
||||||
text: string
|
|
||||||
lastModified: string | null
|
|
||||||
dismissed: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type OptionalMotdState = MotdState | null
|
|
|
@ -8,7 +8,6 @@ import type { Reducer } from 'redux'
|
||||||
import { combineReducers } from 'redux'
|
import { combineReducers } from 'redux'
|
||||||
import { UserReducer } from './user/reducers'
|
import { UserReducer } from './user/reducers'
|
||||||
import { ConfigReducer } from './config/reducers'
|
import { ConfigReducer } from './config/reducers'
|
||||||
import { MotdReducer } from './motd/reducers'
|
|
||||||
import { HistoryReducer } from './history/reducers'
|
import { HistoryReducer } from './history/reducers'
|
||||||
import { EditorConfigReducer } from './editor/reducers'
|
import { EditorConfigReducer } from './editor/reducers'
|
||||||
import { DarkModeConfigReducer } from './dark-mode/reducers'
|
import { DarkModeConfigReducer } from './dark-mode/reducers'
|
||||||
|
@ -20,7 +19,6 @@ import { RealtimeReducer } from './realtime/reducers'
|
||||||
export const allReducers: Reducer<ApplicationState> = combineReducers<ApplicationState>({
|
export const allReducers: Reducer<ApplicationState> = combineReducers<ApplicationState>({
|
||||||
user: UserReducer,
|
user: UserReducer,
|
||||||
config: ConfigReducer,
|
config: ConfigReducer,
|
||||||
motd: MotdReducer,
|
|
||||||
history: HistoryReducer,
|
history: HistoryReducer,
|
||||||
editorConfig: EditorConfigReducer,
|
editorConfig: EditorConfigReducer,
|
||||||
darkMode: DarkModeConfigReducer,
|
darkMode: DarkModeConfigReducer,
|
||||||
|
|
Loading…
Reference in a new issue