Просмотр исходного кода

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

[web] Project dashboard system & translation message migration

GitOrigin-RevId: 0c723a3b526980e5c749da44ebe8a0a3edcc66ad
Alexandre Bourdin 4 лет назад
Родитель
Сommit
7a20e1376d

+ 6 - 0
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

+ 2 - 0
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": "",

+ 7 - 10
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<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>
   )
 }
 

+ 5 - 1
services/web/frontend/js/features/project-list/components/notifications/notification.tsx

@@ -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>
   )

+ 27 - 0
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 (
+    <li className="system-message">
+      {id !== 'protected' ? <Close onDismiss={() => setHidden(true)} /> : null}
+      {children}
+    </li>
+  )
+}
+
+export default SystemMessage

+ 54 - 0
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<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

+ 39 - 0
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 (
+    <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

+ 94 - 90
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() {
       <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>
+    <>
+      <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>
-          </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 ? (
+            <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 />
-                    ) : (
-                      <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>
-              </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' }}
-                      />
+              <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>
-                  <ProjectListTable />
-                </div>
-              </Col>
-            </Row>
+                </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={12}>
-                <LoadMore />
+              <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 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>
+        )}
+      </div>
+    </>
   )
 }
 

+ 44 - 0
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 <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,
+}

+ 6 - 0
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;

+ 1 - 0
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",

+ 87 - 0
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('<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 - 0
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
+}