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:
@@ -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(() => {
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
BIN
Binary file not shown.
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -167,6 +167,7 @@
|
||||
"ai_feedback_the_suggestion_didnt_fix_the_error": "The suggestion didn’t fix the error",
|
||||
"ai_feedback_the_suggestion_wasnt_the_best_fix_available": "The suggestion wasn’t 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. It’s 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 OpenAI’s servers and may be kept there for up to 30 days. It is not used to train OpenAI’s 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 doesn’t 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)
|
||||
|
||||
Reference in New Issue
Block a user