Merge pull request #35739 from overleaf/mfb-bring-main-bibliography-to-top-level

Bring main bibliography file to the top level settings

GitOrigin-RevId: 4c4d4927067995d947c6d0656425760ce56f9435
This commit is contained in:
Maria Florencia Besteiro Gonzalez
2026-09-17 11:36:48 +00:00
committed by Copybot
parent 6f8a1be171
commit 02d36687cd
22 changed files with 406 additions and 13 deletions
@@ -669,6 +669,24 @@ const EditorController = {
)
},
setReferenceFormat(projectId, newReferenceFormat, callback) {
ProjectOptionsHandler.setReferenceFormat(
projectId,
newReferenceFormat,
function (err) {
if (err) {
return callback(err)
}
EditorRealTimeController.emitToRoom(
projectId,
'referenceFormatUpdated',
newReferenceFormat
)
callback()
}
)
},
_notifyProjectUsersOfNewFolders(projectId, folders, callback) {
async.eachSeries(
folders,
@@ -732,5 +750,6 @@ EditorController.promises = {
setPublicAccessLevel: promisify(EditorController.setPublicAccessLevel),
setRootDoc: promisify(EditorController.setRootDoc),
setMainBibliographyDoc: promisify(EditorController.setMainBibliographyDoc),
setReferenceFormat: promisify(EditorController.setReferenceFormat),
}
export default EditorController
@@ -92,6 +92,7 @@ const updateProjectSettingsSchema = z.object({
name: z.string().optional(),
rootDocId: zz.objectId().optional(),
spellCheckLanguage: z.string().optional(),
referenceFormat: z.enum(['bibtex', 'biblatex']).optional(),
}),
})
@@ -144,6 +145,13 @@ const _ProjectController = {
)
}
if (body.referenceFormat != null) {
await EditorController.promises.setReferenceFormat(
projectId,
body.referenceFormat
)
}
res.sendStatus(204)
},
@@ -24,6 +24,7 @@ export default ProjectEditorHandler = {
compiler: project.compiler,
description: project.description,
spellCheckLanguage: project.spellCheckLanguage,
referenceFormat: project.referenceFormat,
png2pdf: project.png2pdf,
deletedByExternalDataSource: project.deletedByExternalDataSource || false,
imageName:
@@ -119,6 +119,15 @@ const ProjectOptionsHandler = {
const { otMigrationStage } = project.overleaf.history
return { otMigrationStage }
},
async setReferenceFormat(projectId, newReferenceFormat) {
if (!newReferenceFormat) {
return
}
const conditions = { _id: projectId }
const update = { referenceFormat: newReferenceFormat }
return Project.updateOne(conditions, update, {})
},
}
export default {
@@ -135,5 +144,6 @@ export default {
setHistoryRangesSupport: callbackify(
ProjectOptionsHandler.setHistoryRangesSupport
),
setReferenceFormat: callbackify(ProjectOptionsHandler.setReferenceFormat),
promises: ProjectOptionsHandler,
}
+1
View File
@@ -115,6 +115,7 @@ export const ProjectSchema = new Schema(
},
],
deferredTpdsFlushCounter: { type: Number },
referenceFormat: { type: String },
},
{ minimize: false }
)
@@ -214,6 +214,8 @@
"beta_program_already_participating": "",
"beta_program_benefits": "",
"beta_program_not_participating": "",
"biblatex": "",
"bibtex": "",
"bibtex_duplicates_keys": "",
"billed_annually_at": "",
"billed_monthly_at": "",
@@ -1622,6 +1624,7 @@
"reference_count": "",
"reference_count_plural": "",
"reference_error_relink_hint": "",
"reference_format": "",
"reference_manager_linked": "",
"reference_manager_lowercase": "",
"reference_manager_searched_groups": "",
@@ -1632,6 +1635,7 @@
"reference_search_settings": "",
"reference_search_style": "",
"reference_sync": "",
"references": "",
"references_from_these_libraries_will_be_included_in_your_reference_search_results": "",
"refresh": "",
"refresh_page_after_linking_dropbox": "",
@@ -1806,7 +1810,6 @@
"select_all_projects": "",
"select_an_output_file": "",
"select_an_output_file_figure_modal": "",
"select_bib_file": "",
"select_cells_in_a_single_row_to_merge": "",
"select_color": "",
"select_entry": "",
@@ -4,7 +4,11 @@ import { PermissionsLevel } from '@/features/ide-react/types/permissions'
export type JoinProjectPayloadProject = Pick<
Project,
Exclude<keyof Project, ['rootDocId', 'publicAccessLevel']>
> & { rootDoc_id?: string; publicAccesLevel?: string }
> & {
rootDoc_id?: string
mainBibliographyDoc_id?: string
publicAccesLevel?: string
}
export type JoinProjectPayload = {
permissionsLevel: PermissionsLevel
@@ -103,12 +103,18 @@ export const IdeReactProvider: FC<React.PropsWithChildren> = ({ children }) => {
function handleJoinProjectResponse({
project: {
rootDoc_id: rootDocId,
mainBibliographyDoc_id: mainBibliographyDocId,
publicAccesLevel: publicAccessLevel,
..._project
},
permissionsLevel,
}: JoinProjectPayload) {
const project = { ..._project, rootDocId, publicAccessLevel }
const project = {
..._project,
rootDocId,
mainBibliographyDocId,
publicAccessLevel,
}
// Cast the project from the payload as ProjectMetadata to ensure it has
// the correct type for the context. It must be close enough because the
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next'
import DropdownSetting from '../dropdown-setting'
import { useProjectSettingsContext } from '../../context/project-settings-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
export default function ReferenceFormatSetting() {
const { t } = useTranslation()
const { write } = usePermissionsContext()
const { referenceFormat, setReferenceFormat } = useProjectSettingsContext()
return (
<DropdownSetting
id="referenceFormat"
label={t('reference_format')}
options={[
{
value: 'bibtex',
label: t('bibtex'),
},
{
value: 'biblatex',
label: t('biblatex'),
},
]}
onChange={setReferenceFormat}
value={referenceFormat || 'biblatex'}
translateOptions="no"
disabled={!write}
/>
)
}
@@ -0,0 +1,35 @@
import { useFileTreeData } from '@/shared/context/file-tree-data-context'
import { useMemo } from 'react'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import DropdownSetting from '../dropdown-setting'
import { useTranslation } from 'react-i18next'
import { useBibliographyDocId } from '../../hooks/use-bibliography-doc-id'
export default function RootBibDocumentSetting() {
const { docs } = useFileTreeData()
const { write } = usePermissionsContext()
const { t } = useTranslation()
const { setMainBibliographyDocId, mainBibliographyDocId } =
useBibliographyDocId()
const validDocsOptions = useMemo(() => {
return docs
?.filter(doc => doc.doc.name.toLowerCase().endsWith('.bib'))
.map(doc => ({ value: doc.doc.id, label: doc.path }))
}, [docs])
return (
<DropdownSetting
id="mainBibliographyDocId"
label={t('main_bibliography_file_for_this_project')}
description={`${t(
'this_is_the_file_that_references_pulled_from_your_reference_manager_will_be_added_to'
)}`}
disabled={!write || !validDocsOptions?.length}
options={validDocsOptions ?? []}
onChange={setMainBibliographyDocId}
value={mainBibliographyDocId ?? ''}
translateOptions="no"
/>
)
}
@@ -13,6 +13,9 @@ type ProjectSettingsSetterContextValue = {
setSpellCheckLanguage: (
spellCheckLanguage: ProjectSettings['spellCheckLanguage']
) => void
setReferenceFormat: (
referenceFormat: ProjectSettings['referenceFormat']
) => Promise<void>
setAutoComplete: (autoComplete: UserSettings['autoComplete']) => void
setAutoPairDelimiters: (
autoPairDelimiters: UserSettings['autoPairDelimiters']
@@ -67,6 +70,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setRootDocId,
spellCheckLanguage,
setSpellCheckLanguage,
referenceFormat,
setReferenceFormat,
} = useProjectWideSettings()
const {
@@ -174,6 +179,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setMendeley,
papers,
setPapers,
referenceFormat,
setReferenceFormat,
}),
[
compiler,
@@ -230,6 +237,8 @@ export const ProjectSettingsProvider: FC<React.PropsWithChildren> = ({
setMendeley,
papers,
setPapers,
referenceFormat,
setReferenceFormat,
]
)
@@ -32,6 +32,8 @@ import FontSizeSetting from '@/features/ide-settings/components/appearance-setti
import LineHeightSetting from '@/features/ide-settings/components/appearance-settings/line-height-setting'
import FontFamilySetting from '@/features/ide-settings/components/appearance-settings/font-family-setting'
import DarkModePdfSetting from '@/features/ide-settings/components/appearance-settings/dark-mode-pdf-setting'
import RootBibDocumentSetting from '../components/reference-settings/root-bib-document-setting'
import ReferenceFormatSetting from '../components/reference-settings/reference-format-setting'
import { useProjectSettingsContext } from '@/features/ide-settings/context/project-settings-context'
import { useFeatureFlag } from '@/shared/context/split-test-context'
@@ -149,11 +151,6 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
key: 'pdfViewer',
component: <PDFViewerSetting />,
},
{
key: 'write-and-cite-settings',
component: <ReferenceSearchSetting />,
hidden: !ReferenceSearchSetting,
},
{
key: 'floating-menu',
component: <FloatingMenuSetting />,
@@ -236,6 +233,31 @@ export const SettingsModalProvider: FC<React.PropsWithChildren> = ({
},
],
},
{
key: 'references',
title: t('references'),
icon: 'book_5',
sections: [
{
key: 'general',
settings: [
{
key: 'mainBibliographyDocId',
component: <RootBibDocumentSetting />,
},
{
key: 'referenceFormat',
component: <ReferenceFormatSetting />,
},
{
key: 'write-and-cite-settings',
component: <ReferenceSearchSetting />,
hidden: !ReferenceSearchSetting,
},
],
},
],
},
{
key: 'appearance',
title: t('appearance'),
@@ -0,0 +1,35 @@
import { useCallback } from 'react'
import { useProjectContext } from '@/shared/context/project-context'
import type { ProjectSettings } from '../utils/api'
import useSaveProjectSettings from './use-save-project-settings'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
export const useBibliographyDocId = () => {
const { project } = useProjectContext()
const mainBibliographyDocId = project?.mainBibliographyDocId
const saveProjectSettings = useSaveProjectSettings()
const { write } = usePermissionsContext()
const setMainBibliographyDocIdFunc = useCallback(
async (
newMainBibliographyDocId: ProjectSettings['mainBibliographyDocId']
) => {
if (write) {
try {
await saveProjectSettings(
'mainBibliographyDocId',
newMainBibliographyDocId
)
} catch (err) {
// TODO: retry mechanism (max 10x before failed completely and rollback the old value)
}
}
},
[write, saveProjectSettings]
)
return {
mainBibliographyDocId,
setMainBibliographyDocId: setMainBibliographyDocIdFunc,
}
}
@@ -44,6 +44,15 @@ export default function useProjectWideSettingsSocketListener() {
[project, updateProject]
)
const setReferenceFormat = useCallback(
(referenceFormat: ProjectSettings['referenceFormat']) => {
if (project) {
updateProject({ referenceFormat })
}
},
[project, updateProject]
)
useEffect(() => {
// data is not available on initial mounting
const dataAvailable = !!project
@@ -53,6 +62,7 @@ export default function useProjectWideSettingsSocketListener() {
socket.on('imageNameUpdated', setImageName)
socket.on('png2pdfUpdated', setPng2pdf)
socket.on('spellCheckLanguageUpdated', setSpellCheckLanguage)
socket.on('referenceFormatUpdated', setReferenceFormat)
return () => {
socket.removeListener('compilerUpdated', setCompiler)
socket.removeListener('imageNameUpdated', setImageName)
@@ -61,6 +71,7 @@ export default function useProjectWideSettingsSocketListener() {
'spellCheckLanguageUpdated',
setSpellCheckLanguage
)
socket.removeListener('referenceFormatUpdated', setReferenceFormat)
}
}
}, [
@@ -68,6 +79,7 @@ export default function useProjectWideSettingsSocketListener() {
project,
setCompiler,
setImageName,
setReferenceFormat,
setPng2pdf,
setSpellCheckLanguage,
])
@@ -31,6 +31,15 @@ export default function useProjectWideSettings() {
[saveProjectSettings]
)
const setReferenceFormat = useCallback(
async (newReferenceFormat: ProjectSettings['referenceFormat']) => {
await saveProjectSettings('referenceFormat', newReferenceFormat).catch(
debugConsole.error
)
},
[saveProjectSettings]
)
const setPng2pdf = useCallback(
async (newPng2pdf: ProjectSettings['png2pdf']) => {
await saveProjectSettings('png2pdf', newPng2pdf).catch(debugConsole.error)
@@ -46,6 +55,8 @@ export default function useProjectWideSettings() {
setCompiler,
imageName: project?.imageName,
setImageName,
referenceFormat: project?.referenceFormat,
setReferenceFormat,
png2pdf: project?.png2pdf ?? png2pdfEnabled,
setPng2pdf,
rootDocId,
@@ -13,6 +13,8 @@ export interface ProjectSettings {
rootDocId: string
spellCheckLanguage: string
name: string
mainBibliographyDocId?: string
referenceFormat?: 'bibtex' | 'biblatex'
}
type SaveUserSettings = Partial<
@@ -50,7 +50,6 @@ export type MyAccessRequest = Pick<
export interface ProjectMetadata extends ProjectSettings {
_id: string
mainBibliographyDocId?: string
members: ProjectMember[]
invites: ProjectMember[]
editAccessRequests?: EditAccessRequest[]
-1
View File
@@ -1566,7 +1566,6 @@
"select_all_projects": "Vælg alle projekter",
"select_an_output_file": "Vælg en outputfil",
"select_an_output_file_figure_modal": "Vælg en outputfil",
"select_bib_file": "Vælg .bib fil",
"select_cells_in_a_single_row_to_merge": "Vælg celler i en enkelt række at flette",
"select_color": "Vælg farven __name__",
"select_folder_from_project": "Vælg mappe fra projekt",
+5 -2
View File
@@ -283,7 +283,9 @@
"beta_program_not_participating": "You are not enrolled in the beta program",
"beta_program_opt_in_action": "Opt-in to beta program",
"beta_program_opt_out_action": "Opt-out of beta program",
"biblatex": "BibLaTeX",
"bibliographies": "Bibliographies",
"bibtex": "BibTeX",
"bibtex_duplicates_keys": "The file __fileName__ contains more than one entry with the citation key __key__. Only the first occurrence is shown in this list.",
"billed_annually_at": "Billed annually at <0>__price__</0> <1>(includes plan and any add-ons)</1>",
"billed_monthly_at": "Billed monthly at <0>__price__</0> <1>(includes plan and any add-ons)</1>",
@@ -2116,6 +2118,7 @@
"reference_count": "__count__ reference",
"reference_count_plural": "__count__ references",
"reference_error_relink_hint": "If this error persists, try re-linking your account here:",
"reference_format": "Reference format",
"reference_manager_linked": "Reference manager linked",
"reference_manager_lowercase": "reference manager",
"reference_manager_searched_groups": "__provider__ search groups",
@@ -2127,6 +2130,7 @@
"reference_search_settings": "Reference search settings",
"reference_search_style": "Reference search style",
"reference_sync": "Reference manager sync",
"references": "References",
"references_from_these_libraries_will_be_included_in_your_reference_search_results": "References from these libraries will be included in your reference search results.",
"refresh": "Refresh",
"refresh_page_after_linking_dropbox": "Please refresh this page after linking your account to Dropbox.",
@@ -2340,7 +2344,6 @@
"select_all_projects": "Select all projects",
"select_an_output_file": "Select an Output File",
"select_an_output_file_figure_modal": "Select an output file",
"select_bib_file": "Select .bib file",
"select_cells_in_a_single_row_to_merge": "Select cells in a single row to merge",
"select_color": "Select color __name__",
"select_entry": "Select entry",
@@ -2729,7 +2732,7 @@
"this_could_be_because_we_cant_support_some_elements_of_the_table": "This could be because we can’t yet support some elements of the table in the table preview. Or there may be an error in the table’s LaTeX code.",
"this_field_is_required": "This field is required",
"this_grants_access_to_features_2": "This grants you access to <0>__appName__</0> <0>__featureType__</0> features.",
"this_is_the_file_that_references_pulled_from_your_reference_manager_will_be_added_to": "This is the file that references pulled from your reference manager will be added to.",
"this_is_the_file_that_references_pulled_from_your_reference_manager_will_be_added_to": "New references are saved to the .bib file you select here. If your project doesn’t have one yet, add a .bib file to choose it.",
"this_is_your_template": "This is your template from your project",
"this_organization_is_tax_exempt": "This organization is tax exempt",
"this_policy_applies_to_all_projects_owned_by_your_group_members": "This policy applies to all projects owned by your group members. Project owners can apply stricter sharing rules for individual projects.",
-1
View File
@@ -1782,7 +1782,6 @@
"select_all_projects": "全选",
"select_an_output_file": "选择输出文件",
"select_an_output_file_figure_modal": "选择一个输出文件",
"select_bib_file": "选择 .bib 文件",
"select_cells_in_a_single_row_to_merge": "在一行中选择单元格合并",
"select_color": "选择颜色 __name__",
"select_folder_from_project": "从项目中选择文件夹",
@@ -0,0 +1,79 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { SettingsModalProvider } from '@/features/ide-settings/context/settings-modal-context'
import {
EditorProviders,
projectDefaults,
} from '../../../helpers/editor-providers'
import ReferenceFormatSetting from '@/features/ide-settings/components/reference-settings/reference-format-setting'
import userEvent from '@testing-library/user-event'
// bibtex is listed first as it differs from the component's default
// ('biblatex'), so selecting it is guaranteed to trigger a change.
const OPTIONS = [
{
label: 'BibTeX',
value: 'bibtex',
},
{
label: 'BibLaTeX',
value: 'biblatex',
},
]
describe('<ReferenceFormatSetting />', function () {
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('defaults to BibLaTeX', function () {
render(
<EditorProviders>
<SettingsModalProvider>
<ReferenceFormatSetting />
</SettingsModalProvider>
</EditorProviders>
)
const select = screen.getByLabelText(
'Reference format'
) as HTMLSelectElement
expect(select.value).to.equal('biblatex')
})
it('each option is shown and can be selected', async function () {
render(
<EditorProviders>
<SettingsModalProvider>
<ReferenceFormatSetting />
</SettingsModalProvider>
</EditorProviders>
)
const saveSettingsMock = fetchMock.post(
`express:/project/:projectId/settings`,
{
status: 200,
},
{ delay: 0 }
)
const select = screen.getByLabelText('Reference format')
for (const option of OPTIONS) {
const optionElement = within(select).getByText(option.label)
expect(optionElement.getAttribute('value')).to.equal(option.value)
await userEvent.selectOptions(select, [optionElement])
expect(
saveSettingsMock.callHistory.called(
`/project/${projectDefaults._id}/settings`,
{
body: { referenceFormat: option.value },
}
)
).to.be.true
}
})
})
@@ -0,0 +1,105 @@
import { screen, within, render } from '@testing-library/react'
import { expect } from 'chai'
import fetchMock from 'fetch-mock'
import { Folder } from '../../../../../types/folder'
import { SettingsModalProvider } from '@/features/ide-settings/context/settings-modal-context'
import {
EditorProviders,
projectDefaults,
} from '../../../helpers/editor-providers'
import RootBibDocumentSetting from '@/features/ide-settings/components/reference-settings/root-bib-document-setting'
import userEvent from '@testing-library/user-event'
const OPTIONS = [
{
label: 'main.bib',
value: '123abc',
},
{
label: 'another.bib',
value: '123abcd',
},
]
describe('<RootBibDocumentSetting />', function () {
const rootFolder: Folder = {
_id: 'root-folder-id',
name: 'rootFolder',
docs: [
{
_id: '123abc',
name: 'main.bib',
},
{
_id: '123abcd',
name: 'another.bib',
},
{
_id: '123abce',
name: 'main.tex',
},
],
fileRefs: [],
folders: [],
}
afterEach(function () {
fetchMock.removeRoutes().clearHistory()
})
it('only lists .bib files as options', function () {
render(
<EditorProviders rootFolder={[rootFolder as any]}>
<SettingsModalProvider>
<RootBibDocumentSetting />
</SettingsModalProvider>
</EditorProviders>
)
const select = screen.getByLabelText(
'Main bibliography file for this project'
)
expect(within(select).getByText('main.bib')).to.exist
expect(within(select).getByText('another.bib')).to.exist
expect(within(select).queryByText('main.tex')).to.be.null
})
it('each option is shown and can be selected', async function () {
render(
<EditorProviders rootFolder={[rootFolder as any]}>
<SettingsModalProvider>
<RootBibDocumentSetting />
</SettingsModalProvider>
</EditorProviders>
)
const saveSettingsMock = fetchMock.post(
`express:/project/:projectId/settings`,
{
status: 200,
},
{ delay: 0 }
)
const select = screen.getByLabelText(
'Main bibliography file for this project'
)
// Reverse order so we test changing to each option
for (const option of [...OPTIONS].reverse()) {
const optionElement = within(select).getByText(option.label)
expect(optionElement.getAttribute('value')).to.equal(option.value)
await userEvent.selectOptions(select, [optionElement])
expect(
saveSettingsMock.callHistory.called(
`/project/${projectDefaults._id}/settings`,
{
body: { mainBibliographyDocId: option.value },
}
)
).to.be.true
}
})
})