diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index af588dfc0c..5b25d8da85 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/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, diff --git a/services/web/app/src/Features/Spelling/SpellingController.js b/services/web/app/src/Features/Spelling/SpellingController.js index 84efab4f55..6c0dc15e89 100644 --- a/services/web/app/src/Features/Spelling/SpellingController.js +++ b/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 diff --git a/services/web/app/src/router.js b/services/web/app/src/router.js index 2ba11e92a5..349e57957b 100644 --- a/services/web/app/src/router.js +++ b/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, diff --git a/services/web/app/views/project/editor/left-menu.pug b/services/web/app/views/project/editor/left-menu.pug index 679268db88..953e390692 100644 --- a/services/web/app/views/project/editor/left-menu.pug +++ b/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( diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 4ca762c2f4..eaf18b62b8 100644 --- a/services/web/frontend/extracted-translations.json +++ b/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": "", diff --git a/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx b/services/web/frontend/js/features/dictionary/components/dictionary-modal-content.tsx new file mode 100644 index 0000000000..44c6c05fb3 --- /dev/null +++ b/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 ( + <> + + {t('edit_dictionary')} + + + + {isError ? ( + {t('generic_something_went_wrong')} + ) : null} + + {ignoredWords.learnedWords?.size > 0 ? ( + + ) : ( + {t('edit_dictionary_empty')} + )} + + + + + + + ) +} diff --git a/services/web/frontend/js/features/dictionary/components/dictionary-modal.tsx b/services/web/frontend/js/features/dictionary/components/dictionary-modal.tsx new file mode 100644 index 0000000000..b5e6096732 --- /dev/null +++ b/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 ( + + + + ) +} + +export default withErrorBoundary(DictionaryModal) diff --git a/services/web/frontend/js/features/dictionary/controllers/modal-controller.js b/services/web/frontend/js/features/dictionary/controllers/modal-controller.js new file mode 100644 index 0000000000..83236b2591 --- /dev/null +++ b/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']) +) diff --git a/services/web/frontend/js/features/dictionary/ignored-words.ts b/services/web/frontend/js/features/dictionary/ignored-words.ts index 3f3a846f28..0d7156069d 100644 --- a/services/web/frontend/js/features/dictionary/ignored-words.ts +++ b/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) diff --git a/services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js b/services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js index bba602de62..a8c9f0e609 100644 --- a/services/web/frontend/js/ide/editor/directives/aceEditor/spell-check/SpellCheckManager.js +++ b/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) diff --git a/services/web/frontend/js/ide/settings/index.js b/services/web/frontend/js/ide/settings/index.js index c096ffbbca..ed0c5699e7 100644 --- a/services/web/frontend/js/ide/settings/index.js +++ b/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' diff --git a/services/web/frontend/stylesheets/app/editor/left-menu.less b/services/web/frontend/stylesheets/app/editor/left-menu.less index 1817892f75..906f1d61de 100644 --- a/services/web/frontend/stylesheets/app/editor/left-menu.less +++ b/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; + } +} diff --git a/services/web/locales/en.json b/services/web/locales/en.json index cafee760cd..abd6a22f3b 100644 --- a/services/web/locales/en.json +++ b/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.", diff --git a/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.js b/services/web/test/frontend/features/dictionary/components/dictionary-modal-content.test.js new file mode 100644 index 0000000000..437eadfd09 --- /dev/null +++ b/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('', 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( {}} />) + screen.getByText('foo') + screen.getByText('bar') + }) + + it('shows message when empty', async function () { + setLearnedWords([]) + renderWithEditorContext( {}} />) + screen.getByText('Your custom dictionary is empty.') + }) + + it('removes words', async function () { + fetchMock.post('/spelling/unlearn', 200) + setLearnedWords(['foo', 'bar']) + renderWithEditorContext( {}} />) + 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( {}} />) + 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.') + }) +})