mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-07 20:31:06 -05:00
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:
parent
cdbf8c1831
commit
7a20e1376d
13 changed files with 387 additions and 105 deletions
|
@ -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
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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">×</span>
|
||||
<span className="sr-only">{t('close')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" className="close pull-right" onClick={onDismiss}>
|
||||
<span aria-hidden="true">×</span>
|
||||
<span className="sr-only">{t('close')}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
11
services/web/types/project/dashboard/system-message.ts
Normal file
11
services/web/types/project/dashboard/system-message.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
export type SystemMessage = {
|
||||
_id: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export type SuggestedLanguage = {
|
||||
url: string
|
||||
imgUrl: string
|
||||
lngCode: string
|
||||
lngName: string
|
||||
}
|
Loading…
Reference in a new issue