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

Add UI to Remove Words from Dictionary

GitOrigin-RevId: a28d865e3c968d6fff113237fcf4143b77af046e
This commit is contained in:
Miguel Serrano 2022-05-24 13:21:25 +02:00 committed by Copybot
parent 5676ec2eed
commit 430b7528b2
14 changed files with 281 additions and 1 deletions

View file

@ -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) { persistentUpgradePromptsAssignment(cb) {
SplitTestHandler.getAssignment( SplitTestHandler.getAssignment(
req, req,
@ -923,6 +939,7 @@ const ProjectController = {
newSourceEditorAssignment, newSourceEditorAssignment,
pdfDetachAssignment, pdfDetachAssignment,
pdfjsAssignment, pdfjsAssignment,
dictionaryEditorAssignment,
persistentUpgradePromptsAssignment, persistentUpgradePromptsAssignment,
} }
) => { ) => {
@ -1029,6 +1046,11 @@ const ProjectController = {
!Features.hasFeature('saas') || !Features.hasFeature('saas') ||
(user.features && user.features.symbolPalette) (user.features && user.features.symbolPalette)
const dictionaryEditorEnabled = shouldDisplayFeature(
'dictionary-editor',
dictionaryEditorAssignment.variant === 'enabled'
)
// Persistent upgrade prompts // Persistent upgrade prompts
const showHeaderUpgradePrompt = const showHeaderUpgradePrompt =
persistentUpgradePromptsAssignment.variant === persistentUpgradePromptsAssignment.variant ===
@ -1098,6 +1120,7 @@ const ProjectController = {
wsUrl, wsUrl,
showSupport: Features.hasFeature('support'), showSupport: Features.hasFeature('support'),
pdfjsVariant: pdfjsAssignment.variant, pdfjsVariant: pdfjsAssignment.variant,
dictionaryEditorEnabled,
showPdfDetach, showPdfDetach,
debugPdfDetach, debugPdfDetach,
showNewSourceEditorOption, showNewSourceEditorOption,

View file

@ -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) { proxyRequestToSpellingApi(req, res) {
const { language } = req.body const { language } = req.body

View file

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

View file

@ -117,6 +117,16 @@ aside#left-menu.full-size(
value=language.code value=language.code
)= language.name )= 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 .form-controls
label(for="autoComplete") #{translate("auto_complete")} label(for="autoComplete") #{translate("auto_complete")}
select( select(

View file

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

View file

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

View file

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

View file

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

View file

@ -8,10 +8,12 @@ export class IgnoredWords {
constructor() { constructor() {
this.reset() this.reset()
this.ignoredMisspellings = new Set(IGNORED_MISSPELLINGS) this.ignoredMisspellings = new Set(IGNORED_MISSPELLINGS)
window.addEventListener('learnedWords:doreset', () => this.reset()) // for tests
} }
reset() { reset() {
this.learnedWords = new Set(getMeta('ol-learnedWords')) this.learnedWords = new Set(getMeta('ol-learnedWords'))
window.dispatchEvent(new CustomEvent('learnedWords:reset'))
} }
add(wordText) { 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) { has(wordText) {
return ( return (
this.ignoredMisspellings.has(wordText) || this.learnedWords.has(wordText) this.ignoredMisspellings.has(wordText) || this.learnedWords.has(wordText)

View file

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

View file

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

View file

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

View file

@ -287,6 +287,10 @@
"make_primary": "Make Primary", "make_primary": "Make Primary",
"make_email_primary_description": "Make this the primary email, used to log in", "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.", "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", "unarchive": "Restore",
"cant_see_what_youre_looking_for_question": "Cant see what youre looking for?", "cant_see_what_youre_looking_for_question": "Cant see what youre looking for?",
"something_went_wrong_canceling_your_subscription": "Something went wrong canceling your subscription. Please contact support.", "something_went_wrong_canceling_your_subscription": "Something went wrong canceling your subscription. Please contact support.",

View file

@ -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.')
})
})