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) {
|
||||
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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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": "",
|
||||
|
|
|
@ -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() {
|
||||
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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -2,3 +2,4 @@
|
|||
// Fix any style issues and re-enable lint.
|
||||
import './services/settings'
|
||||
import './controllers/SettingsController'
|
||||
import '../../features/dictionary/controllers/modal-controller'
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.",
|
||||
|
|
|
@ -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