Commit 5cc3b099 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '34820-snippets-header-vue' into 'master'

Introduction of the snippet_header Vue component

See merge request gitlab-org/gitlab!21900
parents d5b8a2c6 860c1cb6
...@@ -3,11 +3,16 @@ import ZenMode from '~/zen_mode'; ...@@ -3,11 +3,16 @@ import ZenMode from '~/zen_mode';
import LineHighlighter from '~/line_highlighter'; import LineHighlighter from '~/line_highlighter';
import BlobViewer from '~/blob/viewer'; import BlobViewer from '~/blob/viewer';
import snippetEmbed from '~/snippet/snippet_embed'; import snippetEmbed from '~/snippet/snippet_embed';
import initSnippetsApp from '~/snippets';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
new LineHighlighter(); // eslint-disable-line no-new if (!gon.features.snippetsVue) {
new BlobViewer(); // eslint-disable-line no-new new LineHighlighter(); // eslint-disable-line no-new
initNotes(); new BlobViewer(); // eslint-disable-line no-new
new ZenMode(); // eslint-disable-line no-new initNotes();
snippetEmbed(); new ZenMode(); // eslint-disable-line no-new
snippetEmbed();
} else {
initSnippetsApp();
}
}); });
<script> <script>
import getSnippet from '../queries/getSnippet.query.graphql'; import GetSnippetQuery from '../queries/snippet.query.graphql';
import SnippetHeader from './snippet_header.vue';
import { GlLoadingIcon } from '@gitlab/ui';
export default { export default {
components: {
SnippetHeader,
GlLoadingIcon,
},
apollo: { apollo: {
snippetData: { snippet: {
query: getSnippet, query: GetSnippetQuery,
variables() { variables() {
return { return {
ids: this.snippetGid, ids: this.snippetGid,
...@@ -21,11 +27,24 @@ export default { ...@@ -21,11 +27,24 @@ export default {
}, },
data() { data() {
return { return {
snippetData: {}, snippet: {},
}; };
}, },
computed: {
isLoading() {
return this.$apollo.queries.snippet.loading;
},
},
}; };
</script> </script>
<template> <template>
<div class="js-snippet-view"></div> <div class="js-snippet-view">
<gl-loading-icon
v-if="isLoading"
:label="__('Loading snippet')"
:size="2"
class="loading-animation prepend-top-20 append-bottom-20"
/>
<snippet-header v-else :snippet="snippet" />
</div>
</template> </template>
<script>
import { __ } from '~/locale';
import {
GlAvatar,
GlIcon,
GlSprintf,
GlButton,
GlModal,
GlAlert,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
} from '@gitlab/ui';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import DeleteSnippetMutation from '../mutations/deleteSnippet.mutation.graphql';
import CanCreatePersonalSnippet from '../queries/userPermissions.query.graphql';
import CanCreateProjectSnippet from '../queries/projectPermissions.query.graphql';
export default {
components: {
GlAvatar,
GlIcon,
GlSprintf,
GlButton,
GlModal,
GlAlert,
GlLoadingIcon,
GlDropdown,
GlDropdownItem,
TimeAgoTooltip,
},
apollo: {
canCreateSnippet: {
query() {
return this.snippet.project ? CanCreateProjectSnippet : CanCreatePersonalSnippet;
},
variables() {
return {
fullPath: this.snippet.project ? this.snippet.project.fullPath : undefined,
};
},
update(data) {
return this.snippet.project
? data.project.userPermissions.createSnippet
: data.currentUser.userPermissions.createSnippet;
},
},
},
props: {
snippet: {
type: Object,
required: true,
},
},
data() {
return {
isDeleting: false,
errorMessage: '',
canCreateSnippet: false,
};
},
computed: {
personalSnippetActions() {
return [
{
condition: this.snippet.userPermissions.updateSnippet,
text: __('Edit'),
href: this.editLink,
click: undefined,
variant: 'outline-info',
cssClass: undefined,
},
{
condition: this.snippet.userPermissions.adminSnippet,
text: __('Delete'),
href: undefined,
click: this.showDeleteModal,
variant: 'outline-danger',
cssClass: 'btn-inverted btn-danger ml-2',
},
{
condition: this.canCreateSnippet,
text: __('New snippet'),
href: this.snippet.project
? `${this.snippet.project.webUrl}/snippets/new`
: '/snippets/new',
click: undefined,
variant: 'outline-success',
cssClass: 'btn-inverted btn-success ml-2',
},
];
},
editLink() {
return `${this.snippet.webUrl}/edit`;
},
visibility() {
return this.snippet.visibilityLevel;
},
snippetVisibilityLevelDescription() {
switch (this.visibility) {
case 'private':
return this.snippet.project !== null
? __('The snippet is visible only to project members.')
: __('The snippet is visible only to me.');
case 'internal':
return __('The snippet is visible to any logged in user.');
default:
return __('The snippet can be accessed without any authentication.');
}
},
visibilityLevelIcon() {
switch (this.visibility) {
case 'private':
return 'lock';
case 'internal':
return 'shield';
default:
return 'earth';
}
},
},
methods: {
redirectToSnippets() {
window.location.pathname = 'dashboard/snippets';
},
closeDeleteModal() {
this.$refs.deleteModal.hide();
},
showDeleteModal() {
this.$refs.deleteModal.show();
},
deleteSnippet() {
this.isDeleting = true;
this.$apollo
.mutate({
mutation: DeleteSnippetMutation,
variables: { id: this.snippet.id },
})
.then(() => {
this.isDeleting = false;
this.errorMessage = undefined;
this.closeDeleteModal();
this.redirectToSnippets();
})
.catch(err => {
this.isDeleting = false;
this.errorMessage = err.message;
});
},
},
};
</script>
<template>
<div class="detail-page-header">
<div class="detail-page-header-body">
<div
class="snippet-box qa-snippet-box has-tooltip d-flex align-items-center append-right-5 mb-1"
:title="snippetVisibilityLevelDescription"
data-container="body"
>
<span class="sr-only">
{{ s__(`VisibilityLevel|${visibility}`) }}
</span>
<gl-icon :name="visibilityLevelIcon" :size="14" />
</div>
<div class="creator">
<gl-sprintf message="Authored %{timeago} by %{author}">
<template #timeago>
<time-ago-tooltip
:time="snippet.createdAt"
tooltip-placement="bottom"
css-class="snippet_updated_ago"
/>
</template>
<template #author>
<a :href="snippet.author.webUrl" class="d-inline">
<gl-avatar :size="24" :src="snippet.author.avatarUrl" />
<span class="bold">{{ snippet.author.name }}</span>
</a>
</template>
</gl-sprintf>
</div>
</div>
<div class="detail-page-header-actions">
<div class="d-none d-sm-block">
<template v-for="(action, index) in personalSnippetActions">
<gl-button
v-if="action.condition"
:key="index"
:variant="action.variant"
:class="action.cssClass"
:href="action.href || undefined"
@click="action.click ? action.click() : undefined"
>
{{ action.text }}
</gl-button>
</template>
</div>
<div class="d-block d-sm-none dropdown">
<gl-dropdown :text="__('Options')" class="w-100" toggle-class="text-center">
<gl-dropdown-item
v-for="(action, index) in personalSnippetActions"
:key="index"
:href="action.href || undefined"
@click="action.click ? action.click() : undefined"
>{{ action.text }}</gl-dropdown-item
>
</gl-dropdown>
</div>
</div>
<gl-modal ref="deleteModal" modal-id="delete-modal" title="Example title">
<template #modal-title>{{ __('Delete snippet?') }}</template>
<gl-alert v-if="errorMessage" variant="danger" class="mb-2" @dismiss="errorMessage = ''">{{
errorMessage
}}</gl-alert>
<gl-sprintf message="Are you sure you want to delete %{name}?">
<template #name
><strong>{{ snippet.title }}</strong></template
>
</gl-sprintf>
<template #modal-footer>
<gl-button @click="closeDeleteModal">{{ __('Cancel') }}</gl-button>
<gl-button
variant="danger"
:disabled="isDeleting"
data-qa-selector="delete_snippet_button"
@click="deleteSnippet"
>
<gl-loading-icon v-if="isDeleting" inline />
{{ __('Delete snippet') }}
</gl-button>
</template>
</gl-modal>
</div>
</template>
fragment Author on Snippet {
author {
name,
avatarUrl,
username,
webUrl
}
}
\ No newline at end of file
fragment Project on Snippet {
project {
fullPath
webUrl
}
}
\ No newline at end of file
fragment SnippetBase on Snippet {
id
title
description
createdAt
updatedAt
visibilityLevel
webUrl
userPermissions {
adminSnippet
updateSnippet
}
}
\ No newline at end of file
mutation DeleteSnippet($id: ID!) {
destroySnippet(input: {id: $id}) {
errors
}
}
\ No newline at end of file
query getSnippet($ids: [ID!]) {
snippets(ids: $ids) {
edges {
node {
title
description
createdAt
updatedAt
visibility
}
}
}
}
query CanCreateProjectSnippet($fullPath: ID!) {
project(fullPath: $fullPath) {
userPermissions {
createSnippet
}
}
}
\ No newline at end of file
#import '../fragments/snippetBase.fragment.graphql'
#import '../fragments/project.fragment.graphql'
#import '../fragments/author.fragment.graphql'
query GetSnippetQuery($ids: [ID!]) {
snippets(ids: $ids) {
edges {
node {
...SnippetBase
...Project
...Author
}
}
}
}
query CanCreatePersonalSnippet {
currentUser {
userPermissions {
createSnippet
}
}
}
\ No newline at end of file
...@@ -3,13 +3,16 @@ ...@@ -3,13 +3,16 @@
- breadcrumb_title @snippet.to_reference - breadcrumb_title @snippet.to_reference
- page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets") - page_title "#{@snippet.title} (#{@snippet.to_reference})", _("Snippets")
= render 'shared/snippets/header' - if Feature.enabled?(:snippets_vue)
#js-snippet-view{ data: {'qa-selector': 'snippet_view', 'snippet-gid': @snippet.to_global_id} }
- else
= render 'shared/snippets/header'
.project-snippets .project-snippets
%article.file-holder.snippet-file-content %article.file-holder.snippet-file-content
= render 'shared/snippets/blob' = render 'shared/snippets/blob'
.row-content-block.top-block.content-component-block .row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true = render 'award_emoji/awards_block', awardable: @snippet, inline: true
#notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true #notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => true
...@@ -5644,6 +5644,12 @@ msgstr "" ...@@ -5644,6 +5644,12 @@ msgstr ""
msgid "Delete list" msgid "Delete list"
msgstr "" msgstr ""
msgid "Delete snippet"
msgstr ""
msgid "Delete snippet?"
msgstr ""
msgid "Delete source branch" msgid "Delete source branch"
msgstr "" msgstr ""
...@@ -10709,6 +10715,9 @@ msgstr "" ...@@ -10709,6 +10715,9 @@ msgstr ""
msgid "Loading issues" msgid "Loading issues"
msgstr "" msgstr ""
msgid "Loading snippet"
msgstr ""
msgid "Loading the GitLab IDE..." msgid "Loading the GitLab IDE..."
msgstr "" msgstr ""
......
...@@ -8,6 +8,7 @@ describe 'Thread Comments Snippet', :js do ...@@ -8,6 +8,7 @@ describe 'Thread Comments Snippet', :js do
let(:snippet) { create(:project_snippet, :private, project: project, author: user) } let(:snippet) { create(:project_snippet, :private, project: project, author: user) }
before do before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
......
...@@ -18,6 +18,7 @@ describe 'Projects > Snippets > Create Snippet', :js do ...@@ -18,6 +18,7 @@ describe 'Projects > Snippets > Create Snippet', :js do
context 'when a user is authenticated' do context 'when a user is authenticated' do
before do before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
...@@ -76,6 +77,10 @@ describe 'Projects > Snippets > Create Snippet', :js do ...@@ -76,6 +77,10 @@ describe 'Projects > Snippets > Create Snippet', :js do
end end
context 'when a user is not authenticated' do context 'when a user is not authenticated' do
before do
stub_feature_flags(snippets_vue: false)
end
it 'shows a public snippet on the index page but not the New snippet button' do it 'shows a public snippet on the index page but not the New snippet button' do
snippet = create(:project_snippet, :public, project: project) snippet = create(:project_snippet, :public, project: project)
......
...@@ -8,6 +8,7 @@ describe 'Projects > Snippets > Project snippet', :js do ...@@ -8,6 +8,7 @@ describe 'Projects > Snippets > Project snippet', :js do
let(:snippet) { create(:project_snippet, project: project, file_name: file_name, content: content) } let(:snippet) { create(:project_snippet, project: project, file_name: file_name, content: content) }
before do before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
end end
......
...@@ -8,6 +8,7 @@ describe 'Projects > Snippets > User comments on a snippet', :js do ...@@ -8,6 +8,7 @@ describe 'Projects > Snippets > User comments on a snippet', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
......
...@@ -8,6 +8,7 @@ describe 'Projects > Snippets > User deletes a snippet' do ...@@ -8,6 +8,7 @@ describe 'Projects > Snippets > User deletes a snippet' do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
......
...@@ -8,6 +8,7 @@ describe 'Projects > Snippets > User updates a snippet' do ...@@ -8,6 +8,7 @@ describe 'Projects > Snippets > User updates a snippet' do
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
......
...@@ -7,6 +7,7 @@ describe 'Reportable note on snippets', :js do ...@@ -7,6 +7,7 @@ describe 'Reportable note on snippets', :js do
let(:project) { create(:project) } let(:project) { create(:project) }
before do before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user) project.add_maintainer(user)
sign_in(user) sign_in(user)
end end
......
import SnippetApp from '~/snippets/components/app.vue'; import SnippetApp from '~/snippets/components/app.vue';
import SnippetHeader from '~/snippets/components/snippet_header.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils'; import { createLocalVue, shallowMount } from '@vue/test-utils';
describe('Snippet view app', () => { describe('Snippet view app', () => {
let wrapper; let wrapper;
let snippetDataMock;
const localVue = createLocalVue(); const localVue = createLocalVue();
const defaultProps = { const defaultProps = {
snippetGid: 'gid://gitlab/PersonalSnippet/35', snippetGid: 'gid://gitlab/PersonalSnippet/42',
}; };
function createComponent({ props = defaultProps, snippetData = {} } = {}) { function createComponent({ props = defaultProps, loading = false } = {}) {
snippetDataMock = jest.fn();
const $apollo = { const $apollo = {
queries: { queries: {
snippetData: snippetDataMock, snippet: {
loading,
},
}, },
}; };
...@@ -25,17 +28,18 @@ describe('Snippet view app', () => { ...@@ -25,17 +28,18 @@ describe('Snippet view app', () => {
...props, ...props,
}, },
}); });
wrapper.setData({
snippetData,
});
} }
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('renders itself', () => { it('renders loader while the query is in flight', () => {
createComponent({ loading: true });
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
});
it('renders SnippetHeader component after the query is finished', () => {
createComponent(); createComponent();
expect(wrapper.find('.js-snippet-view').exists()).toBe(true); expect(wrapper.find(SnippetHeader).exists()).toBe(true);
}); });
}); });
import SnippetHeader from '~/snippets/components/snippet_header.vue';
import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql';
import { ApolloMutation } from 'vue-apollo';
import { GlButton, GlModal } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
describe('Snippet header component', () => {
let wrapper;
const localVue = createLocalVue();
const snippet = {
snippet: {
id: 'gid://gitlab/PersonalSnippet/50',
title: 'The property of Thor',
visibilityLevel: 'private',
webUrl: 'http://personal.dev.null/42',
userPermissions: {
adminSnippet: true,
updateSnippet: true,
reportSnippet: false,
},
project: null,
author: {
name: 'Thor Odinson',
},
},
};
const mutationVariables = {
mutation: DeleteSnippetMutation,
variables: {
id: snippet.snippet.id,
},
};
const errorMsg = 'Foo bar';
const err = { message: errorMsg };
const resolveMutate = jest.fn(() => Promise.resolve());
const rejectMutation = jest.fn(() => Promise.reject(err));
const mutationTypes = {
RESOLVE: resolveMutate,
REJECT: rejectMutation,
};
function createComponent({
loading = false,
permissions = {},
mutationRes = mutationTypes.RESOLVE,
} = {}) {
const defaultProps = Object.assign({}, snippet);
if (permissions) {
Object.assign(defaultProps.snippet.userPermissions, {
...permissions,
});
}
const $apollo = {
queries: {
canCreateSnippet: {
loading,
},
},
mutate: mutationRes,
};
wrapper = shallowMount(SnippetHeader, {
sync: false,
mocks: { $apollo },
localVue,
propsData: {
...defaultProps,
},
stubs: {
ApolloMutation,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('renders itself', () => {
createComponent();
expect(wrapper.find('.detail-page-header').exists()).toBe(true);
});
it('renders action buttons based on permissions', () => {
createComponent({
permissions: {
adminSnippet: false,
updateSnippet: false,
},
});
expect(wrapper.findAll(GlButton).length).toEqual(0);
createComponent({
permissions: {
adminSnippet: true,
updateSnippet: false,
},
});
expect(wrapper.findAll(GlButton).length).toEqual(1);
createComponent({
permissions: {
adminSnippet: true,
updateSnippet: true,
},
});
expect(wrapper.findAll(GlButton).length).toEqual(2);
createComponent({
permissions: {
adminSnippet: true,
updateSnippet: true,
},
});
wrapper.setData({
canCreateSnippet: true,
});
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.findAll(GlButton).length).toEqual(3);
});
});
it('renders modal for deletion of a snippet', () => {
createComponent();
expect(wrapper.find(GlModal).exists()).toBe(true);
});
describe('Delete mutation', () => {
const { location } = window;
beforeEach(() => {
delete window.location;
window.location = {
pathname: '',
};
});
afterEach(() => {
window.location = location;
});
it('dispatches a mutation to delete the snippet with correct variables', () => {
createComponent();
wrapper.vm.deleteSnippet();
expect(mutationTypes.RESOLVE).toHaveBeenCalledWith(mutationVariables);
});
it('sets error message if mutation fails', () => {
createComponent({ mutationRes: mutationTypes.REJECT });
expect(Boolean(wrapper.vm.errorMessage)).toBe(false);
wrapper.vm.deleteSnippet();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.errorMessage).toEqual(errorMsg);
});
});
it('closes modal and redirects to snippets listing in case of successful mutation', () => {
createComponent();
wrapper.vm.closeDeleteModal = jest.fn();
wrapper.vm.deleteSnippet();
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled();
expect(window.location.pathname).toEqual('dashboard/snippets');
});
});
});
});
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