diff --git a/services/web/app/views/project/list-react.pug b/services/web/app/views/project/list-react.pug index aa8fa4817c..b52e2839fc 100644 --- a/services/web/app/views/project/list-react.pug +++ b/services/web/app/views/project/list-react.pug @@ -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 diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index ec738454ee..b3a072a4e7 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -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": "", diff --git a/services/web/frontend/js/features/project-list/components/notifications/close.tsx b/services/web/frontend/js/features/project-list/components/notifications/close.tsx index 32eafd8cd8..d124a03bd9 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/close.tsx +++ b/services/web/frontend/js/features/project-list/components/notifications/close.tsx @@ -1,20 +1,17 @@ import { useTranslation } from 'react-i18next' -import classnames from 'classnames' type CloseProps = { - onDismiss: () => void -} & React.ComponentProps<'div'> + onDismiss: React.MouseEventHandler +} -function Close({ onDismiss, className, ...props }: CloseProps) { +function Close({ onDismiss }: CloseProps) { const { t } = useTranslation() return ( -
- -
+ ) } diff --git a/services/web/frontend/js/features/project-list/components/notifications/notification.tsx b/services/web/frontend/js/features/project-list/components/notifications/notification.tsx index 840a28642d..b60c543b3f 100644 --- a/services/web/frontend/js/features/project-list/components/notifications/notification.tsx +++ b/services/web/frontend/js/features/project-list/components/notifications/notification.tsx @@ -37,7 +37,11 @@ function Notification({
  • {children} - {onDismiss ? : null} + {onDismiss ? ( +
    + +
    + ) : null}
  • ) diff --git a/services/web/frontend/js/features/project-list/components/notifications/system-message.tsx b/services/web/frontend/js/features/project-list/components/notifications/system-message.tsx new file mode 100644 index 0000000000..40036cf2ca --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/system-message.tsx @@ -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 ( +
  • + {id !== 'protected' ? setHidden(true)} /> : null} + {children} +
  • + ) +} + +export default SystemMessage diff --git a/services/web/frontend/js/features/project-list/components/notifications/system-messages.tsx b/services/web/frontend/js/features/project-list/components/notifications/system-messages.tsx new file mode 100644 index 0000000000..f33fae3dfa --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/system-messages.tsx @@ -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() + 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 ( + + ) +} + +export default SystemMessages diff --git a/services/web/frontend/js/features/project-list/components/notifications/translation-message.tsx b/services/web/frontend/js/features/project-list/components/notifications/translation-message.tsx new file mode 100644 index 0000000000..38c21c4f4b --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/notifications/translation-message.tsx @@ -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 ( +
  • + setHidden(true)} /> +
    + + ]} // eslint-disable-line react/jsx-key + values={{ lngName: config.lngName }} + /> + {t('country_flag', + +
    +
  • + ) +} + +export default TranslationMessage diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx index 34cc546d1d..0396f71749 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-root.tsx @@ -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() { ) : ( -
    - {totalProjectsCount > 0 ? ( - <> -
    -
    - -
    -
    -
    - {error ? : ''} - - - - - - - - - - -
    -
    - {selectedProjects.length === 0 ? ( - - ) : ( - - )} -
    -
    - -
    -
    - -
    -
    -
    - - + <> + +
    + {totalProjectsCount > 0 ? ( + <> +
    +
    +
    - - -
    -
    -
    - - -
    -
    - -
    - -
    - - - - - -
    -
    -
    - -
    -
    - - ) : ( -
    - {error ? : ''} - - +
    + {error ? : ''} - - - -
    - )} -
    + + + + + +
    +
    + {selectedProjects.length === 0 ? ( + + ) : ( + + )} +
    +
    + +
    +
    + +
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    + +
    + +
    + + + + + +
    +
    +
    + +
    +
    + + ) : ( +
    + {error ? : ''} + + + + + + + + + + +
    + )} +
    + ) } diff --git a/services/web/frontend/stories/project-list/system-messages.stories.tsx b/services/web/frontend/stories/project-list/system-messages.stories.tsx new file mode 100644 index 0000000000..5280ef40b0 --- /dev/null +++ b/services/web/frontend/stories/project-list/system-messages.stories.tsx @@ -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 +} + +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 +} + +export default { + title: 'Project List / System Messages', + component: SystemMessages, +} diff --git a/services/web/frontend/stylesheets/app/base.less b/services/web/frontend/stylesheets/app/base.less index 3d3330d081..3bb8925191 100644 --- a/services/web/frontend/stylesheets/app/base.less +++ b/services/web/frontend/stylesheets/app/base.less @@ -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; diff --git a/services/web/locales/en.json b/services/web/locales/en.json index af20347ec5..a71d3cd8a4 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -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", diff --git a/services/web/test/frontend/features/project-list/components/system-messages.test.tsx b/services/web/test/frontend/features/project-list/components/system-messages.test.tsx new file mode 100644 index 0000000000..73cb8667b7 --- /dev/null +++ b/services/web/test/frontend/features/project-list/components/system-messages.test.tsx @@ -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('', 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() + + 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() + + 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() + + 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') + }) +}) diff --git a/services/web/types/project/dashboard/system-message.ts b/services/web/types/project/dashboard/system-message.ts new file mode 100644 index 0000000000..a688e4947d --- /dev/null +++ b/services/web/types/project/dashboard/system-message.ts @@ -0,0 +1,11 @@ +export type SystemMessage = { + _id: string + content: string +} + +export type SuggestedLanguage = { + url: string + imgUrl: string + lngCode: string + lngName: string +}