diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index edede9cd02907c9cdbc3eb4268f5f2c08d1de129..f3c3a7c189eec8c3827cc292d57019d4330d8971 100644 --- a/app/assets/javascripts/repository/components/header_area.vue +++ b/app/assets/javascripts/repository/components/header_area.vue @@ -19,6 +19,7 @@ import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vu import CompactCodeDropdown from '~/repository/components/code_dropdown/compact_code_dropdown.vue'; import SourceCodeDownloadDropdown from '~/vue_shared/components/download_dropdown/download_dropdown.vue'; import CloneCodeDropdown from '~/vue_shared/components/code_dropdown/clone_code_dropdown.vue'; +import AddToTree from '~/repository/components/header_area/add_to_tree.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; export default { @@ -38,6 +39,7 @@ export default { CompactCodeDropdown, SourceCodeDownloadDropdown, CloneCodeDropdown, + AddToTree, WebIdeLink: () => import('ee_else_ce/vue_shared/components/web_ide_link.vue'), LockDirectoryButton: () => import('ee_component/repository/components/lock_directory_button.vue'), @@ -239,6 +241,25 @@ export default { <!-- Tree controls --> <div v-if="isTreeView" class="tree-controls gl-mb-3 gl-flex gl-flex-wrap gl-gap-3 sm:gl-mb-0"> + <add-to-tree + v-if="!isReadmeView && showCompactCodeDropdown" + class="gl-hidden sm:gl-block" + :current-path="currentPath" + :can-collaborate="canCollaborate" + :can-edit-tree="canEditTree" + :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" + :original-branch="originalBranch" + :selected-branch="selectedBranch" + :new-branch-path="newBranchPath" + :new-tag-path="newTagPath" + :new-blob-path="newBlobPath" + :fork-new-blob-path="forkNewBlobPath" + :fork-new-directory-path="forkNewDirectoryPath" + :fork-upload-blob-path="forkUploadBlobPath" + :upload-path="uploadPath" + :new-dir-path="newDirPath" + /> <!-- EE: = render_if_exists 'projects/tree/lock_link' --> <lock-directory-button v-if="!isRoot" :project-path="projectPath" :path="currentPath" /> <gl-button @@ -277,7 +298,27 @@ export default { <!-- code + mobile panel --> <div v-if="!isReadmeView" class="project-code-holder gl-w-full sm:gl-w-auto"> <div v-if="showCompactCodeDropdown" class="gl-flex gl-justify-end gl-gap-3"> + <add-to-tree + v-if="!isReadmeView" + class="sm:gl-hidden" + :current-path="currentPath" + :can-collaborate="canCollaborate" + :can-edit-tree="canEditTree" + :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" + :original-branch="originalBranch" + :selected-branch="selectedBranch" + :new-branch-path="newBranchPath" + :new-tag-path="newTagPath" + :new-blob-path="newBlobPath" + :fork-new-blob-path="forkNewBlobPath" + :fork-new-directory-path="forkNewDirectoryPath" + :fork-upload-blob-path="forkUploadBlobPath" + :upload-path="uploadPath" + :new-dir-path="newDirPath" + /> <compact-code-dropdown + class="gl-ml-auto" :ssh-url="sshUrl" :http-url="httpUrl" :kerberos-url="kerberosUrl" diff --git a/app/assets/javascripts/repository/components/header_area/add_to_tree.vue b/app/assets/javascripts/repository/components/header_area/add_to_tree.vue new file mode 100644 index 0000000000000000000000000000000000000000..d75f61cd0808ae6299b6670200fa7224e853a541 --- /dev/null +++ b/app/assets/javascripts/repository/components/header_area/add_to_tree.vue @@ -0,0 +1,256 @@ +<script> +import { GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui'; +import { uniqueId } from 'lodash'; +import { s__ } from '~/locale'; +import { joinPaths } from '~/lib/utils/url_utility'; +import { BV_SHOW_MODAL } from '~/lib/utils/constants'; +import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; +import projectPathQuery from '~/repository/queries/project_path.query.graphql'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; +import NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; + +export default { + components: { + GlDisclosureDropdown, + UploadBlobModal, + NewDirectoryModal, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + currentPath: { + type: String, + required: false, + default: '', + }, + canEditTree: { + type: Boolean, + required: false, + default: false, + }, + canCollaborate: { + type: Boolean, + required: false, + default: false, + }, + newBlobPath: { + type: String, + required: false, + default: null, + }, + forkNewBlobPath: { + type: String, + required: false, + default: null, + }, + forkNewDirectoryPath: { + type: String, + required: false, + default: null, + }, + forkUploadBlobPath: { + type: String, + required: false, + default: null, + }, + newBranchPath: { + type: String, + required: false, + default: null, + }, + newTagPath: { + type: String, + required: false, + default: null, + }, + uploadPath: { + type: String, + required: false, + default: '', + }, + newDirPath: { + type: String, + required: false, + default: '', + }, + selectedBranch: { + type: String, + required: false, + default: '', + }, + originalBranch: { + type: String, + required: false, + default: '', + }, + canPushCode: { + type: Boolean, + required: false, + default: false, + }, + canPushToBranch: { + type: Boolean, + required: false, + default: false, + }, + }, + apollo: { + projectPath: { + query: projectPathQuery, + }, + userPermissions: { + query: permissionsQuery, + variables() { + return { + projectPath: this.projectPath, + }; + }, + update: (data) => data.project?.userPermissions, + }, + }, + data() { + return { + projectPath: '', + userPermissions: {}, + }; + }, + computed: { + uploadBlobModalId() { + return uniqueId('modal-upload-blob'); + }, + newDirectoryModalId() { + return uniqueId('modal-new-directory'); + }, + canCreateMrFromFork() { + return this.userPermissions?.forkProject && this.userPermissions?.createMergeRequestIn; + }, + hasPushCodePermission() { + return this.userPermissions?.pushCode; + }, + dropdownDirectoryItems() { + if (this.canEditTree) { + return [ + { + text: s__('Repository|New file'), + href: joinPaths( + this.newBlobPath, + this.currentPath ? encodeURIComponent(this.currentPath) : '', + ), + extraAttrs: { + 'data-testid': 'new-file-menu-item', + }, + }, + { + text: s__('Repository|Upload file'), + action: () => this.$root.$emit(BV_SHOW_MODAL, this.uploadBlobModalId), + }, + { + text: s__('Repository|New directory'), + action: () => this.$root.$emit(BV_SHOW_MODAL, this.newDirectoryModalId), + }, + ]; + } + + if (this.canCreateMrFromFork) { + return [ + { + text: s__('Repository|New file'), + href: this.forkNewBlobPath, + extraAttrs: { + 'data-method': 'post', + }, + }, + { + text: s__('Repository|Upload file'), + href: this.forkUploadBlobPath, + extraAttrs: { + 'data-method': 'post', + }, + }, + { + text: s__('Repository|New directory'), + href: this.forkNewDirectoryPath, + extraAttrs: { + 'data-method': 'post', + }, + }, + ]; + } + + return []; + }, + dropdownRepositoryItems() { + if (!this.hasPushCodePermission) return []; + return [ + { + text: s__('Repository|New branch'), + href: this.newBranchPath, + }, + { + text: s__('Repository|New tag'), + href: this.newTagPath, + }, + ]; + }, + dropdownItems() { + if (!this.canCollaborate && !this.canCreateMrFromFork) return []; + return [ + this.dropdownDirectoryItems?.length && { + name: s__('Repository|This directory'), + items: this.dropdownDirectoryItems, + }, + this.dropdownRepositoryItems?.length && { + name: s__('Repository|This repository'), + items: this.dropdownRepositoryItems, + }, + ].filter(Boolean); + }, + renderAddToTreeDropdown() { + return this.dropdownItems.length; + }, + showUploadModal() { + return this.canEditTree && !this.$apollo.queries.userPermissions.loading; + }, + showNewDirectoryModal() { + return this.canEditTree && !this.$apollo.queries.userPermissions.loading; + }, + newDirectoryPath() { + return joinPaths(this.newDirPath, this.currentPath); + }, + }, +}; +</script> + +<template> + <div> + <gl-disclosure-dropdown + v-if="renderAddToTreeDropdown" + :toggle-text="__('Add to tree')" + toggle-class="add-to-tree" + data-testid="add-to-tree" + text-sr-only + icon="plus" + :items="dropdownItems" + /> + <upload-blob-modal + v-if="showUploadModal" + :modal-id="uploadBlobModalId" + :commit-message="__('Upload New File')" + :target-branch="selectedBranch" + :original-branch="originalBranch" + :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" + :path="uploadPath" + /> + <new-directory-modal + v-if="showNewDirectoryModal" + :can-push-code="canPushCode" + :can-push-to-branch="canPushToBranch" + :modal-id="newDirectoryModalId" + :target-branch="selectedBranch" + :original-branch="originalBranch" + :path="newDirectoryPath" + /> + </div> +</template> diff --git a/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue b/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue index 4b7ef9700ef7e470b265387200b54f560cd2011d..37309fe82aa4f18683b7d4289099570b5d328cd2 100644 --- a/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue @@ -300,7 +300,7 @@ export default { return this.$route.name === 'blobPath' || this.$route.name === 'blobPathDecoded'; }, renderAddToTreeDropdown() { - return this.dropdownItems.length; + return this.dropdownItems.length && !this.glFeatures.directoryCodeDropdownUpdates; }, newDirectoryPath() { return joinPaths(this.newDirPath, this.currentPath); diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bdc817cefe0001a9940f050bf51dd81ddb3ae4b2..2420ae0b88eb61f88f8c747dae54ca030ceb9ac0 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -48285,6 +48285,27 @@ msgstr "" msgid "Repository: %{counter_repositories} / Wikis: %{counter_wikis} / Build Artifacts: %{counter_build_artifacts} / Pipeline Artifacts: %{counter_pipeline_artifacts} / LFS: %{counter_lfs_objects} / Snippets: %{counter_snippets} / Packages: %{counter_packages} / Uploads: %{counter_uploads}" msgstr "" +msgid "Repository|New branch" +msgstr "" + +msgid "Repository|New directory" +msgstr "" + +msgid "Repository|New file" +msgstr "" + +msgid "Repository|New tag" +msgstr "" + +msgid "Repository|This directory" +msgstr "" + +msgid "Repository|This repository" +msgstr "" + +msgid "Repository|Upload file" +msgstr "" + msgid "Request" msgstr "" diff --git a/spec/features/projects/show/user_sees_collaboration_links_spec.rb b/spec/features/projects/show/user_sees_collaboration_links_spec.rb index 4d57426bd98c2c5f1a19fb40dedb1559532b5ced..ce4c312c2b39ac6b1455c10d82e89f18a82a123f 100644 --- a/spec/features/projects/show/user_sees_collaboration_links_spec.rb +++ b/spec/features/projects/show/user_sees_collaboration_links_spec.rb @@ -39,8 +39,8 @@ def find_new_menu_toggle end # The dropdown above the tree - page.within('.repo-breadcrumb') do - find_by_testid('add-to-tree').click + page.within('.tree-controls') do + find('.add-to-tree').click aggregate_failures 'dropdown links above the repo tree' do expect(page).to have_link('New file') diff --git a/spec/frontend/repository/components/header_area/add_to_tree_spec.js b/spec/frontend/repository/components/header_area/add_to_tree_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..196fe5f5531dfcf66d2af6c25aa4eed1234a6878 --- /dev/null +++ b/spec/frontend/repository/components/header_area/add_to_tree_spec.js @@ -0,0 +1,204 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import AddToTree from '~/repository/components/header_area/add_to_tree.vue'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; +import NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + +import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; +import projectPathQuery from '~/repository/queries/project_path.query.graphql'; + +import createApolloProvider from 'helpers/mock_apollo_helper'; + +const TEST_PROJECT_PATH = 'test-project/path'; + +Vue.use(VueApollo); + +jest.mock('lodash/uniqueId', () => () => 'fake-id'); + +describe('Add to tree dropdown and modal components', () => { + let wrapper; + let permissionsQuerySpy; + + const createPermissionsQueryResponse = ({ + pushCode = false, + forkProject = false, + createMergeRequestIn = false, + } = {}) => ({ + data: { + project: { + id: 1, + __typename: '__typename', + userPermissions: { + __typename: '__typename', + pushCode, + forkProject, + createMergeRequestIn, + }, + }, + }, + }); + + const factory = ({ currentPath, extraProps = {}, mockRoute = {} } = {}) => { + const apolloProvider = createApolloProvider([[permissionsQuery, permissionsQuerySpy]]); + + apolloProvider.clients.defaultClient.cache.writeQuery({ + query: projectPathQuery, + data: { + projectPath: TEST_PROJECT_PATH, + }, + }); + + wrapper = shallowMount(AddToTree, { + apolloProvider, + provide: { + projectRootPath: TEST_PROJECT_PATH, + }, + propsData: { + currentPath, + ...extraProps, + }, + stubs: { + GlDisclosureDropdown, + }, + mocks: { + $route: { + name: 'treePath', + ...mockRoute, + }, + }, + }); + }; + + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findDropdownGroup = () => wrapper.findComponent(GlDisclosureDropdownGroup); + const findUploadBlobModal = () => wrapper.findComponent(UploadBlobModal); + const findNewDirectoryModal = () => wrapper.findComponent(NewDirectoryModal); + + beforeEach(() => { + permissionsQuerySpy = jest.fn().mockResolvedValue(createPermissionsQueryResponse()); + }); + + it('does not render add to tree dropdown when permissions are false', async () => { + factory({ + currentPath: '/', + extraProps: { + canCollaborate: false, + }, + }); + await nextTick(); + + expect(findDropdown().exists()).toBe(false); + }); + + it('renders add to tree dropdown when permissions are true', async () => { + permissionsQuerySpy.mockResolvedValue( + createPermissionsQueryResponse({ forkProject: true, createMergeRequestIn: true }), + ); + + factory({ + currentPath: '/', + extraProps: { + canCollaborate: true, + canEditTree: true, + }, + }); + await nextTick(); + + expect(findDropdown().exists()).toBe(true); + }); + + describe('renders the upload blob modal', () => { + beforeEach(() => { + factory({ + currentPath: '/', + extraProps: { + canEditTree: true, + }, + }); + }); + + it('does not render the modal while loading', () => { + expect(findUploadBlobModal().exists()).toBe(false); + }); + + it('renders the modal with correct props once loaded', async () => { + await waitForPromises(); + + expect(findUploadBlobModal().exists()).toBe(true); + expect(findUploadBlobModal().props()).toStrictEqual({ + canPushCode: false, + canPushToBranch: false, + commitMessage: 'Upload New File', + emptyRepo: false, + modalId: 'fake-id', + originalBranch: '', + path: '', + replacePath: null, + targetBranch: '', + }); + }); + }); + + describe('renders the new directory modal', () => { + beforeEach(() => { + factory({ + currentPath: 'some_dir', + extraProps: { + canEditTree: true, + newDirPath: 'root/master', + }, + }); + }); + it('does not render the modal while loading', () => { + expect(findNewDirectoryModal().exists()).toBe(false); + }); + + it('renders the modal once loaded', async () => { + await waitForPromises(); + + expect(findNewDirectoryModal().exists()).toBe(true); + expect(findNewDirectoryModal().props('path')).toBe('root/master/some_dir'); + }); + }); + + describe('"this repository" dropdown group', () => { + it('renders when user has pushCode permissions', async () => { + permissionsQuerySpy.mockResolvedValue( + createPermissionsQueryResponse({ + pushCode: true, + }), + ); + + factory({ + currentPath: '/', + extraProps: { + canCollaborate: true, + }, + }); + await waitForPromises(); + + expect(findDropdownGroup().props('group').name).toBe('This repository'); + }); + + it('does not render when user does not have pushCode permissions', async () => { + permissionsQuerySpy.mockResolvedValue( + createPermissionsQueryResponse({ + pushCode: false, + }), + ); + + factory({ + currentPath: '/', + extraProps: { + canCollaborate: true, + }, + }); + await waitForPromises(); + + expect(findDropdownGroup().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/repository/components/header_area_spec.js b/spec/frontend/repository/components/header_area_spec.js index a5332095aa0e81a1ba166342954f95b433753fac..73784f25ad9ed296691c95830e0c8ed752485302 100644 --- a/spec/frontend/repository/components/header_area_spec.js +++ b/spec/frontend/repository/components/header_area_spec.js @@ -6,6 +6,7 @@ import Breadcrumbs from '~/repository/components/header_area/breadcrumbs.vue'; import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vue'; import CompactCodeDropdown from '~/repository/components/code_dropdown/compact_code_dropdown.vue'; import SourceCodeDownloadDropdown from '~/vue_shared/components/download_dropdown/download_dropdown.vue'; +import AddToTree from '~/repository/components/header_area/add_to_tree.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; import CloneCodeDropdown from '~/vue_shared/components/code_dropdown/clone_code_dropdown.vue'; import RepositoryOverflowMenu from '~/repository/components/header_area/repository_overflow_menu.vue'; @@ -37,6 +38,7 @@ describe('HeaderArea', () => { const findCompactCodeDropdown = () => wrapper.findComponent(CompactCodeDropdown); const findSourceCodeDownloadDropdown = () => wrapper.findComponent(SourceCodeDownloadDropdown); const findCloneCodeDropdown = () => wrapper.findComponent(CloneCodeDropdown); + const findAddToTreeDropdown = () => wrapper.findComponent(AddToTree); const findPageHeading = () => wrapper.findByTestId('repository-heading'); const findFileIcon = () => wrapper.findComponent(FileIcon); const findRepositoryOverflowMenu = () => wrapper.findComponent(RepositoryOverflowMenu); @@ -153,13 +155,28 @@ describe('HeaderArea', () => { expect(findCloneCodeDropdown().props('httpUrl')).toBe(headerAppInjected.httpUrl); }); }); + + describe('Add to tree dropdown', () => { + it('does not render AddToTree component', () => { + expect(findAddToTreeDropdown().exists()).toBe(false); + }); + }); }); }); }); describe('when rendered for tree view and directory_code_dropdown_updates flag is true', () => { - it('renders CompactCodeDropdown with correct props', () => { + beforeEach(() => { wrapper = createComponent({}, {}, { glFeatures: { directoryCodeDropdownUpdates: true } }); + }); + + describe('Add to tree dropdown', () => { + it('renders AddToTree component', () => { + expect(findAddToTreeDropdown().exists()).toBe(true); + }); + }); + + it('renders CompactCodeDropdown with correct props', () => { expect(findCompactCodeDropdown().exists()).toBe(true); expect(findCompactCodeDropdown().props()).toMatchObject({ sshUrl: headerAppInjected.sshUrl, @@ -200,6 +217,10 @@ describe('HeaderArea', () => { expect(findSourceCodeDownloadDropdown().exists()).toBe(false); }); + it('does not render AddToTree component', () => { + expect(findAddToTreeDropdown().exists()).toBe(false); + }); + it('displays correct file name and icon', () => { expect(findPageHeading().text()).toContain('index.js'); expect(findFileIcon().props('fileName')).toBe('index.js'); @@ -223,6 +244,10 @@ describe('HeaderArea', () => { expect(findBreadcrumbs().exists()).toBe(false); }); + it('does not render AddToTree component', () => { + expect(findAddToTreeDropdown().exists()).toBe(false); + }); + it('does not render CodeDropdown and SourceCodeDownloadDropdown', () => { expect(findCodeDropdown().exists()).toBe(false); expect(findSourceCodeDownloadDropdown().exists()).toBe(false); diff --git a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb index 63d77e9e0a02866eae5371ad63a33e4a72cd1c6b..a6cc14b676fd994ca894f6532956edd76ec8f5b6 100644 --- a/spec/support/shared_examples/features/project_upload_files_shared_examples.rb +++ b/spec/support/shared_examples/features/project_upload_files_shared_examples.rb @@ -4,7 +4,7 @@ it 'uploads and commits a new text file', :js do find('.add-to-tree').click - page.within('.repo-breadcrumb') do + page.within('[data-testid="add-to-tree"]') do click_button('Upload file') wait_for_requests @@ -16,7 +16,7 @@ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) end - page.within('#modal-upload-blob') do + page.within('[data-testid="commit-change-modal"]') do fill_in(:commit_message, with: 'New commit message') choose(option: true) fill_in(:branch_name, with: 'upload_text', visible: true) @@ -40,7 +40,7 @@ it 'uploads and commits a new image file', :js do find('.add-to-tree').click - page.within('.repo-breadcrumb') do + page.within('[data-testid="add-to-tree"]') do click_button('Upload file') wait_for_requests @@ -52,7 +52,7 @@ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'logo_sample.svg'), make_visible: true) end - page.within('#modal-upload-blob') do + page.within('[data-testid="commit-change-modal"]') do fill_in(:commit_message, with: 'New commit message') choose(option: true) fill_in(:branch_name, with: 'upload_image', visible: true) @@ -71,7 +71,7 @@ it 'uploads and commits a new pdf file', :js do find('.add-to-tree').click - page.within('.repo-breadcrumb') do + page.within('[data-testid="add-to-tree"]') do click_button('Upload file') wait_for_requests @@ -83,7 +83,7 @@ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'sample.pdf'), make_visible: true) end - page.within('#modal-upload-blob') do + page.within('[data-testid="commit-change-modal"]') do fill_in(:commit_message, with: 'New commit message') choose(option: true) fill_in(:branch_name, with: 'upload_image', visible: true) @@ -124,7 +124,7 @@ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) end - page.within('#modal-upload-blob') do + page.within('[data-testid="commit-change-modal"]') do fill_in(:commit_message, with: 'New commit message') click_button('Commit changes') end @@ -161,7 +161,7 @@ attach_file('upload_file', File.join(Rails.root, 'spec', 'fixtures', 'doc_sample.txt'), make_visible: true) end - page.within('#modal-upload-blob') do + page.within('[data-testid="commit-change-modal"]') do fill_in(:commit_message, with: 'New commit message') click_button('Commit changes') end