Merge pull request #33450 from overleaf/worktree-mg-writefull-spelling-tab

Add writefull language suggestions section to Spelling and language tab

GitOrigin-RevId: 6195683ca175a4c3da25a7ab334a605c67db04b8
This commit is contained in:
Malik Glossop
2026-06-04 12:44:58 +02:00
committed by Copybot
parent fc31a88767
commit d25b032e16
11 changed files with 226 additions and 18 deletions
+2
View File
@@ -44,6 +44,7 @@ describe('editor', function () {
cy.log(`change project language to '${lng}'`)
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('tab', { name: 'Spelling and language' }).click()
cy.findByLabelText('Spellcheck language').select(lng)
})
cy.get('body').type('{esc}')
@@ -76,6 +77,7 @@ describe('editor', function () {
cy.log('remove word from dictionary')
cy.findByRole('button', { name: 'Settings' }).click()
cy.findByRole('dialog').within(() => {
cy.findByRole('tab', { name: 'Spelling and language' }).click()
cy.findByLabelText('Dictionary').click()
})
cy.findByTestId('dictionary-modal').within(() => {
+1
View File
@@ -1090,6 +1090,7 @@ module.exports = {
integrationPanelComponents: [],
referenceSearchSetting: [],
settingsModalEditorTabSections: [],
settingsModalSpellcheckSections: [],
errorLogsComponents: [],
referenceIndices: [],
railEntries: [],
@@ -133,6 +133,7 @@
"ai_feedback_the_suggestion_didnt_fix_the_error": "",
"ai_feedback_the_suggestion_wasnt_the_best_fix_available": "",
"ai_feedback_there_was_no_code_fix_suggested": "",
"ai_language_suggestions": "",
"ai_shortcut_on_empty_lines": "",
"ai_shortcut_on_text_selection": "",
"alignment": "",
@@ -216,6 +217,7 @@
"binary_history_error": "",
"blank_project": "",
"blocked_filename": "",
"blocked_language_suggestions": "",
"blog": "",
"bold": "",
"booktabs": "",
@@ -413,6 +415,8 @@
"currently_subscribed_to_plan": "",
"custom": "",
"custom_borders": "",
"custom_prompt": "",
"custom_prompt_example": "",
"customer_stories": "",
"customize_your_group_subscription": "",
"customizing_figures": "",
@@ -613,6 +617,9 @@
"enabling": "",
"end_of_document": "",
"end_time_utc": "",
"english__preference_for_ai_suggestion": "",
"english_american": "",
"english_british": "",
"ensure_recover_account": "",
"enter_any_size_including_units_or_valid_latex_command": "",
"enter_emails_separated_by_commas": "",
@@ -1042,6 +1049,13 @@
"labs_ongoing_experiments": "",
"labs_settings": "",
"language": "",
"language_model_gpt_any_language": "",
"language_model_gpt_any_language_description": "",
"language_model_gpt_custom_prompt": "",
"language_model_gpt_custom_prompt_description": "",
"language_model_writefull_english": "",
"language_model_writefull_english_description": "",
"language_suggestions": "",
"last_active": "",
"last_active_description": "",
"last_edit": "",
@@ -1205,6 +1219,7 @@
"missing_field_for_entry": "",
"missing_fields_for_entry": "",
"missing_payment_details": "",
"model": "",
"money_back_guarantee": "",
"month": "",
"month_plural": "",
@@ -1857,6 +1872,7 @@
"speech_input_not_available": "",
"spellcheck": "",
"spellcheck_language": "",
"spelling_and_language": "",
"split_view": "",
"sso": "",
"sso_active": "",
@@ -64,6 +64,7 @@ export default /** @type {const} */ ([
'shuffle',
'smart_toy',
'space_dashboard',
'spellcheck',
'star',
'strikethrough_s',
'table_chart',
@@ -48,6 +48,11 @@ const editorTabExtraSectionHooks: SettingsSectionHook[] = importOverleafModules(
.map((m: any) => m?.import?.default)
.filter((h: unknown): h is SettingsSectionHook => typeof h === 'function')
const spellcheckExtraSectionHooks: SettingsSectionHook[] =
importOverleafModules('settingsModalSpellcheckSections')
.map((m: any) => m?.import?.default)
.filter((h: unknown): h is SettingsSectionHook => typeof h === 'function')
const useSlotSections = (hooks: SettingsSectionHook[]): SettingsSection[] =>
hooks.map(hook => hook()).filter((s): s is SettingsSection => s != null)
@@ -78,6 +83,7 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
const hasEditorTabs = useFeatureFlag('editor-tabs')
const editorTabExtraSections = useSlotSections(editorTabExtraSectionHooks)
const spellcheckExtraSections = useSlotSections(spellcheckExtraSectionHooks)
const allSettingsTabs: SettingsEntry[] = useMemo(
() => [
@@ -125,20 +131,6 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
},
],
},
{
key: 'spellcheck',
title: t('spellcheck'),
settings: [
{
key: 'spellCheckLanguage',
component: <SpellCheckSetting />,
},
{
key: 'dictionary-settings',
component: <DictionarySetting />,
},
],
},
{
key: 'tools',
title: t('tools'),
@@ -156,6 +148,28 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
...editorTabExtraSections,
],
},
{
key: 'spelling_and_language',
title: t('spelling_and_language'),
icon: 'spellcheck',
sections: [
{
key: 'spellcheck',
title: t('spellcheck'),
settings: [
{
key: 'spellCheckLanguage',
component: <SpellCheckSetting />,
},
{
key: 'dictionary-settings',
component: <DictionarySetting />,
},
],
},
...spellcheckExtraSections,
],
},
{
key: 'compiler',
title: t('compiler'),
@@ -269,6 +283,7 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
hasEmailNotifications,
isOverleaf,
editorTabExtraSections,
spellcheckExtraSections,
]
)
@@ -5,6 +5,7 @@ import {
useCallback,
ReactNode,
useState,
useId,
} from 'react'
import classNames from 'classnames'
import { useSelect } from 'downshift'
@@ -14,12 +15,47 @@ import FormControl from '@/shared/components/form/form-control'
import MaterialIcon from '@/shared/components/material-icon'
import { CaretUp, CaretDown, Check } from '@phosphor-icons/react'
import { DropdownItem } from '@/shared/components/dropdown/dropdown-menu'
import OLOverlay from '@/shared/components/ol/ol-overlay'
import OLSpinner from './ol/ol-spinner'
import DSFormLabel from '@/shared/components/ds/ds-form-label'
import DSFormGroup from '@/shared/components/ds/ds-form-group'
import DSFormControl from '@/shared/components/ds/ds-form-control'
import { DropdownItemProps } from '@/shared/components/types/dropdown-menu-props'
function SelectMenuPopover({
show,
target,
onHide,
children,
}: {
show: boolean
target: HTMLElement | null
onHide: () => void
children: ReactNode
}) {
const id = useId()
return (
<OLOverlay
show={show}
target={target}
placement="bottom-start"
rootClose
onHide={onHide}
>
{({ ref, style }) => (
<div
id={`select-popover-${id}`}
ref={ref}
style={{ ...style, width: target?.offsetWidth }}
className="select-portal-popover"
>
{children}
</div>
)}
</OLOverlay>
)
}
export type SelectProps<T> = {
// The items rendered as dropdown options.
items: T[]
@@ -63,6 +99,11 @@ export type SelectProps<T> = {
// CIAM-specific layout
isCiam?: boolean
size?: React.ComponentProps<typeof FormControl>['size']
// Renders the menu in a portal so it escapes overflow-clipping ancestors.
portal?: boolean
// Optional id for the toggle button element. When provided, enables association
// with an external <label htmlFor="...">
id?: string
}
export const Select = <T,>({
@@ -85,7 +126,10 @@ export const Select = <T,>({
dataTestId,
isCiam,
size,
portal = false,
id,
}: SelectProps<T>) => {
const toggleButtonId = id ? { id } : {}
const [selectedItem, setSelectedItem] = useState<T | undefined | null>(
defaultItem
)
@@ -164,13 +208,14 @@ export const Select = <T,>({
return isCiam ? <Check /> : 'check'
}
const dropdown = (
const menu = (
<ul
{...getMenuProps({ disabled })}
{...getMenuProps({ disabled }, { suppressRefError: portal })}
className={classNames('dropdown-menu', {
'w-100': !isCiam,
'ciam-dropdown-menu': isCiam,
show: isOpen,
'select-portal-menu': portal,
})}
>
{isOpen &&
@@ -208,6 +253,18 @@ export const Select = <T,>({
</ul>
)
const dropdown = portal ? (
<SelectMenuPopover
show={isOpen}
target={rootRef.current}
onHide={closeMenu}
>
{menu}
</SelectMenuPopover>
) : (
menu
)
if (isCiam) {
return (
<div className="select-wrapper" ref={rootRef}>
@@ -224,6 +281,7 @@ export const Select = <T,>({
disabled,
onKeyDown,
className: 'select-trigger',
...toggleButtonId,
})}
value={value}
readOnly
@@ -252,6 +310,7 @@ export const Select = <T,>({
disabled,
onKeyDown,
className: 'select-trigger',
...toggleButtonId,
})}
value={value}
readOnly
@@ -17,3 +17,12 @@
.select-highlighted {
background-color: var(--select-background-highlighted);
}
.select-portal-popover {
z-index: calc(#{$zindex-modal} + 1);
}
.select-portal-menu {
position: static;
width: 100%;
}
+17
View File
@@ -167,6 +167,7 @@
"ai_feedback_the_suggestion_didnt_fix_the_error": "The suggestion didnt fix the error",
"ai_feedback_the_suggestion_wasnt_the_best_fix_available": "The suggestion wasnt the best fix available",
"ai_feedback_there_was_no_code_fix_suggested": "There was no code fix suggested",
"ai_language_suggestions": "AI language suggestions",
"ai_shortcut_on_empty_lines": "AI shortcut on empty lines",
"ai_shortcut_on_text_selection": "AI shortcut on text selection",
"ai_usage": "AI usage",
@@ -291,6 +292,7 @@
"binary_history_error": "Preview not available for this file type",
"blank_project": "Blank project",
"blocked_filename": "This file name is blocked.",
"blocked_language_suggestions": "Blocked language suggestions",
"blog": "Blog",
"bold": "Bold",
"booktabs": "Booktabs",
@@ -555,6 +557,8 @@
"currently_subscribed_to_plan": "You are currently subscribed to the <0>__planName__</0> plan.",
"custom": "Custom",
"custom_borders": "Custom borders",
"custom_prompt": "Custom prompt",
"custom_prompt_example": "For example, \"Correct the text to reduce the number of words while maintaining clarity.\"",
"customer_resource_portal": "Customer resource portal",
"customer_stories": "Customer stories",
"customize": "Customize",
@@ -798,6 +802,9 @@
"enabling": "Enabling",
"end_of_document": "End of document",
"end_time_utc": "End time (UTC)",
"english__preference_for_ai_suggestion": "English preference for AI suggestions",
"english_american": "English (American)",
"english_british": "English (British)",
"ensure_recover_account": "This will ensure that it can be used to recover your __appName__ account in case you lose access to your primary email address.",
"enter_any_size_including_units_or_valid_latex_command": "Enter any size (including units) or valid LaTeX command",
"enter_emails_separated_by_commas": "Enter emails separated by commas",
@@ -1078,6 +1085,7 @@
"go_to_writefull": "Go to Writefull",
"good_news_you_already_purchased_this_add_on": "Good news! You already have this add-on, so no need to pay again.",
"good_news_you_are_already_receiving_this_add_on_via_writefull": "Good news! You already have this add-on via your Writefull subscription. No need to pay again.",
"gpt": "GPT",
"great_for_getting_started": "Great for getting started",
"great_for_small_teams_and_departments": "Great for small teams and departments",
"group": "Group",
@@ -1357,6 +1365,13 @@
"labs_program_benefits": "By signing up for Overleaf Labs you can get your hands on in-development features and try them out as much as you like. All we ask in return is your honest feedback to help us develop and improve. Its important to note that features available in this program are still being tested and actively developed. This means they could change, be removed, or become part of a premium plan.",
"labs_settings": "Labs settings",
"language": "Language",
"language_model": "Language model",
"language_model_gpt_any_language": "GPT model, any language",
"language_model_gpt_any_language_description": "Using this model means your text is sent to OpenAIs servers and may be kept there for up to 30 days. It is not used to train OpenAIs models.",
"language_model_gpt_custom_prompt": "GPT model with custom prompt",
"language_model_gpt_custom_prompt_description": "Customize your edits with a prompt, no matter your language, text type, or editing needs.",
"language_model_writefull_english": "Writefull",
"language_model_writefull_english_description": "Our custom model for research writing. It doesnt store your data or train on your texts.",
"language_suggestions": "Language suggestions",
"larger_discounts_available": "Larger discounts available",
"larger_discounts_for_groups_of_20_plus": "Larger discounts for groups of 20+",
@@ -1580,6 +1595,7 @@
"missing_fields_for_entry": "Missing fields for",
"missing_payment_details": "Missing payment details",
"missing_required_sso_fields": "Missing required SSO fields in order to update configuration. Please check the SSO configuration and try again. Missing: __missingFields__.",
"model": "Model",
"money_back_guarantee": "14-day money back guarantee, hassle free",
"month": "month",
"month_plural": "months",
@@ -2418,6 +2434,7 @@
"speech_input_not_available": "Speech input is not yet available in this browser",
"spellcheck": "Spellcheck",
"spellcheck_language": "Spellcheck language",
"spelling_and_language": "Spelling and language",
"split_view": "Split view",
"sso": "SSO",
"sso_account_already_linked": "Account already linked to another __appName__ user",
@@ -40,6 +40,7 @@ function render(props: RenderProps) {
items={testData}
itemToString={x => String(x?.value)}
label={props.label}
id={props.id}
name="select_control"
defaultText={props.defaultText}
defaultItem={props.defaultItem}
@@ -52,6 +53,7 @@ function render(props: RenderProps) {
optionalLabel={props.optionalLabel}
loading={props.loading}
selectedIcon={props.selectedIcon}
portal={props.portal}
/>
<button type="submit">submit</button>
</form>
@@ -61,6 +63,38 @@ function render(props: RenderProps) {
describe('<Select />', function () {
describe('initial rendering', function () {
it('associates an external label when id is provided', function () {
cy.mount(
<div style={{ width: '300px' }}>
<label htmlFor="external-select">External label</label>
<Select
id="external-select"
items={testData}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
defaultText="Choose an item"
/>
</div>
)
cy.findByRole('combobox', { name: 'External label' }).should('exist')
})
it('does not associate an external label when id is not provided', function () {
cy.mount(
<div style={{ width: '300px' }}>
<label htmlFor="external-select">External label</label>
<Select
items={testData}
itemToString={x => String(x?.value)}
itemToKey={x => String(x.key)}
defaultText="Choose an item"
/>
</div>
)
cy.findByRole('combobox').should('have.value', 'Choose an item')
cy.findByRole('combobox', { name: 'External label' }).should('not.exist')
})
it('renders default text', function () {
render({ defaultText: 'Choose an item' })
cy.findByTestId('ol-spinner').should('not.exist')
@@ -135,6 +169,26 @@ describe('<Select />', function () {
cy.findByRole('option', { name: 'Demo item 2 Subtitle 2' })
cy.findByRole('option', { name: 'Demo item 3 Subtitle 3' })
})
it('renders dropdown inside a popover when portal is enabled', function () {
render({ defaultText: 'Choose an item', portal: true })
cy.findByRole('combobox').click()
cy.get('.select-portal-popover').should('exist')
cy.get('.select-portal-popover .select-portal-menu')
.should('exist')
.and('be.visible')
cy.findByRole('option', { name: 'Demo item 1' }).should('exist')
})
it('does not render a popover when portal is disabled', function () {
render({ defaultText: 'Choose an item', portal: false })
cy.findByRole('combobox').click()
cy.get('.select-portal-popover').should('not.exist')
cy.get('.select-portal-menu').should('not.exist')
cy.findByRole('option', { name: 'Demo item 1' }).should('exist')
})
})
describe('item selection', function () {
@@ -24,11 +24,10 @@ const TAB_SETTINGS = {
'Keybindings',
'PDF Viewer',
'Reference search',
'Spellcheck language',
'Dictionary',
'Breadcrumbs',
'Equation preview',
],
'Spelling and language': ['Spellcheck language', 'Dictionary'],
Compiler: [
'Main document',
'Compiler',
@@ -154,6 +153,22 @@ describe('<SettingsModal />', function () {
selectTab('Editor')
await waitFor(() => expect(screen.getByText('AI assistance')).to.exist)
})
it('shows the Language Suggestions section in the Spelling and language tab', async function () {
render(
<EditorProviders
rootFolder={[rootFolder as any]}
layoutContext={{ leftMenuShown: true }}
>
<SettingsModal />
</EditorProviders>
)
selectTab('Spelling and language')
await waitFor(
() => expect(screen.getByText('Language suggestions')).to.exist
)
})
})
describe('when a user does not have Writefull enabled', function () {
@@ -182,6 +197,25 @@ describe('<SettingsModal />', function () {
expect(screen.queryByText('AI assistance')).to.be.null
})
it('does not show the Language Suggestions section when ol-writefullEnabled is false', async function () {
window.metaAttributesCache.set('ol-writefullEnabled', false)
window.metaAttributesCache.set('ol-showAiFeatures', true)
render(
<EditorProviders
rootFolder={[rootFolder as any]}
layoutContext={{ leftMenuShown: true }}
>
<SettingsModal />
</EditorProviders>
)
selectTab('Spelling and language')
await waitFor(
() => expect(screen.getByLabelText('Spellcheck language')).to.exist
)
expect(screen.queryByText('Language suggestions')).to.be.null
})
it('does not show the AI assistance section when ol-showAiFeatures is false', async function () {
window.metaAttributesCache.set('ol-writefullEnabled', true)
window.metaAttributesCache.set('ol-showAiFeatures', false)