Merge pull request #9882 from overleaf/ii-dashboard-system-message-migration

[web] Project dashboard system & translation message migration

GitOrigin-RevId: 0c723a3b526980e5c749da44ebe8a0a3edcc66ad
This commit is contained in:
Alexandre Bourdin 2022-10-12 16:52:53 +02:00 committed by Copybot
parent cdbf8c1831
commit 7a20e1376d
13 changed files with 387 additions and 105 deletions

View file

@ -18,6 +18,12 @@ block append meta
meta(name="ol-tags" data-type="json" content=tags)
meta(name="ol-portalTemplates" data-type="json" content=portalTemplates)
meta(name="ol-prefetchedProjectsBlob" data-type="json" content=prefetchedProjectsBlob)
if (suggestedLanguageSubdomainConfig)
meta(name="ol-suggestedLanguage" data-type="json" content=Object.assign(suggestedLanguageSubdomainConfig, {
lngName: translate(suggestedLanguageSubdomainConfig.lngCode),
imgUrl: buildImgPath("flags/24/" + suggestedLanguageSubdomainConfig.lngCode + ".png")
}))
meta(name="ol-currentUrl" data-type="string" content=currentUrl)
block content
main.content.content-alt.project-list-react#project-list-root

View file

@ -74,6 +74,7 @@
"checking_project_github_status": "",
"clear_cached_files": "",
"clear_search": "",
"click_here_to_view_sl_in_lng": "",
"clone_with_git": "",
"close": "",
"clsi_maintenance": "",
@ -105,6 +106,7 @@
"copy_project": "",
"copying": "",
"country": "",
"country_flag": "",
"create": "",
"create_first_project": "",
"create_new_folder": "",

View file

@ -1,20 +1,17 @@
import { useTranslation } from 'react-i18next'
import classnames from 'classnames'
type CloseProps = {
onDismiss: () => void
} & React.ComponentProps<'div'>
onDismiss: React.MouseEventHandler<HTMLButtonElement>
}
function Close({ onDismiss, className, ...props }: CloseProps) {
function Close({ onDismiss }: CloseProps) {
const { t } = useTranslation()
return (
<div className={classnames('notification-close', className)} {...props}>
<button type="button" className="close pull-right" onClick={onDismiss}>
<span aria-hidden="true">&times;</span>
<span className="sr-only">{t('close')}</span>
</button>
</div>
<button type="button" className="close pull-right" onClick={onDismiss}>
<span aria-hidden="true">&times;</span>
<span className="sr-only">{t('close')}</span>
</button>
)
}

View file

@ -37,7 +37,11 @@ function Notification({
<li className={classnames('notification-entry', className)} {...props}>
<Alert bsStyle={bsStyle}>
{children}
{onDismiss ? <Close onDismiss={handleDismiss} /> : null}
{onDismiss ? (
<div className="notification-close">
<Close onDismiss={handleDismiss} />
</div>
) : null}
</Alert>
</li>
)

View file

@ -0,0 +1,27 @@
import Close from './close'
import usePersistedState from '../../../../shared/hooks/use-persisted-state'
type SystemMessageProps = {
id: string
children: React.ReactNode
}
function SystemMessage({ id, children }: SystemMessageProps) {
const [hidden, setHidden] = usePersistedState(
`systemMessage.hide.${id}`,
false
)
if (hidden) {
return null
}
return (
<li className="system-message">
{id !== 'protected' ? <Close onDismiss={() => setHidden(true)} /> : null}
{children}
</li>
)
}
export default SystemMessage

View file

@ -0,0 +1,54 @@
import { useEffect } from 'react'
import SystemMessage from './system-message'
import TranslationMessage from './translation-message'
import useAsync from '../../../../shared/hooks/use-async'
import { getJSON } from '../../../../infrastructure/fetch-json'
import getMeta from '../../../../utils/meta'
import {
SystemMessage as TSystemMessage,
SuggestedLanguage,
} from '../../../../../../types/project/dashboard/system-message'
const MESSAGE_POLL_INTERVAL = 15 * 60 * 1000
function SystemMessages() {
const { data: messages, runAsync } = useAsync<TSystemMessage[]>()
const suggestedLanguage = getMeta('ol-suggestedLanguage', undefined) as
| SuggestedLanguage
| undefined
useEffect(() => {
const pollMessages = () => {
// Ignore polling if tab is hidden or browser is offline
if (document.hidden || !navigator.onLine) {
return
}
runAsync(getJSON('/system/messages')).catch(console.error)
}
pollMessages()
const interval = setInterval(pollMessages, MESSAGE_POLL_INTERVAL)
return () => {
clearInterval(interval)
}
}, [runAsync])
if (!messages?.length && !suggestedLanguage) {
return null
}
return (
<ul className="system-messages">
{messages?.map((message, idx) => (
<SystemMessage key={idx} id={message._id}>
{message.content}
</SystemMessage>
))}
{suggestedLanguage ? <TranslationMessage /> : null}
</ul>
)
}
export default SystemMessages

View file

@ -0,0 +1,39 @@
import { Trans, useTranslation } from 'react-i18next'
import Close from './close'
import usePersistedState from '../../../../shared/hooks/use-persisted-state'
import getMeta from '../../../../utils/meta'
import { SuggestedLanguage } from '../../../../../../types/project/dashboard/system-message'
function TranslationMessage() {
const { t } = useTranslation()
const [hidden, setHidden] = usePersistedState('hide-i18n-notification', false)
const config = getMeta('ol-suggestedLanguage') as SuggestedLanguage
const currentUrl = getMeta('ol-currentUrl') as string
if (hidden) {
return null
}
return (
<li className="system-message">
<Close onDismiss={() => setHidden(true)} />
<div className="text-center">
<a href={config.url + currentUrl}>
<Trans
i18nKey="click_here_to_view_sl_in_lng"
components={[<strong />]} // eslint-disable-line react/jsx-key
values={{ lngName: config.lngName }}
/>
<img
className="ms-1"
src={config.imgUrl}
alt={t('country_flag', { country: config.lngName })}
aria-hidden
/>
</a>
</div>
</li>
)
}
export default TranslationMessage

View file

@ -13,6 +13,7 @@ import SidebarFilters from './sidebar/sidebar-filters'
import SurveyWidget from './survey-widget'
import WelcomeMessage from './welcome-message'
import LoadingBranded from '../../../shared/components/loading-branded'
import SystemMessages from './notifications/system-messages'
import UserNotifications from './notifications/user-notifications'
import SearchForm from './search-form'
import ProjectsDropdown from './dropdown/projects-dropdown'
@ -51,107 +52,110 @@ function ProjectListPageContent() {
<LoadingBranded loadProgress={loadProgress} />
</div>
) : (
<div className="project-list-wrapper clearfix">
{totalProjectsCount > 0 ? (
<>
<div className="project-list-sidebar-wrapper-react hidden-xs">
<div className="project-list-sidebar-subwrapper">
<aside className="project-list-sidebar-react">
<NewProjectButton id="new-project-button-sidebar" />
<SidebarFilters />
</aside>
</div>
</div>
<div className="project-list-main-react">
{error ? <DashApiError /> : ''}
<Row>
<Col xs={12}>
<UserNotifications />
</Col>
</Row>
<Row>
<Col md={7} className="hidden-xs">
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
/>
</Col>
<Col md={5}>
<div className="project-tools">
<div className="hidden-xs">
{selectedProjects.length === 0 ? (
<CurrentPlanWidget />
) : (
<ProjectTools />
)}
</div>
<div className="visible-xs">
<CurrentPlanWidget />
</div>
</div>
</Col>
</Row>
<div className="visible-xs mt-1">
<div role="toolbar" className="projects-toolbar">
<ProjectsDropdown />
<SortByDropdown />
<>
<SystemMessages />
<div className="project-list-wrapper clearfix">
{totalProjectsCount > 0 ? (
<>
<div className="project-list-sidebar-wrapper-react hidden-xs">
<div className="project-list-sidebar-subwrapper">
<aside className="project-list-sidebar-react">
<NewProjectButton id="new-project-button-sidebar" />
<SidebarFilters />
</aside>
</div>
</div>
<Row className="row-spaced">
<Col xs={12}>
<div className="card project-list-card">
<div className="visible-xs pt-2 pb-3">
<div className="clearfix">
<NewProjectButton
id="new-project-button-projects-table"
className="pull-left me-2"
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
className="overflow-hidden"
formGroupProps={{ className: 'mb-0' }}
/>
</div>
</div>
<ProjectListTable />
</div>
</Col>
</Row>
<Row className="row-spaced">
<Col xs={12}>
<LoadMore />
</Col>
</Row>
</div>
<div className="project-list-sidebar-survey-wrapper hidden-xs">
<div className="project-list-sidebar-survey-subwrapper">
<SurveyWidget />
</div>
</div>
</>
) : (
<div className="project-list-welcome-wrapper">
{error ? <DashApiError /> : ''}
<Row className="row-spaced">
<Col
xs={8}
xsOffset={2}
md={8}
mdOffset={2}
className="project-list-empty-col"
>
<div className="project-list-main-react">
{error ? <DashApiError /> : ''}
<Row>
<Col xs={12}>
<UserNotifications />
</Col>
</Row>
<WelcomeMessage />
</Col>
</Row>
</div>
)}
</div>
<Row>
<Col md={7} className="hidden-xs">
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
/>
</Col>
<Col md={5}>
<div className="project-tools">
<div className="hidden-xs">
{selectedProjects.length === 0 ? (
<CurrentPlanWidget />
) : (
<ProjectTools />
)}
</div>
<div className="visible-xs">
<CurrentPlanWidget />
</div>
</div>
</Col>
</Row>
<div className="visible-xs mt-1">
<div role="toolbar" className="projects-toolbar">
<ProjectsDropdown />
<SortByDropdown />
</div>
</div>
<Row className="row-spaced">
<Col xs={12}>
<div className="card project-list-card">
<div className="visible-xs pt-2 pb-3">
<div className="clearfix">
<NewProjectButton
id="new-project-button-projects-table"
className="pull-left me-2"
/>
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
className="overflow-hidden"
formGroupProps={{ className: 'mb-0' }}
/>
</div>
</div>
<ProjectListTable />
</div>
</Col>
</Row>
<Row className="row-spaced">
<Col xs={12}>
<LoadMore />
</Col>
</Row>
</div>
<div className="project-list-sidebar-survey-wrapper hidden-xs">
<div className="project-list-sidebar-survey-subwrapper">
<SurveyWidget />
</div>
</div>
</>
) : (
<div className="project-list-welcome-wrapper">
{error ? <DashApiError /> : ''}
<Row className="row-spaced">
<Col
xs={8}
xsOffset={2}
md={8}
mdOffset={2}
className="project-list-empty-col"
>
<Row>
<Col xs={12}>
<UserNotifications />
</Col>
</Row>
<WelcomeMessage />
</Col>
</Row>
</div>
)}
</div>
</>
)
}

View file

@ -0,0 +1,44 @@
import SystemMessages from '../../js/features/project-list/components/notifications/system-messages'
import useFetchMock from '../hooks/use-fetch-mock'
import { FetchMockStatic } from 'fetch-mock'
export const SystemMessage = (args: any) => {
useFetchMock((fetchMock: FetchMockStatic) => {
fetchMock.get(/\/system\/messages/, [
{
_id: 1,
content: `
Closing this message will mark it as hidden.
Remove it from the local storage to make it appear again.
`,
},
{
_id: 'protected',
content: 'A protected message content - cannot be closed',
},
])
})
window.metaAttributesCache = new Map()
return <SystemMessages {...args} />
}
export const TranslationMessage = (args: any) => {
useFetchMock((fetchMock: FetchMockStatic) => {
fetchMock.get(/\/system\/messages/, [])
})
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-suggestedLanguage', {
url: '/dev/null',
lngName: 'German',
imgUrl: 'https://flagcdn.com/w40/de.png',
})
return <SystemMessages {...args} />
}
export default {
title: 'Project List / System Messages',
component: SystemMessages,
}

View file

@ -1,3 +1,9 @@
.system-messages {
list-style: none;
margin: 0;
padding: 0;
}
.system-message {
padding: (@line-height-computed / 4) (@line-height-computed / 2);
background-color: @sys-msg-background;

View file

@ -1443,6 +1443,7 @@
"address": "Address",
"coupon_code": "Coupon code",
"country": "Country",
"country_flag": "__country__ country flag",
"billing_address": "Billing Address",
"upgrade_now": "Upgrade Now",
"state": "State",

View file

@ -0,0 +1,87 @@
import { expect } from 'chai'
import { render, screen, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
import SystemMessages from '../../../../../frontend/js/features/project-list/components/notifications/system-messages'
describe('<SystemMessages />', function () {
beforeEach(function () {
fetchMock.reset()
localStorage.clear()
window.metaAttributesCache = new Map()
})
afterEach(function () {
fetchMock.reset()
localStorage.clear()
window.metaAttributesCache = new Map()
})
it('renders non-dismissable system message', async function () {
const data = {
_id: 'protected',
content: 'Random content',
}
fetchMock.get(/\/system\/messages/, [data])
render(<SystemMessages />)
await fetchMock.flush(true)
screen.getByText(data.content)
expect(screen.queryByRole('button', { name: /close/i })).to.be.null
})
it('renders and closes dismissable system message', async function () {
const data = {
_id: 1,
content: 'Random content',
}
fetchMock.get(/\/system\/messages/, [data])
render(<SystemMessages />)
await fetchMock.flush(true)
screen.getByText(data.content)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(screen.queryByText(data.content)).to.be.null
const dismissed = localStorage.getItem(`systemMessage.hide.${data._id}`)
expect(dismissed).to.equal('true')
})
it('renders and closes translation message', async function () {
const data = {
url: '/dev/null',
lngName: 'German',
imgUrl: 'https://flagcdn.com/w40/de.png',
}
const currentUrl = '/project'
fetchMock.get(/\/system\/messages/, [])
window.metaAttributesCache = new Map()
window.metaAttributesCache.set('ol-suggestedLanguage', data)
window.metaAttributesCache.set('ol-currentUrl', currentUrl)
render(<SystemMessages />)
await fetchMock.flush(true)
const link = screen.getByRole('link', { name: /click here/i })
expect(link.getAttribute('href')).to.equal(`${data.url}${currentUrl}`)
const flag = screen.getByRole('img', { hidden: true })
expect(flag.getAttribute('src')).to.equal(data.imgUrl)
const closeBtn = screen.getByRole('button', { name: /close/i })
fireEvent.click(closeBtn)
expect(
screen.queryByRole('link', {
name: `Click here to use Overleaf in ${data.lngName}`,
})
).to.be.null
expect(screen.queryByRole('img')).to.be.null
const dismissed = localStorage.getItem('hide-i18n-notification')
expect(dismissed).to.equal('true')
})
})

View file

@ -0,0 +1,11 @@
export type SystemMessage = {
_id: string
content: string
}
export type SuggestedLanguage = {
url: string
imgUrl: string
lngCode: string
lngName: string
}