瀏覽代碼

Add "Set as main document" to file tree menu (#30399)

GitOrigin-RevId: e05a7d0b103226bdc34e559d0d48c12183abdf5a
Alf Eaton 7 月之前
父節點
當前提交
a841dbfd2c

+ 2 - 0
services/web/frontend/extracted-translations.json

@@ -1641,6 +1641,7 @@
   "session_error": "",
   "session_expired_redirecting_to_login": "",
   "sessions": "",
+  "set_as_main_document": "",
   "set_color": "",
   "set_column_width": "",
   "set_up_single_sign_on": "",
@@ -2260,6 +2261,7 @@
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "",
   "you_are_using_your_organization_email_x_would_like_you_to_take_action": "",
   "you_can_also_choose_to_view_anonymously_or_leave_the_project": "",
+  "you_can_also_right_click_a_file_to_set_it_as_main": "",
   "you_can_buy_this_plan_but_not_as_a_trial": "",
   "you_can_manage_your_reference_manager_integrations_from_your_account_settings_page": "",
   "you_can_now_enable_sso": "",

+ 18 - 3
services/web/frontend/js/features/file-tree/components/file-tree-item/file-tree-item-menu-items.tsx

@@ -23,6 +23,8 @@ function FileTreeItemMenuItems() {
     startUploadingDocOrFile,
     downloadPath,
     selectedFileName,
+    canSetRootDocId,
+    setRootDocId,
   } = useFileTreeActionable()
 
   const { project } = useProjectContext()
@@ -63,10 +65,23 @@ function FileTreeItemMenuItems() {
           </DropdownItem>
         </li>
       ) : null}
+      {canSetRootDocId ? (
+        <>
+          <DropdownDivider />
+          <li role="none">
+            <DropdownItem onClick={setRootDocId}>
+              {t('set_as_main_document')}
+            </DropdownItem>
+          </li>
+        </>
+      ) : null}
       {canDelete ? (
-        <li role="none">
-          <DropdownItem onClick={startDeleting}>{t('delete')}</DropdownItem>
-        </li>
+        <>
+          <DropdownDivider />
+          <li role="none">
+            <DropdownItem onClick={startDeleting}>{t('delete')}</DropdownItem>
+          </li>
+        </>
       ) : null}
       {canCreate ? (
         <>

+ 39 - 1
services/web/frontend/js/features/file-tree/contexts/file-tree-actionable.tsx

@@ -19,6 +19,7 @@ import {
   NewDocEntity,
   NewLinkedFileEntity,
   NewEntity,
+  syncRootDocId,
 } from '../util/sync-mutation'
 import { findInTree, findInTreeOrThrow } from '../util/find-in-tree'
 import { isNameUniqueInFolder } from '../util/is-name-unique-in-folder'
@@ -39,6 +40,7 @@ import { useReferencesContext } from '@/features/ide-react/context/references-co
 import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
 import { FileTreeEntity } from '@ol-types/file-tree-entity'
 import { Doc } from '@ol-types/doc'
+import { isValidTeXFile } from '@/main/is-valid-tex-file'
 
 type DroppedFile = File & {
   relativePath?: string
@@ -87,6 +89,8 @@ const FileTreeActionableContext = createContext<
       droppedFiles: { files: File[]; targetFolderId: string } | null
       setDroppedFiles: (value: DroppedFiles | null) => void
       downloadPath?: string
+      canSetRootDocId: boolean
+      setRootDocId: () => Promise<void>
     }
   | undefined
 >(undefined)
@@ -230,11 +234,13 @@ function fileTreeActionableReducer(state: State, action: Action) {
 export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
   children,
 }) => {
-  const { projectId } = useProjectContext()
+  const { projectId, project, updateProject } = useProjectContext()
   const { fileTreeReadOnly } = useFileTreeData()
   const { indexAllReferences } = useReferencesContext()
   const { write } = usePermissionsContext()
 
+  const rootDocId = project?.rootDocId
+
   const [state, dispatch] = useReducer(
     fileTreeReadOnly
       ? fileTreeActionableReadOnlyReducer
@@ -522,6 +528,34 @@ export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
     }
   }, [fileTreeData, projectId, selectedEntityIds])
 
+  const canSetRootDocId = useMemo(() => {
+    // must have write permission on the project
+    if (!write) {
+      return false
+    }
+
+    // must be only one file selected
+    if (!selectedFileName) {
+      return false
+    }
+
+    // must not already be the root doc
+    if (rootDocId && selectedEntityIds.has(rootDocId)) {
+      return false
+    }
+
+    // must have a valid root doc extension
+    return isValidTeXFile(selectedFileName)
+  }, [rootDocId, selectedEntityIds, selectedFileName, write])
+
+  const setRootDocId = useCallback(async () => {
+    const [selectedEntityId] = selectedEntityIds
+
+    await syncRootDocId(projectId, selectedEntityId)
+
+    updateProject({ rootDocId: selectedEntityId })
+  }, [projectId, selectedEntityIds, updateProject])
+
   const value = useMemo(
     () => ({
       canDelete: write && selectedEntityIds.size > 0 && !isRootFolderSelected,
@@ -549,6 +583,8 @@ export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
       droppedFiles,
       setDroppedFiles,
       downloadPath,
+      canSetRootDocId,
+      setRootDocId,
     }),
     [
       cancel,
@@ -573,6 +609,8 @@ export const FileTreeActionableProvider: FC<React.PropsWithChildren> = ({
       startUploadingDocOrFile,
       state,
       write,
+      canSetRootDocId,
+      setRootDocId,
     ]
   )
 

+ 6 - 0
services/web/frontend/js/features/file-tree/util/sync-mutation.ts

@@ -88,3 +88,9 @@ export function syncCreateEntity<T extends NewEntity>(
 function getEntityPathName(entityType: string) {
   return entityType === 'fileRef' ? 'file' : entityType
 }
+
+export function syncRootDocId(projectId: string, rootDocId: string) {
+  return postJSON(`/project/${projectId}/settings`, {
+    body: { rootDocId },
+  })
+}

+ 1 - 1
services/web/frontend/js/features/ide-redesign/components/settings/compiler-settings/root-document-setting.tsx

@@ -45,7 +45,7 @@ export default function RootDocumentSetting() {
     <DropdownSetting
       id="rootDocId"
       label={t('main_document')}
-      description={t('the_primary_file_for_compiling_your_project')}
+      description={`${t('the_primary_file_for_compiling_your_project')} ${t('you_can_also_right_click_a_file_to_set_it_as_main')}`}
       disabled={!write}
       options={validDocsOptions}
       onChange={changeRootDocId}

+ 3 - 1
services/web/locales/en.json

@@ -2112,6 +2112,7 @@
   "session_error": "Session error. Please check you have cookies enabled. If the problem persists, try clearing your cache and cookies.",
   "session_expired_redirecting_to_login": "Session Expired. Redirecting to login page in __seconds__ seconds",
   "sessions": "Sessions",
+  "set_as_main_document": "Set as main document",
   "set_color": "set color",
   "set_column_width": "Set column width",
   "set_new_password": "Set new password",
@@ -2401,7 +2402,7 @@
   "the_next_payment_will_be_collected_on": "The next payment will be collected on <strong>__date__</strong>.",
   "the_original_text_has_changed": "The original text has changed, so this suggestion can’t be applied",
   "the_overleaf_color_scheme": "The __appName__ color scheme",
-  "the_primary_file_for_compiling_your_project": "The primary file for compiling your project",
+  "the_primary_file_for_compiling_your_project": "The primary file for compiling your project.",
   "the_project_that_contains_this_file_is_not_shared_with_you": "The project that contains this file is not shared with you",
   "the_requested_conversion_job_was_not_found": "The link to open this content on Overleaf specified a conversion job that could not be found. It’s possible that the job has expired and needs to be run again. If this keeps happening for links on a particular site, please report this to them.",
   "the_requested_publisher_was_not_found": "The link to open this content on Overleaf specified a publisher that could not be found. If this keeps happening for links on a particular site, please report this to them.",
@@ -2820,6 +2821,7 @@
   "you_are_on_x_plan_as_member_of_group_subscription_y_administered_by_z": "You are on our <0>__planName__</0> plan as a <1>member</1> of the group subscription <1>__groupName__</1> administered by <1>__adminEmail__</1>",
   "you_are_using_your_organization_email_x_would_like_you_to_take_action": "Because you are using your organization email on Overleaf, __companyName__ would like you to take one of the following actions.",
   "you_can_also_choose_to_view_anonymously_or_leave_the_project": "You can also choose to <0>view anonymously</0> (you will lose edit access) or <1>leave the project</1>.",
+  "you_can_also_right_click_a_file_to_set_it_as_main": "You can also right-click a file to set it as main.",
   "you_can_buy_this_plan_but_not_as_a_trial": "You can buy this plan but not as a trial, as you’ve completed a trial before.",
   "you_can_manage_your_reference_manager_integrations_from_your_account_settings_page": "You can manage your reference manager integrations from your <0>account settings page</0>.",
   "you_can_now_enable_sso": "You can now enable SSO on your group settings page.",

+ 1 - 1
services/web/locales/zh-CN.json

@@ -2192,7 +2192,7 @@
   "the_next_payment_will_be_collected_on": "下一笔付款将于<strong>__date__</strong>收取。",
   "the_original_text_has_changed": "原文本已发生改变,因此此建议无法应用",
   "the_overleaf_color_scheme": "__appName__ 配色方案",
-  "the_primary_file_for_compiling_your_project": "编译项目的主要文件",
+  "the_primary_file_for_compiling_your_project": "编译项目的主要文件",
   "the_project_that_contains_this_file_is_not_shared_with_you": "包含此文件的项目未与您共享",
   "the_requested_conversion_job_was_not_found": "在Overleaf打开此内容的链接指定了找不到的转换作业。作业可能已过期,需要重新运行。如果某个网站的链接经常出现这种情况,请向他们报告。",
   "the_requested_publisher_was_not_found": "在Overleaf打开此内容的链接指定了找不到的发布者。如果某个网站的链接经常出现这种情况,请向他们报告。",

+ 62 - 0
services/web/test/frontend/features/file-tree/flows/context-menu.spec.tsx

@@ -111,4 +111,66 @@ describe('FileTree Context Menu Flow', function () {
     cy.findAllByRole('button', { name: 'main.tex' }).trigger('contextmenu')
     cy.findByRole('menu').should('not.exist')
   })
+
+  it('shows "set main document" item when appropriate', function () {
+    const rootFolder = [
+      {
+        _id: 'root-folder-id',
+        name: 'rootFolder',
+        docs: [
+          { _id: 'main-doc', name: 'main.tex' },
+          { _id: 'other-doc', name: 'other.tex' },
+        ],
+        folders: [],
+        fileRefs: [],
+      },
+    ]
+
+    cy.mount(
+      <EditorProviders
+        rootFolder={rootFolder as any}
+        projectId="123abc"
+        rootDocId="main-doc"
+      >
+        <FileTreeRoot
+          refProviders={{}}
+          setRefProviderEnabled={cy.stub()}
+          setStartedFreeTrial={cy.stub()}
+          onSelect={cy.stub()}
+          onInit={cy.stub()}
+          isConnected
+        />
+      </EditorProviders>
+    )
+
+    cy.findByRole('menu').should('not.exist')
+
+    // main.tex is already the main document
+    cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
+    cy.findByRole('menu')
+      .findByRole('menuitem', { name: 'Set as main document' })
+      .should('not.exist')
+
+    // set other.tex as the main document
+    cy.findByRole('button', { name: 'other.tex' }).click({ force: true })
+    cy.findByRole('button', { name: 'other.tex' }).trigger('contextmenu')
+
+    cy.intercept('POST', '/project/123abc/settings', { statusCode: 204 }).as(
+      'update-settings'
+    )
+
+    cy.findByRole('menu')
+      .findByRole('menuitem', { name: 'Set as main document' })
+      .click()
+
+    cy.wait('@update-settings')
+      .its('request.body.rootDocId')
+      .should('eq', 'other-doc')
+
+    // main.tex is now not the main document
+    cy.findByRole('button', { name: 'main.tex' }).trigger('contextmenu')
+    cy.findByRole('menu').findByRole('menuitem', {
+      name: 'Set as main document',
+    })
+  })
 })