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

Merge pull request #8073 from overleaf/ta-td-dictionary

Add UI to Remove Words from Dictionary

GitOrigin-RevId: a28d865e3c968d6fff113237fcf4143b77af046e
Miguel Serrano 4 лет назад
Родитель
Сommit
430b7528b2

+ 23 - 0
services/web/app/src/Features/Project/ProjectController.js

@@ -893,6 +893,22 @@ const ProjectController = {
             }
           )
         },
+        dictionaryEditorAssignment(cb) {
+          SplitTestHandler.getAssignment(
+            req,
+            res,
+            'dictionary-editor',
+            {},
+            (error, assignment) => {
+              // do not fail editor load if assignment fails
+              if (error) {
+                cb(null, { variant: 'default' })
+              } else {
+                cb(null, assignment)
+              }
+            }
+          )
+        },
         persistentUpgradePromptsAssignment(cb) {
           SplitTestHandler.getAssignment(
             req,
@@ -923,6 +939,7 @@ const ProjectController = {
           newSourceEditorAssignment,
           pdfDetachAssignment,
           pdfjsAssignment,
+          dictionaryEditorAssignment,
           persistentUpgradePromptsAssignment,
         }
       ) => {
@@ -1029,6 +1046,11 @@ const ProjectController = {
               !Features.hasFeature('saas') ||
               (user.features && user.features.symbolPalette)
 
+            const dictionaryEditorEnabled = shouldDisplayFeature(
+              'dictionary-editor',
+              dictionaryEditorAssignment.variant === 'enabled'
+            )
+
             // Persistent upgrade prompts
             const showHeaderUpgradePrompt =
               persistentUpgradePromptsAssignment.variant ===
@@ -1098,6 +1120,7 @@ const ProjectController = {
               wsUrl,
               showSupport: Features.hasFeature('support'),
               pdfjsVariant: pdfjsAssignment.variant,
+              dictionaryEditorEnabled,
               showPdfDetach,
               debugPdfDetach,
               showNewSourceEditorOption,

+ 9 - 0
services/web/app/src/Features/Spelling/SpellingController.js

@@ -19,6 +19,15 @@ module.exports = {
     })
   },
 
+  unlearn(req, res, next) {
+    const { word } = req.body
+    const userId = SessionManager.getLoggedInUserId(req.session)
+    LearnedWordsManager.unlearnWord(userId, word, err => {
+      if (err) return next(err)
+      res.sendStatus(204)
+    })
+  },
+
   proxyRequestToSpellingApi(req, res) {
     const { language } = req.body
 

+ 11 - 0
services/web/app/src/router.js

@@ -932,6 +932,17 @@ function initialize(webRouter, privateApiRouter, publicApiRouter) {
     SpellingController.learn
   )
 
+  webRouter.post(
+    '/spelling/unlearn',
+    validate({
+      body: Joi.object({
+        word: Joi.string().required(),
+      }),
+    }),
+    AuthenticationController.requireLogin(),
+    SpellingController.unlearn
+  )
+
   webRouter.get(
     '/project/:project_id/messages',
     AuthorizationMiddleware.blockRestrictedUserFromProject,

+ 10 - 0
services/web/app/views/project/editor/left-menu.pug

@@ -117,6 +117,16 @@ aside#left-menu.full-size(
 								value=language.code
 							)= language.name
 
+			if dictionaryEditorEnabled
+				.form-controls(ng-controller="DictionaryModalController")
+					label #{translate("dictionary")}
+					button.btn.btn-default.btn-sm(ng-click="openModal()") #{translate("edit")}
+
+					dictionary-modal(
+						handle-hide="handleHide"
+						show="show"
+					)
+
 			.form-controls
 				label(for="autoComplete") #{translate("auto_complete")}
 				select(

+ 3 - 0
services/web/frontend/extracted-translations.json

@@ -105,6 +105,9 @@
   "dropbox_synced": "",
   "duplicate_file": "",
   "easily_manage_your_project_files_everywhere": "",
+  "edit_dictionary_empty": "",
+  "edit_dictionary_remove": "",
+  "edit_dictionary": "",
   "editing": "",
   "editor_and_pdf": "",
   "editor_only_hide_pdf": "",

+ 78 - 0
services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx

@@ -0,0 +1,78 @@
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
+import { Alert, Button, Modal } from 'react-bootstrap'
+import Icon from '../../../shared/components/icon'
+import Tooltip from '../../../shared/components/tooltip'
+import useAsync from '../../../shared/hooks/use-async'
+import { postJSON } from '../../../infrastructure/fetch-json'
+import ignoredWords from '../ignored-words'
+
+type DictionaryModalContentProps = {
+  handleHide: () => void
+}
+
+export default function DictionaryModalContent({
+  handleHide,
+}: DictionaryModalContentProps) {
+  const { t } = useTranslation()
+
+  const { isError, runAsync } = useAsync()
+
+  const handleRemove = useCallback(
+    word => {
+      ignoredWords.remove(word)
+      runAsync(
+        postJSON('/spelling/unlearn', {
+          body: {
+            word,
+          },
+        })
+      ).catch(console.error)
+    },
+    [runAsync]
+  )
+
+  return (
+    <>
+      <Modal.Header closeButton>
+        <Modal.Title>{t('edit_dictionary')}</Modal.Title>
+      </Modal.Header>
+
+      <Modal.Body>
+        {isError ? (
+          <Alert bsStyle="danger">{t('generic_something_went_wrong')}</Alert>
+        ) : null}
+
+        {ignoredWords.learnedWords?.size > 0 ? (
+          <ul className="list-unstyled">
+            {[...ignoredWords.learnedWords].sort().map(learnedWord => (
+              <li key={learnedWord}>
+                <Tooltip
+                  id={`tooltip-remove-learned-word-${learnedWord}`}
+                  description={t('edit_dictionary_remove')}
+                >
+                  <Button
+                    className="btn-link action-btn"
+                    onClick={() => handleRemove(learnedWord)}
+                  >
+                    <Icon
+                      type="trash-o"
+                      accessibilityLabel={t('edit_dictionary_remove')}
+                    />
+                  </Button>
+                </Tooltip>
+                {learnedWord}
+              </li>
+            ))}
+          </ul>
+        ) : (
+          <i>{t('edit_dictionary_empty')}</i>
+        )}
+      </Modal.Body>
+
+      <Modal.Footer>
+        <Button onClick={handleHide}>{t('done')}</Button>
+      </Modal.Footer>
+    </>
+  )
+}

+ 25 - 0
services/web/frontend/js/features/dictionary/components/dictionary-modal.tsx

@@ -0,0 +1,25 @@
+import React from 'react'
+import DictionaryModalContent from './dictionary-modal-content'
+import AccessibleModal from '../../../shared/components/accessible-modal'
+import withErrorBoundary from '../../../infrastructure/error-boundary'
+
+type DictionaryModalProps = {
+  show?: boolean
+  handleHide: () => void
+}
+
+function DictionaryModal({ show, handleHide }: DictionaryModalProps) {
+  return (
+    <AccessibleModal
+      animation
+      show={show}
+      onHide={handleHide}
+      id="dictionary-modal"
+      bsSize="small"
+    >
+      <DictionaryModalContent handleHide={handleHide} />
+    </AccessibleModal>
+  )
+}
+
+export default withErrorBoundary(DictionaryModal)

+ 26 - 0
services/web/frontend/js/features/dictionary/controllers/modal-controller.js

@@ -0,0 +1,26 @@
+import App from '../../../base'
+import { react2angular } from 'react2angular'
+import DictionaryModal from '../components/dictionary-modal'
+import { rootContext } from '../../../shared/context/root-context'
+
+export default App.controller('DictionaryModalController', function ($scope) {
+  $scope.show = false
+
+  $scope.handleHide = () => {
+    $scope.$applyAsync(() => {
+      $scope.show = false
+      window.dispatchEvent(new CustomEvent('learnedWords:reset'))
+    })
+  }
+
+  $scope.openModal = () => {
+    $scope.$applyAsync(() => {
+      $scope.show = true
+    })
+  }
+})
+
+App.component(
+  'dictionaryModal',
+  react2angular(rootContext.use(DictionaryModal), ['show', 'handleHide'])
+)

+ 9 - 0
services/web/frontend/js/features/dictionary/ignored-words.ts

@@ -8,10 +8,12 @@ export class IgnoredWords {
   constructor() {
     this.reset()
     this.ignoredMisspellings = new Set(IGNORED_MISSPELLINGS)
+    window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests
   }
 
   reset() {
     this.learnedWords = new Set(getMeta('ol-learnedWords'))
+    window.dispatchEvent(new CustomEvent('learnedWords:reset'))
   }
 
   add(wordText) {
@@ -21,6 +23,13 @@ export class IgnoredWords {
     )
   }
 
+  remove(wordText) {
+    this.learnedWords.delete(wordText)
+    window.dispatchEvent(
+      new CustomEvent('learnedWords:remove', { detail: wordText })
+    )
+  }
+
   has(wordText) {
     return (
       this.ignoredMisspellings.has(wordText) || this.learnedWords.has(wordText)

+ 16 - 1
services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js

@@ -61,6 +61,12 @@ class SpellCheckManager {
 
     this.selectedHighlightContents = null
 
+    window.addEventListener('learnedWords:reset', () => this.reset())
+
+    window.addEventListener('learnedWords:remove', ({ detail: word }) =>
+      this.removeWordFromCache(word)
+    )
+
     $(document).on('click', e => {
       // There is a bug (?) in Safari when ctrl-clicking an element, and the
       // the contextmenu event is preventDefault-ed. In this case, the
@@ -101,11 +107,15 @@ class SpellCheckManager {
     }
   }
 
-  reInitForLangChange() {
+  reset() {
     this.adapter.highlightedWordManager.reset()
     this.init()
   }
 
+  reInitForLangChange() {
+    this.reset()
+  }
+
   isSpellCheckEnabled() {
     return !!(
       this.$scope.spellCheck &&
@@ -188,6 +198,11 @@ class SpellCheckManager {
     }
   }
 
+  removeWordFromCache(word) {
+    const language = this.$scope.spellCheckLanguage
+    this.cache.remove(`${language}:${word}`)
+  }
+
   learnWord(highlight) {
     this.apiRequest('/learn', { word: highlight.word })
     this.adapter.highlightedWordManager.removeWord(highlight.word)

+ 1 - 0
services/web/frontend/js/ide/settings/index.js

@@ -2,3 +2,4 @@
 // Fix any style issues and re-enable lint.
 import './services/settings'
 import './controllers/SettingsController'
+import '../../features/dictionary/controllers/modal-controller'

+ 7 - 0
services/web/frontend/stylesheets/app/editor/left-menu.less

@@ -82,6 +82,7 @@
       padding-right: 5px;
       white-space: nowrap;
     }
+    button,
     select {
       width: 50%;
       margin: 9px 0;
@@ -125,3 +126,9 @@
   background-color: #999;
   z-index: 99;
 }
+
+#dictionary-modal {
+  li {
+    word-break: break-all;
+  }
+}

+ 4 - 0
services/web/locales/en.json

@@ -287,6 +287,10 @@
   "make_primary": "Make Primary",
   "make_email_primary_description": "Make this the primary email, used to log in",
   "github_sync_repository_not_found_description": "The linked repository has either been removed, or you no longer have access to it. You can set up sync with a new repository by cloning the project and using the ‘GitHub’ menu item. You can also unlink the repository from this project.",
+  "dictionary": "Dictionary",
+  "edit_dictionary": "Edit Dictionary",
+  "edit_dictionary_empty": "Your custom dictionary is empty.",
+  "edit_dictionary_remove": "Remove from dictionary",
   "unarchive": "Restore",
   "cant_see_what_youre_looking_for_question": "Can’t see what you’re looking for?",
   "something_went_wrong_canceling_your_subscription": "Something went wrong canceling your subscription. Please contact support.",

+ 59 - 0
services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.js

@@ -0,0 +1,59 @@
+import { screen, fireEvent } from '@testing-library/react'
+import { expect } from 'chai'
+import fetchMock from 'fetch-mock'
+import DictionaryModal from '../../../../../frontend/js/features/dictionary/components/dictionary-modal'
+import { renderWithEditorContext } from '../../../helpers/render-with-context'
+
+function setLearnedWords(words) {
+  window.metaAttributesCache.set('ol-learnedWords', words)
+  window.dispatchEvent(new CustomEvent('learnedWords:doreset'))
+}
+describe('<DictionaryModalContent />', function () {
+  beforeEach(function () {
+    window.metaAttributesCache = window.metaAttributesCache || new Map()
+  })
+
+  afterEach(function () {
+    window.metaAttributesCache = new Map()
+    fetchMock.reset()
+  })
+
+  it('list words', async function () {
+    setLearnedWords(['foo', 'bar'])
+    renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
+    screen.getByText('foo')
+    screen.getByText('bar')
+  })
+
+  it('shows message when empty', async function () {
+    setLearnedWords([])
+    renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
+    screen.getByText('Your custom dictionary is empty.')
+  })
+
+  it('removes words', async function () {
+    fetchMock.post('/spelling/unlearn', 200)
+    setLearnedWords(['foo', 'bar'])
+    renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
+    screen.getByText('bar')
+    const [firstButton] = screen.getAllByRole('button', {
+      name: 'Remove from dictionary',
+    })
+    fireEvent.click(firstButton)
+    expect(screen.queryByText('bar')).to.not.exist
+    screen.getByText('foo')
+  })
+
+  it('handles errors', async function () {
+    fetchMock.post('/spelling/unlearn', 500)
+    setLearnedWords(['foo'])
+    renderWithEditorContext(<DictionaryModal show handleHide={() => {}} />)
+    const [firstButton] = screen.getAllByRole('button', {
+      name: 'Remove from dictionary',
+    })
+    fireEvent.click(firstButton)
+    await fetchMock.flush()
+    screen.getByText('Sorry, something went wrong')
+    screen.getByText('Your custom dictionary is empty.')
+  })
+})