diff --git a/app/assets/javascripts/pages/projects/show/index.js b/app/assets/javascripts/pages/projects/show/index.js index eb6cb11832cf3e1883c1ccd934387a1636047ba8..8c05fabbb24289096ff91f7cc86d08dc1c44fba8 100644 --- a/app/assets/javascripts/pages/projects/show/index.js +++ b/app/assets/javascripts/pages/projects/show/index.js @@ -11,6 +11,7 @@ import initAmbiguousRefModal from '~/ref/init_ambiguous_ref_modal'; import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vue'; import initSourceCodeDropdowns from '~/vue_shared/components/download_dropdown/init_download_dropdowns'; import EmptyProject from '~/pages/projects/show/empty_project'; +import initHeaderApp from '~/repository/init_header_app'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import { initHomePanel } from '../home_panel'; @@ -27,6 +28,7 @@ if (document.querySelector('.blob-viewer')) { import(/* webpackChunkName: 'blobViewer' */ '~/blob/viewer') .then(({ BlobViewer }) => { new BlobViewer(); // eslint-disable-line no-new + initHeaderApp(true); }) .catch(() => {}); } diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue new file mode 100644 index 0000000000000000000000000000000000000000..b301b9a7f928f3d0e2f383e01ac7b89e3612a373 --- /dev/null +++ b/app/assets/javascripts/repository/components/header_area.vue @@ -0,0 +1,188 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Shortcuts from '~/behaviors/shortcuts/shortcuts'; +import { shouldDisableShortcuts } from '~/behaviors/shortcuts/shortcuts_toggle'; +import { keysFor, START_SEARCH_PROJECT_FILE } from '~/behaviors/shortcuts/keybindings'; +import { sanitize } from '~/lib/dompurify'; +import { InternalEvents } from '~/tracking'; +import { FIND_FILE_BUTTON_CLICK } from '~/tracking/constants'; +import { visitUrl, joinPaths } from '~/lib/utils/url_utility'; +import { generateHistoryUrl } from '~/repository/utils/url_utility'; +import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import Breadcrumbs from '~/repository/components/header_area/breadcrumbs.vue'; +import BlobControls from '~/repository/components/header_area/blob_controls.vue'; + +export default { + name: 'HeaderArea', + i18n: { + compare: __('Compare'), + findFile: __('Find file'), + history: __('History'), + }, + components: { + GlButton, + RefSelector, + Breadcrumbs, + BlobControls, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: [ + 'canCollaborate', + 'canEditTree', + 'canPushCode', + 'originalBranch', + 'selectedBranch', + 'newBranchPath', + 'newTagPath', + 'newBlobPath', + 'forkNewBlobPath', + 'forkNewDirectoryPath', + 'forkUploadBlobPath', + 'uploadPath', + 'newDirPath', + 'projectRootPath', + 'comparePath', + 'isReadmeView', + ], + props: { + projectPath: { + type: String, + required: true, + }, + refType: { + type: String, + required: false, + default: null, + }, + currentRef: { + type: String, + required: false, + default: null, + }, + historyLink: { + type: String, + required: true, + }, + projectId: { + type: String, + required: true, + }, + }, + computed: { + isTreeView() { + return this.$route.name !== 'blobPathDecoded'; + }, + historyPath() { + const url = generateHistoryUrl( + this.historyLink, + this.$route.params.path, + this.$route.meta.refType || this.$route.query.ref_type, + ); + + return url.href; + }, + getRefType() { + return this.$route.query.ref_type; + }, + currentPath() { + return this.$route.params.path; + }, + refSelectorQueryParams() { + return { + sort: 'updated_desc', + }; + }, + refSelectorValue() { + return this.refType ? joinPaths('refs', this.refType, this.currentRef) : this.currentRef; + }, + findFileTooltip() { + const { description } = START_SEARCH_PROJECT_FILE; + const key = this.findFileShortcutKey; + return shouldDisableShortcuts() + ? null + : sanitize(`${description} <kbd class="flat gl-ml-1" aria-hidden=true>${key}</kbd>`); + }, + findFileShortcutKey() { + return keysFor(START_SEARCH_PROJECT_FILE)[0]; + }, + }, + methods: { + onInput(selectedRef) { + visitUrl(generateRefDestinationPath(this.projectRootPath, this.originalBranch, selectedRef)); + }, + handleFindFile() { + InternalEvents.trackEvent(FIND_FILE_BUTTON_CLICK); + Shortcuts.focusSearchFile(); + }, + }, +}; +</script> + +<template> + <section class="nav-block gl-flex gl-flex-col gl-items-stretch sm:gl-flex-row"> + <div class="tree-ref-container mb-2 mb-md-0 gl-flex gl-flex-wrap gl-gap-2"> + <ref-selector + v-if="!isReadmeView" + class="tree-ref-holder gl-max-w-26" + data-testid="ref-dropdown-container" + :project-id="projectId" + :value="refSelectorValue" + use-symbolic-ref-names + :query-params="refSelectorQueryParams" + @input="onInput" + /> + <breadcrumbs + v-if="!isReadmeView" + class="js-repo-breadcrumbs" + :current-path="currentPath" + :ref-type="getRefType" + :can-collaborate="canCollaborate" + :can-edit-tree="canEditTree" + :can-push-code="canPushCode" + :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" + /> + </div> + + <!-- Tree controls --> + <div v-if="isTreeView" class="tree-controls gl-mb-3 gl-flex gl-flex-wrap gl-gap-3 sm:gl-mb-0"> + <!-- EE: = render_if_exists 'projects/tree/lock_link' --> + <gl-button + v-if="comparePath" + data-testid="tree-compare-control" + :href="comparePath" + class="shortcuts-compare" + >{{ $options.i18n.compare }}</gl-button + > + <gl-button v-if="!isReadmeView" :href="historyPath" data-testid="tree-history-control">{{ + $options.i18n.history + }}</gl-button> + <gl-button + v-gl-tooltip.html="findFileTooltip" + :aria-keyshortcuts="findFileShortcutKey" + data-testid="tree-find-file-control" + class="gl-mt-3 gl-w-full sm:gl-mt-0 sm:gl-w-auto" + @click="handleFindFile" + > + {{ $options.i18n.findFile }} + </gl-button> + <!-- web ide --> + <!-- code + mobile panel --> + </div> + + <!-- Blob controls --> + <blob-controls :project-path="projectPath" :ref-type="getRefType" /> + </section> +</template> diff --git a/app/assets/javascripts/repository/components/blob_controls.vue b/app/assets/javascripts/repository/components/header_area/blob_controls.vue similarity index 96% rename from app/assets/javascripts/repository/components/blob_controls.vue rename to app/assets/javascripts/repository/components/header_area/blob_controls.vue index 4611afa270a6de4354796e4f3e0de4f5339e40a7..2c5c163f7a8ed6b84c40c431ea1cc3e78520f563 100644 --- a/app/assets/javascripts/repository/components/blob_controls.vue +++ b/app/assets/javascripts/repository/components/header_area/blob_controls.vue @@ -18,9 +18,9 @@ import { import { sanitize } from '~/lib/dompurify'; import { InternalEvents } from '~/tracking'; import { FIND_FILE_BUTTON_CLICK } from '~/tracking/constants'; -import { updateElementsVisibility } from '../utils/dom'; -import blobControlsQuery from '../queries/blob_controls.query.graphql'; -import { getRefType } from '../utils/ref_type'; +import { updateElementsVisibility } from '~/repository/utils/dom'; +import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql'; +import { getRefType } from '~/repository/utils/ref_type'; export default { i18n: { diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue similarity index 94% rename from app/assets/javascripts/repository/components/breadcrumbs.vue rename to app/assets/javascripts/repository/components/header_area/breadcrumbs.vue index 3dea54d5995b13b9647de2ad2dbf3c2e3c6c8aac..b53607f08b7c07a00389205318cfd4dc68b8716c 100644 --- a/app/assets/javascripts/repository/components/breadcrumbs.vue +++ b/app/assets/javascripts/repository/components/header_area/breadcrumbs.vue @@ -5,11 +5,11 @@ import permissionsQuery from 'shared_queries/repository/permissions.query.graphq import { joinPaths, escapeFileUrl, buildURLwithRefType } from '~/lib/utils/url_utility'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { __ } from '~/locale'; -import getRefMixin from '../mixins/get_ref'; -import projectPathQuery from '../queries/project_path.query.graphql'; -import projectShortPathQuery from '../queries/project_short_path.query.graphql'; -import UploadBlobModal from './upload_blob_modal.vue'; -import NewDirectoryModal from './new_directory_modal.vue'; +import getRefMixin from '~/repository/mixins/get_ref'; +import projectPathQuery from '~/repository/queries/project_path.query.graphql'; +import projectShortPathQuery from '~/repository/queries/project_short_path.query.graphql'; +import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; +import NewDirectoryModal from '~/repository/components/new_directory_modal.vue'; const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob'; const NEW_DIRECTORY_MODAL_ID = 'modal-new-directory'; @@ -31,7 +31,7 @@ export default { query: permissionsQuery, variables() { return { - projectPath: this.projectPath, + projectPath: this.projectPath || this.projectRootPath, }; }, update: (data) => data.project?.userPermissions, @@ -44,6 +44,11 @@ export default { GlModal: GlModalDirective, }, mixins: [getRefMixin], + inject: { + projectRootPath: { + default: '', + }, + }, props: { currentPath: { type: String, diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 00c52f6a7607fd4c08c86ccc24673788be1b79fa..17b8d618bf9265d26b455751fa5d378f35f66274 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -3,7 +3,7 @@ import Vue from 'vue'; // eslint-disable-next-line no-restricted-imports import Vuex from 'vuex'; import { parseBoolean } from '~/lib/utils/common_utils'; -import { joinPaths, escapeFileUrl, visitUrl } from '~/lib/utils/url_utility'; +import { joinPaths, visitUrl } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; import initWebIdeLink from '~/pages/projects/shared/web_ide_link'; import PerformancePlugin from '~/performance/vue_performance_plugin'; @@ -12,10 +12,10 @@ import RefSelector from '~/ref/components/ref_selector.vue'; import HighlightWorker from '~/vue_shared/components/source_viewer/workers/highlight_worker?worker'; import CodeDropdown from '~/vue_shared/components/code_dropdown/code_dropdown.vue'; import App from './components/app.vue'; -import Breadcrumbs from './components/breadcrumbs.vue'; +import Breadcrumbs from './components/header_area/breadcrumbs.vue'; import ForkInfo from './components/fork_info.vue'; import LastCommit from './components/last_commit.vue'; -import BlobControls from './components/blob_controls.vue'; +import BlobControls from './components/header_area/blob_controls.vue'; import apolloProvider from './graphql'; import commitsQuery from './queries/commits.query.graphql'; import projectPathQuery from './queries/project_path.query.graphql'; @@ -24,7 +24,9 @@ import refsQuery from './queries/ref.query.graphql'; import createRouter from './router'; import { updateFormAction } from './utils/dom'; import { setTitle } from './utils/title'; +import { generateHistoryUrl } from './utils/url_utility'; import { generateRefDestinationPath } from './utils/ref_switcher_utils'; +import initHeaderApp from './init_header_app'; Vue.use(Vuex); Vue.use(PerformancePlugin, { @@ -196,6 +198,7 @@ export default function setupVueRepositoryList() { }); }; + initHeaderApp(); initCodeDropdown(); initLastCommitApp(); initBlobControlsApp(); @@ -258,31 +261,33 @@ export default function setupVueRepositoryList() { } const treeHistoryLinkEl = document.getElementById('js-tree-history-link'); - const { historyLink } = treeHistoryLinkEl.dataset; - // eslint-disable-next-line no-new - new Vue({ - el: treeHistoryLinkEl, - router, - render(h) { - const url = new URL(window.location.href); - url.pathname = `${historyLink}/${ - this.$route.params.path ? escapeFileUrl(this.$route.params.path) : '' - }`; - url.searchParams.set('ref_type', this.$route.meta.refType || this.$route.query.ref_type); - return h( - GlButton, - { - attrs: { - href: url.href, - // Ideally passing this class to `props` should work - // But it doesn't work here. :( - class: 'btn btn-default btn-md gl-button', + if (treeHistoryLinkEl) { + const { historyLink } = treeHistoryLinkEl.dataset; + // eslint-disable-next-line no-new + new Vue({ + el: treeHistoryLinkEl, + router, + render(h) { + const url = generateHistoryUrl( + historyLink, + this.$route.params.path, + this.$route.meta.refType || this.$route.query.ref_type, + ); + return h( + GlButton, + { + attrs: { + href: url.href, + // Ideally passing this class to `props` should work + // But it doesn't work here. :( + class: 'btn btn-default btn-md gl-button', + }, }, - }, - [__('History')], - ); - }, - }); + [__('History')], + ); + }, + }); + } initWebIdeLink({ el: document.getElementById('js-tree-web-ide-link'), router }); diff --git a/app/assets/javascripts/repository/init_header_app.js b/app/assets/javascripts/repository/init_header_app.js new file mode 100644 index 0000000000000000000000000000000000000000..a2d932582c8c742fb6478515362be56d6d930d0c --- /dev/null +++ b/app/assets/javascripts/repository/init_header_app.js @@ -0,0 +1,71 @@ +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import apolloProvider from './graphql'; +import HeaderArea from './components/header_area.vue'; +import createRouter from './router'; + +export default function initHeaderApp(isReadmeView = false) { + const headerEl = document.getElementById('js-repository-blob-header-app'); + if (headerEl) { + const { + historyLink, + ref, + escapedRef, + refType, + projectId, + breadcrumbsCanCollaborate, + breadcrumbsCanEditTree, + breadcrumbsCanPushCode, + breadcrumbsSelectedBranch, + breadcrumbsNewBranchPath, + breadcrumbsNewTagPath, + breadcrumbsNewBlobPath, + breadcrumbsForkNewBlobPath, + breadcrumbsForkNewDirectoryPath, + breadcrumbsForkUploadBlobPath, + breadcrumbsUploadPath, + breadcrumbsNewDirPath, + projectRootPath, + comparePath, + projectPath, + } = headerEl.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el: headerEl, + provide: { + canCollaborate: parseBoolean(breadcrumbsCanCollaborate), + canEditTree: parseBoolean(breadcrumbsCanEditTree), + canPushCode: parseBoolean(breadcrumbsCanPushCode), + originalBranch: ref, + selectedBranch: breadcrumbsSelectedBranch, + newBranchPath: breadcrumbsNewBranchPath, + newTagPath: breadcrumbsNewTagPath, + newBlobPath: breadcrumbsNewBlobPath, + forkNewBlobPath: breadcrumbsForkNewBlobPath, + forkNewDirectoryPath: breadcrumbsForkNewDirectoryPath, + forkUploadBlobPath: breadcrumbsForkUploadBlobPath, + uploadPath: breadcrumbsUploadPath, + newDirPath: breadcrumbsNewDirPath, + projectRootPath, + comparePath, + isReadmeView, + }, + apolloProvider, + router: createRouter(projectPath, escapedRef), + render(h) { + return h(HeaderArea, { + props: { + refType, + currentRef: ref, + historyLink, + // BlobControls: + projectPath, + // RefSelector: + projectId, + }, + }); + }, + }); + } +} diff --git a/app/assets/javascripts/repository/utils/url_utility.js b/app/assets/javascripts/repository/utils/url_utility.js new file mode 100644 index 0000000000000000000000000000000000000000..91f004ba9b3f27b1d62b27a90abe19d6ff2cc7bd --- /dev/null +++ b/app/assets/javascripts/repository/utils/url_utility.js @@ -0,0 +1,10 @@ +import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; + +export function generateHistoryUrl(historyLink, path, refType) { + const url = new URL(window.location.href); + + url.pathname = joinPaths(historyLink, path ? escapeFileUrl(path) : ''); + url.searchParams.set('ref_type', refType); + + return url; +} diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml index 138c99852c16448ba9300f2d5794ae41b8d5a3cc..f665c0e48464528a759bde4b9a6d533e3975002f 100644 --- a/app/views/projects/_files.html.haml +++ b/app/views/projects/_files.html.haml @@ -4,10 +4,17 @@ - if readme_path = @project.repository.readme_path - add_page_startup_api_call project_blob_path(@project, tree_join(@ref, readme_path), viewer: "rich", format: "json") - add_page_specific_style 'page_bundles/commit_description' +- add_page_specific_style 'page_bundles/projects' +- unless @ref.blank? || @repository&.root_ref == @ref + - compare_path = project_compare_index_path(@project, from: @repository&.root_ref, to: @ref) #tree-holder.tree-holder.clearfix.js-per-page.gl-mt-5{ data: { blame_per_page: Gitlab::Git::BlamePagination::PAGINATION_PER_PAGE } } - .nav-block.gl-flex.gl-flex-col.sm:gl-flex-row.gl-items-stretch - = render 'projects/tree/tree_header', tree: @tree + - if params[:common_repository_blob_header_app] == 'true' + #js-repository-blob-header-app{ data: { project_id: @project.id, ref: ref, ref_type: @ref_type.to_s, history_link: project_commits_path(@project, @ref), breadcrumbs: breadcrumb_data_attributes, project_root_path: project_path(@project), project_path: project.full_path, compare_path: compare_path, escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref) } } + + - else + .nav-block.gl-flex.gl-flex-col.sm:gl-flex-row.gl-items-stretch + = render 'projects/tree/tree_header', tree: @tree - if project.forked? #js-fork-info{ data: vue_fork_divergence_data(project, ref) } diff --git a/app/views/projects/_readme.html.haml b/app/views/projects/_readme.html.haml index fc9ddb650e9cf15e9f179d3e38c8edb38d312dd2..08dc61e5694d0104118924f1f4a8febed383746b 100644 --- a/app/views/projects/_readme.html.haml +++ b/app/views/projects/_readme.html.haml @@ -1,7 +1,17 @@ +- ref = local_assigns.fetch(:ref) { current_ref } +- project = local_assigns.fetch(:project) { @project } +- add_page_specific_style 'page_bundles/projects' +- unless @ref.blank? || @repository&.root_ref == @ref + - compare_path = project_compare_index_path(@project, from: @repository&.root_ref, to: @ref) + - if (readme = @repository.readme) && readme.rich_viewer .tree-holder.gl-mt-5 - .nav-block.mt-0 - = render 'projects/tree/tree_header', tree: @tree + - if params[:common_repository_blob_header_app] == 'true' + #js-repository-blob-header-app{ data: { project_id: @project.id, ref: ref, ref_type: @ref_type.to_s, history_link: project_commits_path(@project, @ref), breadcrumbs: breadcrumb_data_attributes, project_root_path: project_path(@project), project_path: project.full_path, compare_path: compare_path, escaped_ref: ActionDispatch::Journey::Router::Utils.escape_path(ref) } } + + - else + .nav-block.mt-0 + = render 'projects/tree/tree_header', tree: @tree %article.file-holder.readme-holder{ id: 'readme', class: ("limited-width-container" unless fluid_layout) } .js-file-title.file-title-flex-parent .file-header-content diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index da84dc9cffea4ad2b72dfdb6da8ed7a4c1b572b7..53adf6b4d7b056d2a8ef42fafda45219babe5b61 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -1,5 +1,3 @@ -- add_page_specific_style 'page_bundles/projects' - .tree-ref-container.gl-flex.gl-flex-wrap.gl-gap-2.mb-2.mb-md-0 .tree-ref-holder.gl-max-w-26{ data: { testid: 'ref-dropdown-container' } } #js-tree-ref-switcher{ data: { project_id: @project.id, ref_type: @ref_type.to_s, project_root_path: project_path(@project) } } diff --git a/qa/qa/page/project/show.rb b/qa/qa/page/project/show.rb index 1652e15cb21da2fe174cdf95cafefdcf5c3b2863..f45852a1d46156dc3603f29d04bbe5bbb83bb493 100644 --- a/qa/qa/page/project/show.rb +++ b/qa/qa/page/project/show.rb @@ -48,7 +48,7 @@ class Show < Page::Base element 'quick-actions-container' end - view 'app/assets/javascripts/repository/components/breadcrumbs.vue' do + view 'app/assets/javascripts/repository/components/header_area/breadcrumbs.vue' do element 'add-to-tree' element 'new-file-menu-item' end diff --git a/scripts/frontend/quarantined_vue3_specs.txt b/scripts/frontend/quarantined_vue3_specs.txt index 1adfae5b8a917ba8f20b87589d0e829035cda3cd..2ca7cbc9a0fcf3ce958b733ed15c28f358ebe586 100644 --- a/scripts/frontend/quarantined_vue3_specs.txt +++ b/scripts/frontend/quarantined_vue3_specs.txt @@ -379,8 +379,8 @@ spec/frontend/releases/components/asset_links_form_spec.js spec/frontend/releases/components/tag_create_spec.js spec/frontend/releases/components/tag_field_exsting_spec.js spec/frontend/releases/components/tag_search_spec.js -spec/frontend/repository/components/blob_controls_spec.js -spec/frontend/repository/components/breadcrumbs_spec.js +spec/frontend/repository/components/header_area/blob_controls_spec.js +spec/frontend/repository/components/header_area/breadcrumbs_spec.js spec/frontend/repository/components/table/index_spec.js spec/frontend/repository/components/table/row_spec.js spec/frontend/repository/router_spec.js diff --git a/spec/frontend/repository/components/blob_controls_spec.js b/spec/frontend/repository/components/header_area/blob_controls_spec.js similarity index 96% rename from spec/frontend/repository/components/blob_controls_spec.js rename to spec/frontend/repository/components/header_area/blob_controls_spec.js index d36b0b8e7c965a2a2cdf34b6b4fc84a2d443082a..7e3a55223a9b0f3efbd2a5cc2ce52efefb684ccb 100644 --- a/spec/frontend/repository/components/blob_controls_spec.js +++ b/spec/frontend/repository/components/header_area/blob_controls_spec.js @@ -3,7 +3,7 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import BlobControls from '~/repository/components/blob_controls.vue'; +import BlobControls from '~/repository/components/header_area/blob_controls.vue'; import blobControlsQuery from '~/repository/queries/blob_controls.query.graphql'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper'; @@ -13,7 +13,7 @@ import { resetShortcutsForTests } from '~/behaviors/shortcuts'; import ShortcutsBlob from '~/behaviors/shortcuts/shortcuts_blob'; import Shortcuts from '~/behaviors/shortcuts/shortcuts'; import BlobLinePermalinkUpdater from '~/blob/blob_line_permalink_updater'; -import { blobControlsDataMock, refMock } from '../mock_data'; +import { blobControlsDataMock, refMock } from '../../mock_data'; jest.mock('~/repository/utils/dom'); jest.mock('~/behaviors/shortcuts/shortcuts_blob'); diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/header_area/breadcrumbs_spec.js similarity index 98% rename from spec/frontend/repository/components/breadcrumbs_spec.js rename to spec/frontend/repository/components/header_area/breadcrumbs_spec.js index 57ee0502986936675b083d391c638fa42e3badac..92f888c678bc9ae8cbaac0b71e2b19d0b06acede 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/header_area/breadcrumbs_spec.js @@ -2,7 +2,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { shallowMount, RouterLinkStub } from '@vue/test-utils'; -import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; +import Breadcrumbs from '~/repository/components/header_area/breadcrumbs.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'; @@ -55,6 +55,9 @@ describe('Repository breadcrumbs component', () => { wrapper = shallowMount(Breadcrumbs, { apolloProvider, + provide: { + projectRootPath: TEST_PROJECT_PATH, + }, propsData: { currentPath, ...extraProps, diff --git a/spec/frontend/repository/components/header_area_spec.js b/spec/frontend/repository/components/header_area_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a4f768b1e364a901276e5cda0a22758cd4111063 --- /dev/null +++ b/spec/frontend/repository/components/header_area_spec.js @@ -0,0 +1,160 @@ +import { RouterLinkStub } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import HeaderArea from '~/repository/components/header_area.vue'; +import Breadcrumbs from '~/repository/components/header_area/breadcrumbs.vue'; +import BlobControls from '~/repository/components/header_area/blob_controls.vue'; +import Shortcuts from '~/behaviors/shortcuts/shortcuts'; +import { useMockInternalEventsTracking } from 'helpers/tracking_internal_events_helper'; + +const defaultMockRoute = { + params: { + path: '', + }, + meta: { + refType: '', + }, + query: { + ref_type: '', + }, +}; + +const defaultProvided = { + canCollaborate: true, + canEditTree: true, + canPushCode: true, + originalBranch: 'main', + selectedBranch: 'feature/new-ui', + newBranchPath: '/project/new-branch', + newTagPath: '/project/new-tag', + newBlobPath: '/project/new-file', + forkNewBlobPath: '/project/fork/new-file', + forkNewDirectoryPath: '/project/fork/new-directory', + forkUploadBlobPath: '/project/fork/upload', + uploadPath: '/project/upload', + newDirPath: '/project/new-directory', + projectRootPath: '/project/root/path', + comparePath: undefined, + isReadmeView: false, +}; + +describe('HeaderArea', () => { + let wrapper; + + const findBreadcrumbs = () => wrapper.findComponent(Breadcrumbs); + const findRefSelector = () => wrapper.findComponent(RefSelector); + const findHistoryButton = () => wrapper.findByTestId('tree-history-control'); + const findFindFileButton = () => wrapper.findByTestId('tree-find-file-control'); + const findCompareButton = () => wrapper.findByTestId('tree-compare-control'); + const { bindInternalEventDocument } = useMockInternalEventsTracking(); + + const createComponent = (props = {}, routeName = 'blobPathDecoded', provided = {}) => { + return shallowMountExtended(HeaderArea, { + provide: { + ...defaultProvided, + ...provided, + }, + propsData: { + projectPath: 'test/project', + historyLink: '/history', + refType: 'branch', + projectId: '123', + refSelectorValue: 'refs/heads/main', + ...props, + }, + stubs: { + RouterLink: RouterLinkStub, + }, + mocks: { + $route: { + ...defaultMockRoute, + name: routeName, + }, + }, + }); + }; + + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the component', () => { + expect(wrapper.exists()).toBe(true); + }); + + it('renders RefSelector', () => { + expect(findRefSelector().exists()).toBe(true); + }); + + it('renders Breadcrumbs component', () => { + expect(findBreadcrumbs().exists()).toBe(true); + }); + + describe('when rendered for tree view', () => { + beforeEach(() => { + wrapper = createComponent({}, 'treePathDecoded'); + }); + + describe('History button', () => { + it('renders History button with correct href', () => { + expect(findHistoryButton().exists()).toBe(true); + expect(findHistoryButton().attributes('href')).toContain('/history'); + }); + }); + + describe('Find file button', () => { + it('renders Find file button', () => { + expect(findFindFileButton().exists()).toBe(true); + }); + + it('triggers a `focusSearchFile` shortcut when the findFile button is clicked', () => { + jest.spyOn(Shortcuts, 'focusSearchFile').mockResolvedValue(); + findFindFileButton().vm.$emit('click'); + + expect(Shortcuts.focusSearchFile).toHaveBeenCalled(); + }); + + it('emits a tracking event when the Find file button is clicked', () => { + const { trackEventSpy } = bindInternalEventDocument(wrapper.element); + jest.spyOn(Shortcuts, 'focusSearchFile').mockResolvedValue(); + + findFindFileButton().vm.$emit('click'); + + expect(trackEventSpy).toHaveBeenCalledWith('click_find_file_button_on_repository_pages'); + }); + }); + + describe('Compare button', () => { + it('does not render Compare button for root ref', () => { + expect(findCompareButton().exists()).not.toBe(true); + }); + + it('renders Compare button for non-root ref', () => { + wrapper = createComponent({}, 'treePathDecoded', { comparePath: 'test/project/compare' }); + expect(findCompareButton().exists()).toBe(true); + }); + }); + }); + + describe('when rendered for blob view', () => { + it('renders BlobControls component with correct props', () => { + wrapper = createComponent({ refType: 'branch' }); + const blobControls = wrapper.findComponent(BlobControls); + expect(blobControls.exists()).toBe(true); + expect(blobControls.props('projectPath')).toBe('test/project'); + expect(blobControls.props('refType')).toBe(''); + }); + }); + + describe('when isReadmeView is true', () => { + beforeEach(() => { + wrapper = createComponent({}, 'treePathDecoded', { isReadmeView: true }); + }); + + it('does not render RefSelector, Breadcrumbs and History button', () => { + expect(findRefSelector().exists()).toBe(false); + expect(findBreadcrumbs().exists()).toBe(false); + expect(findHistoryButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/repository/utils/url_utility_spec.js b/spec/frontend/repository/utils/url_utility_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..19128340a6f37738943d326f8745b67bbee6c14e --- /dev/null +++ b/spec/frontend/repository/utils/url_utility_spec.js @@ -0,0 +1,38 @@ +import { generateHistoryUrl } from '~/repository/utils/url_utility'; + +describe('Repository URL utilities', () => { + describe('generateHistoryUrl', () => { + it('generates correct URL with path and ref type', () => { + const historyLink = '/-/commits'; + const path = 'path/to/file.js'; + const refType = 'branch'; + + const result = generateHistoryUrl(historyLink, path, refType); + + expect(result.pathname).toBe('/-/commits/path/to/file.js'); + expect(result.searchParams.get('ref_type')).toBe('branch'); + }); + + it('generates correct URL when path is empty', () => { + const historyLink = '/-/commits'; + const path = ''; + const refType = 'tag'; + + const result = generateHistoryUrl(historyLink, path, refType); + + expect(result.pathname).toBe('/-/commits'); + expect(result.searchParams.get('ref_type')).toBe('tag'); + }); + + it('escapes special characters in path', () => { + const historyLink = '/-/commits'; + const path = 'path/to/file with spaces.js'; + const refType = 'branch'; + + const result = generateHistoryUrl(historyLink, path, refType); + + expect(result.pathname).toBe('/-/commits/path/to/file%20with%20spaces.js'); + expect(result.searchParams.get('ref_type')).toBe('branch'); + }); + }); +});