Commit d337289b authored by Etienne Baqué's avatar Etienne Baqué

Merge branch '335378-fix-code-owners-section' into 'master'

Blob refactor: Display code owners info

See merge request gitlab-org/gitlab!74298
parents f64c4df1 fcb029ed
<script>
import { GlIcon, GlLink } from '@gitlab/ui';
import { __ } from '~/locale';
import createFlash from '~/flash';
import { helpPagePath } from '~/helpers/help_page_helper';
import codeOwnersInfoQuery from '../queries/code_owners_info.query.graphql';
import getRefMixin from '../mixins/get_ref';
export default {
i18n: {
title: __('Code owners'),
about: __('About this feature'),
andSeparator: __('and'),
errorMessage: __('An error occurred while loading code owners.'),
},
codeOwnersHelpPath: helpPagePath('user/project/code_owners'),
components: {
GlIcon,
GlLink,
},
mixins: [getRefMixin],
apollo: {
project: {
query: codeOwnersInfoQuery,
variables() {
return {
projectPath: this.projectPath,
filePath: this.filePath,
ref: this.ref,
};
},
skip() {
return !this.filePath;
},
result() {
this.isFetching = false;
},
error() {
createFlash({ message: this.$options.i18n.errorMessage });
},
},
},
props: {
projectPath: {
type: String,
required: true,
},
filePath: {
type: String,
required: false,
default: null,
},
},
data() {
return {
isFetching: false,
project: {
repository: {
blobs: {
nodes: [
{
codeOwners: [],
},
],
},
},
},
};
},
computed: {
blobInfo() {
return this.project?.repository?.blobs?.nodes[0];
},
codeOwners() {
return this.blobInfo?.codeOwners || [];
},
hasCodeOwners() {
return this.filePath && Boolean(this.codeOwners.length);
},
commaSeparateList() {
return this.codeOwners.length > 2;
},
showAndSeparator() {
return this.codeOwners.length > 1;
},
lastListItem() {
return this.codeOwners.length - 1;
},
},
watch: {
filePath() {
this.isFetching = true;
this.$apollo.queries.project.refetch();
},
},
};
</script>
<template>
<div
v-if="hasCodeOwners && !isFetching"
class="well-segment blob-auxiliary-viewer file-owner-content qa-file-owner-content"
>
<gl-icon name="users" data-testid="users-icon" />
<strong>{{ $options.i18n.title }}</strong>
<gl-link :href="$options.codeOwnersHelpPath" target="_blank" :title="$options.i18n.about">
<gl-icon name="question-o" data-testid="help-icon" />
</gl-link>
:
<div
v-for="(owner, index) in codeOwners"
:key="index"
:class="[
{ 'gl-display-inline-block': commaSeparateList, 'gl-display-inline': !commaSeparateList },
]"
data-testid="code-owners"
>
<span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span>
<span v-if="showAndSeparator && index === lastListItem" data-testid="and-separator">{{
$options.i18n.andSeparator
}}</span>
<gl-link :href="owner.webPath" target="_blank" :title="$options.i18n.about">
{{ owner.name }}
</gl-link>
</div>
</div>
</template>
...@@ -111,7 +111,7 @@ export default { ...@@ -111,7 +111,7 @@ export default {
</script> </script>
<template> <template>
<div class="info-well d-none d-sm-flex project-last-commit commit p-3"> <div class="well-segment commit gl-p-5 gl-w-full">
<gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" /> <gl-loading-icon v-if="isLoading" size="md" color="dark" class="m-auto" />
<template v-else-if="commit"> <template v-else-if="commit">
<user-avatar-link <user-avatar-link
......
...@@ -9,6 +9,7 @@ import App from './components/app.vue'; ...@@ -9,6 +9,7 @@ import App from './components/app.vue';
import Breadcrumbs from './components/breadcrumbs.vue'; import Breadcrumbs from './components/breadcrumbs.vue';
import DirectoryDownloadLinks from './components/directory_download_links.vue'; import DirectoryDownloadLinks from './components/directory_download_links.vue';
import LastCommit from './components/last_commit.vue'; import LastCommit from './components/last_commit.vue';
import CodeOwners from './components/code_owners.vue';
import apolloProvider from './graphql'; import apolloProvider from './graphql';
import commitsQuery from './queries/commits.query.graphql'; import commitsQuery from './queries/commits.query.graphql';
import projectPathQuery from './queries/project_path.query.graphql'; import projectPathQuery from './queries/project_path.query.graphql';
...@@ -71,7 +72,23 @@ export default function setupVueRepositoryList() { ...@@ -71,7 +72,23 @@ export default function setupVueRepositoryList() {
}, },
}); });
const initCodeOwnersApp = () =>
new Vue({
el: document.getElementById('js-code-owners'),
router,
apolloProvider,
render(h) {
return h(CodeOwners, {
props: {
filePath: this.$route.params.path,
projectPath,
},
});
},
});
initLastCommitApp(); initLastCommitApp();
initCodeOwnersApp();
router.afterEach(({ params: { path } }) => { router.afterEach(({ params: { path } }) => {
setTitle(path, ref, fullName); setTitle(path, ref, fullName);
......
query getCodeOwnersInfo($projectPath: ID!, $filePath: String!, $ref: String!) {
project(fullPath: $projectPath) {
id
repository {
blobs(paths: [$filePath], ref: $ref) {
nodes {
codeOwners {
name
webPath
}
}
}
}
}
}
...@@ -71,6 +71,10 @@ module Types ...@@ -71,6 +71,10 @@ module Types
field :pipeline_editor_path, GraphQL::Types::String, null: true, field :pipeline_editor_path, GraphQL::Types::String, null: true,
description: 'Web path to edit .gitlab-ci.yml file.' description: 'Web path to edit .gitlab-ci.yml file.'
field :code_owners, [Types::UserType], null: true,
description: 'List of code owners for the blob.',
calls_gitaly: true
field :file_type, GraphQL::Types::String, null: true, field :file_type, GraphQL::Types::String, null: true,
description: 'Expected format of the blob based on the extension.' description: 'Expected format of the blob based on the extension.'
......
...@@ -66,6 +66,10 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated ...@@ -66,6 +66,10 @@ class BlobPresenter < Gitlab::View::Presenter::Delegated
project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default project_ci_pipeline_editor_path(project, branch_name: blob.commit_id) if can_collaborate_with_project?(project) && blob.path == project.ci_config_path_or_default
end end
def code_owners
Gitlab::CodeOwners.for_blob(project, blob)
end
def fork_and_edit_path def fork_and_edit_path
fork_path_for_current_user(project, edit_blob_path) fork_path_for_current_user(project, edit_blob_path)
end end
......
...@@ -10,10 +10,11 @@ ...@@ -10,10 +10,11 @@
.nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch .nav-block.gl-display-flex.gl-xs-flex-direction-column.gl-align-items-stretch
= render 'projects/tree/tree_header', tree: @tree = render 'projects/tree/tree_header', tree: @tree
#js-last-commit .info-well.gl-display-none.gl-sm-display-flex.project-last-commit.gl-flex-direction-column
.info-well.gl-display-none.gl-sm-display-flex.project-last-commit #js-last-commit.gl-m-auto
.gl-spinner-container.m-auto .gl-spinner-container.m-auto
= loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom') = loading_icon(size: 'md', color: 'dark', css_class: 'align-text-bottom')
#js-code-owners
- if is_project_overview - if is_project_overview
.project-buttons.gl-mb-3.js-show-on-project-root .project-buttons.gl-mb-3.js-show-on-project-root
......
...@@ -14073,6 +14073,7 @@ Returns [`Tree`](#tree). ...@@ -14073,6 +14073,7 @@ Returns [`Tree`](#tree).
| Name | Type | Description | | Name | Type | Description |
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| <a id="repositoryblobcanmodifyblob"></a>`canModifyBlob` | [`Boolean`](#boolean) | Whether the current user can modify the blob. | | <a id="repositoryblobcanmodifyblob"></a>`canModifyBlob` | [`Boolean`](#boolean) | Whether the current user can modify the blob. |
| <a id="repositoryblobcodeowners"></a>`codeOwners` | [`[UserCore!]`](#usercore) | List of code owners for the blob. |
| <a id="repositoryblobeditblobpath"></a>`editBlobPath` | [`String`](#string) | Web path to edit the blob in the old-style editor. | | <a id="repositoryblobeditblobpath"></a>`editBlobPath` | [`String`](#string) | Web path to edit the blob in the old-style editor. |
| <a id="repositoryblobexternalstorageurl"></a>`externalStorageUrl` | [`String`](#string) | Web path to download the raw blob via external storage, if enabled. | | <a id="repositoryblobexternalstorageurl"></a>`externalStorageUrl` | [`String`](#string) | Web path to download the raw blob via external storage, if enabled. |
| <a id="repositoryblobfiletype"></a>`fileType` | [`String`](#string) | Expected format of the blob based on the extension. | | <a id="repositoryblobfiletype"></a>`fileType` | [`String`](#string) | Expected format of the blob based on the extension. |
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BlobPresenter do
let_it_be(:project) { create(:project, :repository) }
let_it_be(:user) { project.owner }
let(:blob) { project.repository.blob_at('HEAD', 'files/ruby/regex.rb') }
subject(:presenter) { described_class.new(blob, current_user: user) }
describe '#code_owners' do
before do
allow(Gitlab::CodeOwners).to receive(:for_blob).with(project, blob).and_return([user])
end
it { expect(presenter.code_owners).to match_array([user]) }
end
end
...@@ -3746,6 +3746,9 @@ msgstr "" ...@@ -3746,6 +3746,9 @@ msgstr ""
msgid "An error occurred while loading chart data" msgid "An error occurred while loading chart data"
msgstr "" msgstr ""
msgid "An error occurred while loading code owners."
msgstr ""
msgid "An error occurred while loading commit signatures" msgid "An error occurred while loading commit signatures"
msgstr "" msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Code owners component matches the snapshot 1`] = `<!---->`;
exports[`Code owners component matches the snapshot 2`] = `
<div
class="well-segment blob-auxiliary-viewer file-owner-content qa-file-owner-content"
>
<gl-icon-stub
data-testid="users-icon"
name="users"
size="16"
/>
<strong>
Code owners
</strong>
<gl-link-stub
href="/help/user/project/code_owners"
target="_blank"
title="About this feature"
>
<gl-icon-stub
data-testid="help-icon"
name="question-o"
size="16"
/>
</gl-link-stub>
:
<div
class="gl-display-inline"
data-testid="code-owners"
>
<!---->
<!---->
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
</div>
`;
exports[`Code owners component matches the snapshot 3`] = `
<div
class="well-segment blob-auxiliary-viewer file-owner-content qa-file-owner-content"
>
<gl-icon-stub
data-testid="users-icon"
name="users"
size="16"
/>
<strong>
Code owners
</strong>
<gl-link-stub
href="/help/user/project/code_owners"
target="_blank"
title="About this feature"
>
<gl-icon-stub
data-testid="help-icon"
name="question-o"
size="16"
/>
</gl-link-stub>
:
<div
class="gl-display-inline"
data-testid="code-owners"
>
<!---->
<!---->
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
<div
class="gl-display-inline"
data-testid="code-owners"
>
<!---->
<span
data-testid="and-separator"
>
and
</span>
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
</div>
`;
exports[`Code owners component matches the snapshot 4`] = `
<div
class="well-segment blob-auxiliary-viewer file-owner-content qa-file-owner-content"
>
<gl-icon-stub
data-testid="users-icon"
name="users"
size="16"
/>
<strong>
Code owners
</strong>
<gl-link-stub
href="/help/user/project/code_owners"
target="_blank"
title="About this feature"
>
<gl-icon-stub
data-testid="help-icon"
name="question-o"
size="16"
/>
</gl-link-stub>
:
<div
class="gl-display-inline-block"
data-testid="code-owners"
>
<!---->
<!---->
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
<div
class="gl-display-inline-block"
data-testid="code-owners"
>
<span
data-testid="comma-separator"
>
,
</span>
<!---->
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
<div
class="gl-display-inline-block"
data-testid="code-owners"
>
<span
data-testid="comma-separator"
>
,
</span>
<span
data-testid="and-separator"
>
and
</span>
<gl-link-stub
href="path/to/@johnDoe"
target="_blank"
title="About this feature"
>
John Doe
</gl-link-stub>
</div>
</div>
`;
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
exports[`Repository last commit component renders commit widget 1`] = ` exports[`Repository last commit component renders commit widget 1`] = `
<div <div
class="info-well d-none d-sm-flex project-last-commit commit p-3" class="well-segment commit gl-p-5 gl-w-full"
> >
<user-avatar-link-stub <user-avatar-link-stub
class="avatar-cell" class="avatar-cell"
...@@ -108,7 +108,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` ...@@ -108,7 +108,7 @@ exports[`Repository last commit component renders commit widget 1`] = `
exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = ` exports[`Repository last commit component renders the signature HTML as returned by the backend 1`] = `
<div <div
class="info-well d-none d-sm-flex project-last-commit commit p-3" class="well-segment commit gl-p-5 gl-w-full"
> >
<user-avatar-link-stub <user-avatar-link-stub
class="avatar-cell" class="avatar-cell"
......
import { GlLink } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import CodeOwners from '~/repository/components/code_owners.vue';
import codeOwnersInfoQuery from '~/repository/queries/code_owners_info.query.graphql';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { codeOwnerMock, codeOwnersDataMock, refMock } from '../mock_data';
let wrapper;
let mockResolver;
const localVue = createLocalVue();
const createComponent = async (codeOwners = [codeOwnerMock]) => {
localVue.use(VueApollo);
const project = {
...codeOwnersDataMock,
repository: {
blobs: {
nodes: [{ codeOwners }],
},
},
};
mockResolver = jest.fn().mockResolvedValue({ data: { project } });
wrapper = extendedWrapper(
shallowMount(CodeOwners, {
localVue,
apolloProvider: createMockApollo([[codeOwnersInfoQuery, mockResolver]]),
propsData: { projectPath: 'some/project', filePath: 'some/file' },
mixins: [{ data: () => ({ ref: refMock }) }],
}),
);
wrapper.setData({ isFetching: false });
await waitForPromises();
};
describe('Code owners component', () => {
const findHelpIcon = () => wrapper.findByTestId('help-icon');
const findUsersIcon = () => wrapper.findByTestId('users-icon');
const findCodeOwners = () => wrapper.findAllByTestId('code-owners');
const findCommaSeparators = () => wrapper.findAllByTestId('comma-separator');
const findAndSeparator = () => wrapper.findAllByTestId('and-separator');
const findLink = () => wrapper.findComponent(GlLink);
beforeEach(() => createComponent());
afterEach(() => wrapper.destroy());
describe('help link', () => {
it('renders a GlLink component', () => {
expect(findLink().exists()).toBe(true);
expect(findLink().attributes('href')).toBe('/help/user/project/code_owners');
expect(findLink().attributes('target')).toBe('_blank');
expect(findLink().attributes('title')).toBe('About this feature');
});
it('renders a Help icon', () => {
expect(findHelpIcon().exists()).toBe(true);
expect(findHelpIcon().props('name')).toBe('question-o');
});
});
it('renders a Users icon', () => {
expect(findUsersIcon().exists()).toBe(true);
expect(findUsersIcon().props('name')).toBe('users');
});
it.each`
codeOwners | commaSeparators | hasAndSeparator
${[]} | ${0} | ${false}
${[codeOwnerMock]} | ${0} | ${false}
${[codeOwnerMock, codeOwnerMock]} | ${0} | ${true}
${[codeOwnerMock, codeOwnerMock, codeOwnerMock]} | ${2} | ${true}
`('matches the snapshot', async ({ codeOwners, commaSeparators, hasAndSeparator }) => {
await createComponent(codeOwners);
expect(findCommaSeparators().length).toBe(commaSeparators);
expect(findAndSeparator().exists()).toBe(hasAndSeparator);
expect(findCodeOwners().length).toBe(codeOwners.length);
expect(wrapper.element).toMatchSnapshot();
});
});
...@@ -55,3 +55,18 @@ export const projectMock = { ...@@ -55,3 +55,18 @@ export const projectMock = {
export const propsMock = { path: 'some_file.js', projectPath: 'some/path' }; export const propsMock = { path: 'some_file.js', projectPath: 'some/path' };
export const refMock = 'default-ref'; export const refMock = 'default-ref';
export const codeOwnerMock = { name: 'John Doe', webPath: 'path/to/@johnDoe' };
export const codeOwnersDataMock = {
id: '1234',
repository: {
blobs: {
nodes: [
{
codeOwners: [],
},
],
},
},
};
...@@ -24,6 +24,7 @@ RSpec.describe Types::Repository::BlobType do ...@@ -24,6 +24,7 @@ RSpec.describe Types::Repository::BlobType do
:raw_path, :raw_path,
:replace_path, :replace_path,
:pipeline_editor_path, :pipeline_editor_path,
:code_owners,
:simple_viewer, :simple_viewer,
:rich_viewer, :rich_viewer,
:plain_data, :plain_data,
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment