mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #8073 from overleaf/ta-td-dictionary
Add UI to Remove Words from Dictionary GitOrigin-RevId: a28d865e3c968d6fff113237fcf4143b77af046e
This commit is contained in:
parent
5676ec2eed
commit
430b7528b2
14 changed files with 281 additions and 1 deletions
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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": "",
|
||||||
|
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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)
|
|
@ -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'])
|
||||||
|
)
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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": "Can’t see what you’re looking for?",
|
"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.",
|
"something_went_wrong_canceling_your_subscription": "Something went wrong canceling your subscription. Please contact support.",
|
||||||
|
|
|
@ -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.')
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue