Commit 38e8ead9 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '196794-blob-header' into 'master'

Resolve "New re-usable component for Blob File Header"

Closes #196794

See merge request gitlab-org/gitlab!24788
parents f398d7e3 4f760db3
<script>
import ViewerSwitcher from './blob_header_viewer_switcher.vue';
import DefaultActions from './blob_header_default_actions.vue';
import BlobFilepath from './blob_header_filepath.vue';
import eventHub from '../event_hub';
import { RICH_BLOB_VIEWER, SIMPLE_BLOB_VIEWER } from './constants';
export default {
components: {
ViewerSwitcher,
DefaultActions,
BlobFilepath,
},
props: {
blob: {
type: Object,
required: true,
},
hideDefaultActions: {
type: Boolean,
required: false,
default: false,
},
hideViewerSwitcher: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
activeViewer: this.blob.richViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
};
},
computed: {
showViewerSwitcher() {
return !this.hideViewerSwitcher && Boolean(this.blob.simpleViewer && this.blob.richViewer);
},
showDefaultActions() {
return !this.hideDefaultActions;
},
},
created() {
if (this.showViewerSwitcher) {
eventHub.$on('switch-viewer', this.setActiveViewer);
}
},
beforeDestroy() {
if (this.showViewerSwitcher) {
eventHub.$off('switch-viewer', this.setActiveViewer);
}
},
methods: {
setActiveViewer(viewer) {
this.activeViewer = viewer;
},
},
};
</script>
<template>
<div class="js-file-title file-title-flex-parent">
<blob-filepath :blob="blob">
<template #filepathPrepend>
<slot name="prepend"></slot>
</template>
</blob-filepath>
<div class="file-actions d-none d-sm-block">
<viewer-switcher v-if="showViewerSwitcher" :blob="blob" :active-viewer="activeViewer" />
<slot name="actions"></slot>
<default-actions v-if="showDefaultActions" :blob="blob" :active-viewer="activeViewer" />
</div>
</div>
</template>
<script>
import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { BTN_COPY_CONTENTS_TITLE, BTN_DOWNLOAD_TITLE, BTN_RAW_TITLE } from './constants';
import {
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
BTN_RAW_TITLE,
RICH_BLOB_VIEWER,
SIMPLE_BLOB_VIEWER,
} from './constants';
import eventHub from '../event_hub';
export default {
components: {
......@@ -16,6 +23,11 @@ export default {
type: Object,
required: true,
},
activeViewer: {
type: String,
default: SIMPLE_BLOB_VIEWER,
required: false,
},
},
computed: {
rawUrl() {
......@@ -24,10 +36,13 @@ export default {
downloadUrl() {
return `${this.blob.rawPath}?inline=false`;
},
copyDisabled() {
return this.activeViewer === RICH_BLOB_VIEWER;
},
},
methods: {
requestCopyContents() {
this.$emit('copy');
eventHub.$emit('copy');
},
},
BTN_COPY_CONTENTS_TITLE,
......@@ -41,6 +56,7 @@ export default {
v-gl-tooltip.hover
:aria-label="$options.BTN_COPY_CONTENTS_TITLE"
:title="$options.BTN_COPY_CONTENTS_TITLE"
:disabled="copyDisabled"
@click="requestCopyContents"
>
<gl-icon name="copy-to-clipboard" :size="14" />
......
......@@ -6,6 +6,7 @@ import {
SIMPLE_BLOB_VIEWER,
SIMPLE_BLOB_VIEWER_TITLE,
} from './constants';
import eventHub from '../event_hub';
export default {
components: {
......@@ -21,25 +22,24 @@ export default {
type: Object,
required: true,
},
activeViewer: {
type: String,
default: SIMPLE_BLOB_VIEWER,
required: false,
},
data() {
return {
viewer: this.blob.richViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
};
},
computed: {
isSimpleViewer() {
return this.viewer === SIMPLE_BLOB_VIEWER;
return this.activeViewer === SIMPLE_BLOB_VIEWER;
},
isRichViewer() {
return this.viewer === RICH_BLOB_VIEWER;
return this.activeViewer === RICH_BLOB_VIEWER;
},
},
methods: {
switchToViewer(viewer) {
if (viewer !== this.viewer) {
this.viewer = viewer;
this.$emit('switch-viewer', viewer);
if (viewer !== this.activeViewer) {
eventHub.$emit('switch-viewer', viewer);
}
},
},
......
import Vue from 'vue';
export default new Vue();
fragment BlobViewer on SnippetBlobViewer {
collapsed
loadingPartialName
renderError
tooLarge
}
<script>
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import { SNIPPET_VISIBILITY_PUBLIC } from '../constants';
import BlobHeader from '~/blob/components/blob_header.vue';
import GetSnippetBlobQuery from '../queries/snippet.blob.query.graphql';
import { GlLoadingIcon } from '@gitlab/ui';
export default {
components: {
BlobEmbeddable,
BlobHeader,
GlLoadingIcon,
},
apollo: {
blob: {
query: GetSnippetBlobQuery,
variables() {
return {
ids: this.snippet.id,
};
},
update: data => data.snippets.edges[0].node.blob,
},
},
props: {
snippet: {
......@@ -12,15 +28,32 @@ export default {
required: true,
},
},
data() {
return {
blob: {},
};
},
computed: {
embeddable() {
return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
},
isBlobLoading() {
return this.$apollo.queries.blob.loading;
},
},
};
</script>
<template>
<div>
<blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" />
<gl-loading-icon
v-if="isBlobLoading"
:label="__('Loading blob')"
:size="2"
class="prepend-top-20 append-bottom-20"
/>
<article v-else class="file-holder snippet-file-content">
<blob-header :blob="blob" />
</article>
</div>
</template>
#import '~/graphql_shared/fragments/blobviewer.fragment.graphql'
query SnippetBlobFull($ids: [ID!]) {
snippets(ids: $ids) {
edges {
node {
id
blob {
binary
name
path
rawPath
size
simpleViewer {
...BlobViewer
}
richViewer {
...BlobViewer
}
}
}
}
}
}
......@@ -11419,6 +11419,9 @@ msgstr ""
msgid "Live preview"
msgstr ""
msgid "Loading blob"
msgstr ""
msgid "Loading contribution stats for group members"
msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Blob Header Default Actions rendering matches the snapshot 1`] = `
<div
class="js-file-title file-title-flex-parent"
>
<blob-filepath-stub
blob="[object Object]"
/>
<div
class="file-actions d-none d-sm-block"
>
<viewer-switcher-stub
activeviewer="rich"
blob="[object Object]"
/>
<default-actions-stub
activeviewer="rich"
blob="[object Object]"
/>
</div>
</div>
`;
......@@ -4,9 +4,11 @@ import {
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
BTN_RAW_TITLE,
RICH_BLOB_VIEWER,
} from '~/blob/components/constants';
import { GlButtonGroup, GlButton } from '@gitlab/ui';
import { Blob } from './mock_data';
import eventHub from '~/blob/event_hub';
describe('Blob Header Default Actions', () => {
let wrapper;
......@@ -14,10 +16,11 @@ describe('Blob Header Default Actions', () => {
let buttons;
const hrefPrefix = 'http://localhost';
function createComponent(props = {}) {
function createComponent(blobProps = {}, propsData = {}) {
wrapper = mount(BlobHeaderActions, {
propsData: {
blob: Object.assign({}, Blob, props),
blob: Object.assign({}, Blob, blobProps),
...propsData,
},
});
}
......@@ -51,14 +54,30 @@ describe('Blob Header Default Actions', () => {
it('correct href attribute on Download button', () => {
expect(buttons.at(2).vm.$el.href).toBe(`${hrefPrefix}${Blob.rawPath}?inline=false`);
});
it('does not render "Copy file contents" button as disables if the viewer is Simple', () => {
expect(buttons.at(0).attributes('disabled')).toBeUndefined();
});
it('renders "Copy file contents" button as disables if the viewer is Rich', () => {
createComponent(
{},
{
activeViewer: RICH_BLOB_VIEWER,
},
);
buttons = wrapper.findAll(GlButton);
expect(buttons.at(0).attributes('disabled')).toBeTruthy();
});
});
describe('functionally', () => {
it('emits an event when a Copy Contents button is clicked', () => {
jest.spyOn(wrapper.vm, '$emit');
jest.spyOn(eventHub, '$emit');
buttons.at(0).vm.$emit('click');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('copy');
expect(eventHub.$emit).toHaveBeenCalledWith('copy');
});
});
});
import { shallowMount, mount } from '@vue/test-utils';
import BlobHeader from '~/blob/components/blob_header.vue';
import ViewerSwitcher from '~/blob/components/blob_header_viewer_switcher.vue';
import DefaultActions from '~/blob/components/blob_header_default_actions.vue';
import BlobFilepath from '~/blob/components/blob_header_filepath.vue';
import eventHub from '~/blob/event_hub';
import { Blob } from './mock_data';
describe('Blob Header Default Actions', () => {
let wrapper;
function createComponent(blobProps = {}, options = {}, propsData = {}, shouldMount = false) {
const method = shouldMount ? mount : shallowMount;
wrapper = method.call(this, BlobHeader, {
propsData: {
blob: Object.assign({}, Blob, blobProps),
...propsData,
},
...options,
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('rendering', () => {
const slots = {
prepend: 'Foo Prepend',
actions: 'Actions Bar',
};
it('matches the snapshot', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('renders all components', () => {
createComponent();
expect(wrapper.find(ViewerSwitcher).exists()).toBe(true);
expect(wrapper.find(DefaultActions).exists()).toBe(true);
expect(wrapper.find(BlobFilepath).exists()).toBe(true);
});
it('does not render viewer switcher if the blob has only the simple viewer', () => {
createComponent({
richViewer: null,
});
expect(wrapper.find(ViewerSwitcher).exists()).toBe(false);
});
it('does not render viewer switcher if a corresponding prop is passed', () => {
createComponent(
{},
{},
{
hideViewerSwitcher: true,
},
);
expect(wrapper.find(ViewerSwitcher).exists()).toBe(false);
});
it('does not render default actions is corresponding prop is passed', () => {
createComponent(
{},
{},
{
hideDefaultActions: true,
},
);
expect(wrapper.find(DefaultActions).exists()).toBe(false);
});
Object.keys(slots).forEach(slot => {
it('renders the slots', () => {
const slotContent = slots[slot];
createComponent(
{},
{
scopedSlots: {
[slot]: `<span>${slotContent}</span>`,
},
},
{},
true,
);
expect(wrapper.text()).toContain(slotContent);
});
});
});
describe('functionality', () => {
const newViewer = 'Foo Bar';
it('listens to "switch-view" event when viewer switcher is shown and updates activeViewer', () => {
expect(wrapper.vm.showViewerSwitcher).toBe(true);
eventHub.$emit('switch-viewer', newViewer);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.activeViewer).toBe(newViewer);
});
});
it('does not update active viewer if the switcher is not shown', () => {
const activeViewer = 'Alpha Beta';
createComponent(
{},
{
data() {
return {
activeViewer,
};
},
},
{
hideViewerSwitcher: true,
},
);
expect(wrapper.vm.showViewerSwitcher).toBe(false);
eventHub.$emit('switch-viewer', newViewer);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.activeViewer).toBe(activeViewer);
});
});
});
});
......@@ -8,14 +8,16 @@ import {
} from '~/blob/components/constants';
import { GlButtonGroup, GlButton } from '@gitlab/ui';
import { Blob } from './mock_data';
import eventHub from '~/blob/event_hub';
describe('Blob Header Viewer Switcher', () => {
let wrapper;
function createComponent(props = {}) {
function createComponent(blobProps = {}, propsData = {}) {
wrapper = mount(BlobHeaderViewerSwitcher, {
propsData: {
blob: Object.assign({}, Blob, props),
blob: Object.assign({}, Blob, blobProps),
...propsData,
},
});
}
......@@ -25,14 +27,9 @@ describe('Blob Header Viewer Switcher', () => {
});
describe('intiialization', () => {
it('is initialized with rich viewer as preselected when richViewer exists', () => {
it('is initialized with simple viewer as active', () => {
createComponent();
expect(wrapper.vm.viewer).toBe(RICH_BLOB_VIEWER);
});
it('is initialized with simple viewer as preselected when richViewer does not exists', () => {
createComponent({ richViewer: null });
expect(wrapper.vm.viewer).toBe(SIMPLE_BLOB_VIEWER);
expect(wrapper.vm.activeViewer).toBe(SIMPLE_BLOB_VIEWER);
});
});
......@@ -63,46 +60,42 @@ describe('Blob Header Viewer Switcher', () => {
let simpleBtn;
let richBtn;
beforeEach(() => {
createComponent();
function factory(propsOptions = {}) {
createComponent({}, propsOptions);
buttons = wrapper.findAll(GlButton);
simpleBtn = buttons.at(0);
richBtn = buttons.at(1);
});
it('does not switch the viewer if the selected one is already active', () => {
jest.spyOn(wrapper.vm, '$emit');
jest.spyOn(eventHub, '$emit');
}
expect(wrapper.vm.viewer).toBe(RICH_BLOB_VIEWER);
richBtn.vm.$emit('click');
expect(wrapper.vm.viewer).toBe(RICH_BLOB_VIEWER);
expect(wrapper.vm.$emit).not.toHaveBeenCalled();
it('does not switch the viewer if the selected one is already active', () => {
factory();
expect(wrapper.vm.activeViewer).toBe(SIMPLE_BLOB_VIEWER);
simpleBtn.vm.$emit('click');
expect(wrapper.vm.activeViewer).toBe(SIMPLE_BLOB_VIEWER);
expect(eventHub.$emit).not.toHaveBeenCalled();
});
it('emits an event when a Simple Viewer button is clicked', () => {
jest.spyOn(wrapper.vm, '$emit');
it('emits an event when a Rich Viewer button is clicked', () => {
factory();
expect(wrapper.vm.activeViewer).toBe(SIMPLE_BLOB_VIEWER);
simpleBtn.vm.$emit('click');
richBtn.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.viewer).toBe(SIMPLE_BLOB_VIEWER);
expect(wrapper.vm.$emit).toHaveBeenCalledWith('switch-viewer', SIMPLE_BLOB_VIEWER);
expect(eventHub.$emit).toHaveBeenCalledWith('switch-viewer', RICH_BLOB_VIEWER);
});
});
it('emits an event when a Rich Viewer button is clicked', () => {
jest.spyOn(wrapper.vm, '$emit');
wrapper.setData({ viewer: SIMPLE_BLOB_VIEWER });
it('emits an event when a Simple Viewer button is clicked', () => {
factory({
activeViewer: RICH_BLOB_VIEWER,
});
simpleBtn.vm.$emit('click');
return wrapper.vm
.$nextTick()
.then(() => {
richBtn.vm.$emit('click');
})
.then(() => {
expect(wrapper.vm.viewer).toBe(RICH_BLOB_VIEWER);
expect(wrapper.vm.$emit).toHaveBeenCalledWith('switch-viewer', RICH_BLOB_VIEWER);
return wrapper.vm.$nextTick().then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('switch-viewer', SIMPLE_BLOB_VIEWER);
});
});
});
......
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue';
import BlobHeader from '~/blob/components/blob_header.vue';
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import {
SNIPPET_VISIBILITY_PRIVATE,
......@@ -15,7 +17,15 @@ describe('Blob Embeddable', () => {
visibilityLevel: SNIPPET_VISIBILITY_PUBLIC,
};
function createComponent(props = {}) {
function createComponent(props = {}, loading = false) {
const $apollo = {
queries: {
blob: {
loading,
},
},
};
wrapper = shallowMount(SnippetBlobView, {
propsData: {
snippet: {
......@@ -23,32 +33,44 @@ describe('Blob Embeddable', () => {
...props,
},
},
mocks: { $apollo },
});
wrapper.vm.$apollo.queries.blob.loading = false;
}
afterEach(() => {
wrapper.destroy();
});
it('renders blob-embeddable component', () => {
describe('rendering', () => {
it('renders correct components', () => {
createComponent();
expect(wrapper.find(BlobEmbeddable).exists()).toBe(true);
expect(wrapper.find(BlobHeader).exists()).toBe(true);
});
it('does not render blob-embeddable for internal snippet', () => {
it.each([SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PRIVATE, 'foo'])(
'does not render blob-embeddable by default',
visibilityLevel => {
createComponent({
visibilityLevel: SNIPPET_VISIBILITY_INTERNAL,
visibilityLevel,
});
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
},
);
it('does render blob-embeddable for public snippet', () => {
createComponent({
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE,
visibilityLevel: SNIPPET_VISIBILITY_PUBLIC,
});
expect(wrapper.find(BlobEmbeddable).exists()).toBe(true);
});
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
createComponent({
visibilityLevel: 'foo',
it('shows loading icon while blob data is in flight', () => {
createComponent({}, true);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find('.snippet-file-content').exists()).toBe(false);
});
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
});
});
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