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';
import LineHighlighter from '~/line_highlighter';
import BlobViewer from '~/blob/viewer';
import snippetEmbed from '~/snippet/snippet_embed';
import initSnippetsApp from '~/snippets';
document.addEventListener('DOMContentLoaded', () => {
new LineHighlighter(); // eslint-disable-line no-new
new BlobViewer(); // eslint-disable-line no-new
initNotes();
new ZenMode(); // eslint-disable-line no-new
snippetEmbed();
if (!gon.features.snippetsVue) {
new LineHighlighter(); // eslint-disable-line no-new
new BlobViewer(); // eslint-disable-line no-new
initNotes();
new ZenMode(); // eslint-disable-line no-new
snippetEmbed();
} else {
initSnippetsApp();
}
});
<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 {
components: {
SnippetHeader,
GlLoadingIcon,
},
apollo: {
snippetData: {
query: getSnippet,
snippet: {
query: GetSnippetQuery,
variables() {
return {
ids: this.snippetGid,
......@@ -21,11 +27,24 @@ export default {
},
data() {
return {
snippetData: {},
snippet: {},
};
},
computed: {
isLoading() {
return this.$apollo.queries.snippet.loading;
},
},
};
</script>
<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>
<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 @@
- breadcrumb_title @snippet.to_reference
- 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
%article.file-holder.snippet-file-content
= render 'shared/snippets/blob'
.project-snippets
%article.file-holder.snippet-file-content
= render 'shared/snippets/blob'
.row-content-block.top-block.content-component-block
= render 'award_emoji/awards_block', awardable: @snippet, inline: true
.row-content-block.top-block.content-component-block
= 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 ""
msgid "Delete list"
msgstr ""
msgid "Delete snippet"
msgstr ""
msgid "Delete snippet?"
msgstr ""
msgid "Delete source branch"
msgstr ""
......@@ -10709,6 +10715,9 @@ msgstr ""
msgid "Loading issues"
msgstr ""
msgid "Loading snippet"
msgstr ""
msgid "Loading the GitLab IDE..."
msgstr ""
......
......@@ -8,6 +8,7 @@ describe 'Thread Comments Snippet', :js do
let(:snippet) { create(:project_snippet, :private, project: project, author: user) }
before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user)
sign_in(user)
......
......@@ -18,6 +18,7 @@ describe 'Projects > Snippets > Create Snippet', :js do
context 'when a user is authenticated' do
before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user)
sign_in(user)
......@@ -76,6 +77,10 @@ describe 'Projects > Snippets > Create Snippet', :js do
end
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
snippet = create(:project_snippet, :public, project: project)
......
......@@ -8,6 +8,7 @@ describe 'Projects > Snippets > Project snippet', :js do
let(:snippet) { create(:project_snippet, project: project, file_name: file_name, content: content) }
before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user)
sign_in(user)
end
......
......@@ -8,6 +8,7 @@ describe 'Projects > Snippets > User comments on a snippet', :js do
let(:user) { create(:user) }
before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user)
sign_in(user)
......
......@@ -8,6 +8,7 @@ describe 'Projects > Snippets > User deletes a snippet' do
let(:user) { create(:user) }
before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user)
sign_in(user)
......
......@@ -8,6 +8,7 @@ describe 'Projects > Snippets > User updates a snippet' do
let(:user) { create(:user) }
before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user)
sign_in(user)
......
......@@ -7,6 +7,7 @@ describe 'Reportable note on snippets', :js do
let(:project) { create(:project) }
before do
stub_feature_flags(snippets_vue: false)
project.add_maintainer(user)
sign_in(user)
end
......
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';
describe('Snippet view app', () => {
let wrapper;
let snippetDataMock;
const localVue = createLocalVue();
const defaultProps = {
snippetGid: 'gid://gitlab/PersonalSnippet/35',
snippetGid: 'gid://gitlab/PersonalSnippet/42',
};
function createComponent({ props = defaultProps, snippetData = {} } = {}) {
snippetDataMock = jest.fn();
function createComponent({ props = defaultProps, loading = false } = {}) {
const $apollo = {
queries: {
snippetData: snippetDataMock,
snippet: {
loading,
},
},
};
......@@ -25,17 +28,18 @@ describe('Snippet view app', () => {
...props,
},
});
wrapper.setData({
snippetData,
});
}
afterEach(() => {
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();
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