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> <script>
import { GlButton, GlButtonGroup, GlIcon, GlTooltipDirective } from '@gitlab/ui'; 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 { export default {
components: { components: {
...@@ -16,6 +23,11 @@ export default { ...@@ -16,6 +23,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
activeViewer: {
type: String,
default: SIMPLE_BLOB_VIEWER,
required: false,
},
}, },
computed: { computed: {
rawUrl() { rawUrl() {
...@@ -24,10 +36,13 @@ export default { ...@@ -24,10 +36,13 @@ export default {
downloadUrl() { downloadUrl() {
return `${this.blob.rawPath}?inline=false`; return `${this.blob.rawPath}?inline=false`;
}, },
copyDisabled() {
return this.activeViewer === RICH_BLOB_VIEWER;
},
}, },
methods: { methods: {
requestCopyContents() { requestCopyContents() {
this.$emit('copy'); eventHub.$emit('copy');
}, },
}, },
BTN_COPY_CONTENTS_TITLE, BTN_COPY_CONTENTS_TITLE,
...@@ -41,6 +56,7 @@ export default { ...@@ -41,6 +56,7 @@ export default {
v-gl-tooltip.hover v-gl-tooltip.hover
:aria-label="$options.BTN_COPY_CONTENTS_TITLE" :aria-label="$options.BTN_COPY_CONTENTS_TITLE"
:title="$options.BTN_COPY_CONTENTS_TITLE" :title="$options.BTN_COPY_CONTENTS_TITLE"
:disabled="copyDisabled"
@click="requestCopyContents" @click="requestCopyContents"
> >
<gl-icon name="copy-to-clipboard" :size="14" /> <gl-icon name="copy-to-clipboard" :size="14" />
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
SIMPLE_BLOB_VIEWER, SIMPLE_BLOB_VIEWER,
SIMPLE_BLOB_VIEWER_TITLE, SIMPLE_BLOB_VIEWER_TITLE,
} from './constants'; } from './constants';
import eventHub from '../event_hub';
export default { export default {
components: { components: {
...@@ -21,25 +22,24 @@ export default { ...@@ -21,25 +22,24 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
activeViewer: {
type: String,
default: SIMPLE_BLOB_VIEWER,
required: false,
}, },
data() {
return {
viewer: this.blob.richViewer ? RICH_BLOB_VIEWER : SIMPLE_BLOB_VIEWER,
};
}, },
computed: { computed: {
isSimpleViewer() { isSimpleViewer() {
return this.viewer === SIMPLE_BLOB_VIEWER; return this.activeViewer === SIMPLE_BLOB_VIEWER;
}, },
isRichViewer() { isRichViewer() {
return this.viewer === RICH_BLOB_VIEWER; return this.activeViewer === RICH_BLOB_VIEWER;
}, },
}, },
methods: { methods: {
switchToViewer(viewer) { switchToViewer(viewer) {
if (viewer !== this.viewer) { if (viewer !== this.activeViewer) {
this.viewer = viewer; eventHub.$emit('switch-viewer', viewer);
this.$emit('switch-viewer', viewer);
} }
}, },
}, },
......
import Vue from 'vue';
export default new Vue();
fragment BlobViewer on SnippetBlobViewer {
collapsed
loadingPartialName
renderError
tooLarge
}
<script> <script>
import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import { SNIPPET_VISIBILITY_PUBLIC } from '../constants'; 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 { export default {
components: { components: {
BlobEmbeddable, BlobEmbeddable,
BlobHeader,
GlLoadingIcon,
},
apollo: {
blob: {
query: GetSnippetBlobQuery,
variables() {
return {
ids: this.snippet.id,
};
},
update: data => data.snippets.edges[0].node.blob,
},
}, },
props: { props: {
snippet: { snippet: {
...@@ -12,15 +28,32 @@ export default { ...@@ -12,15 +28,32 @@ export default {
required: true, required: true,
}, },
}, },
data() {
return {
blob: {},
};
},
computed: { computed: {
embeddable() { embeddable() {
return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC; return this.snippet.visibilityLevel === SNIPPET_VISIBILITY_PUBLIC;
}, },
isBlobLoading() {
return this.$apollo.queries.blob.loading;
},
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<blob-embeddable v-if="embeddable" class="mb-3" :url="snippet.webUrl" /> <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> </div>
</template> </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 "" ...@@ -11419,6 +11419,9 @@ msgstr ""
msgid "Live preview" msgid "Live preview"
msgstr "" msgstr ""
msgid "Loading blob"
msgstr ""
msgid "Loading contribution stats for group members" msgid "Loading contribution stats for group members"
msgstr "" 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 { ...@@ -4,9 +4,11 @@ import {
BTN_COPY_CONTENTS_TITLE, BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE, BTN_DOWNLOAD_TITLE,
BTN_RAW_TITLE, BTN_RAW_TITLE,
RICH_BLOB_VIEWER,
} from '~/blob/components/constants'; } from '~/blob/components/constants';
import { GlButtonGroup, GlButton } from '@gitlab/ui'; import { GlButtonGroup, GlButton } from '@gitlab/ui';
import { Blob } from './mock_data'; import { Blob } from './mock_data';
import eventHub from '~/blob/event_hub';
describe('Blob Header Default Actions', () => { describe('Blob Header Default Actions', () => {
let wrapper; let wrapper;
...@@ -14,10 +16,11 @@ describe('Blob Header Default Actions', () => { ...@@ -14,10 +16,11 @@ describe('Blob Header Default Actions', () => {
let buttons; let buttons;
const hrefPrefix = 'http://localhost'; const hrefPrefix = 'http://localhost';
function createComponent(props = {}) { function createComponent(blobProps = {}, propsData = {}) {
wrapper = mount(BlobHeaderActions, { wrapper = mount(BlobHeaderActions, {
propsData: { propsData: {
blob: Object.assign({}, Blob, props), blob: Object.assign({}, Blob, blobProps),
...propsData,
}, },
}); });
} }
...@@ -51,14 +54,30 @@ describe('Blob Header Default Actions', () => { ...@@ -51,14 +54,30 @@ describe('Blob Header Default Actions', () => {
it('correct href attribute on Download button', () => { it('correct href attribute on Download button', () => {
expect(buttons.at(2).vm.$el.href).toBe(`${hrefPrefix}${Blob.rawPath}?inline=false`); 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', () => { describe('functionally', () => {
it('emits an event when a Copy Contents button is clicked', () => { 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'); 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 { ...@@ -8,14 +8,16 @@ import {
} from '~/blob/components/constants'; } from '~/blob/components/constants';
import { GlButtonGroup, GlButton } from '@gitlab/ui'; import { GlButtonGroup, GlButton } from '@gitlab/ui';
import { Blob } from './mock_data'; import { Blob } from './mock_data';
import eventHub from '~/blob/event_hub';
describe('Blob Header Viewer Switcher', () => { describe('Blob Header Viewer Switcher', () => {
let wrapper; let wrapper;
function createComponent(props = {}) { function createComponent(blobProps = {}, propsData = {}) {
wrapper = mount(BlobHeaderViewerSwitcher, { wrapper = mount(BlobHeaderViewerSwitcher, {
propsData: { propsData: {
blob: Object.assign({}, Blob, props), blob: Object.assign({}, Blob, blobProps),
...propsData,
}, },
}); });
} }
...@@ -25,14 +27,9 @@ describe('Blob Header Viewer Switcher', () => { ...@@ -25,14 +27,9 @@ describe('Blob Header Viewer Switcher', () => {
}); });
describe('intiialization', () => { describe('intiialization', () => {
it('is initialized with rich viewer as preselected when richViewer exists', () => { it('is initialized with simple viewer as active', () => {
createComponent(); createComponent();
expect(wrapper.vm.viewer).toBe(RICH_BLOB_VIEWER); expect(wrapper.vm.activeViewer).toBe(SIMPLE_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);
}); });
}); });
...@@ -63,46 +60,42 @@ describe('Blob Header Viewer Switcher', () => { ...@@ -63,46 +60,42 @@ describe('Blob Header Viewer Switcher', () => {
let simpleBtn; let simpleBtn;
let richBtn; let richBtn;
beforeEach(() => { function factory(propsOptions = {}) {
createComponent(); createComponent({}, propsOptions);
buttons = wrapper.findAll(GlButton); buttons = wrapper.findAll(GlButton);
simpleBtn = buttons.at(0); simpleBtn = buttons.at(0);
richBtn = buttons.at(1); richBtn = buttons.at(1);
});
it('does not switch the viewer if the selected one is already active', () => { jest.spyOn(eventHub, '$emit');
jest.spyOn(wrapper.vm, '$emit'); }
expect(wrapper.vm.viewer).toBe(RICH_BLOB_VIEWER); it('does not switch the viewer if the selected one is already active', () => {
richBtn.vm.$emit('click'); factory();
expect(wrapper.vm.viewer).toBe(RICH_BLOB_VIEWER); expect(wrapper.vm.activeViewer).toBe(SIMPLE_BLOB_VIEWER);
expect(wrapper.vm.$emit).not.toHaveBeenCalled(); 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', () => { it('emits an event when a Rich Viewer button is clicked', () => {
jest.spyOn(wrapper.vm, '$emit'); factory();
expect(wrapper.vm.activeViewer).toBe(SIMPLE_BLOB_VIEWER);
simpleBtn.vm.$emit('click'); richBtn.vm.$emit('click');
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.viewer).toBe(SIMPLE_BLOB_VIEWER); expect(eventHub.$emit).toHaveBeenCalledWith('switch-viewer', RICH_BLOB_VIEWER);
expect(wrapper.vm.$emit).toHaveBeenCalledWith('switch-viewer', SIMPLE_BLOB_VIEWER);
}); });
}); });
it('emits an event when a Rich Viewer button is clicked', () => { it('emits an event when a Simple Viewer button is clicked', () => {
jest.spyOn(wrapper.vm, '$emit'); factory({
activeViewer: RICH_BLOB_VIEWER,
wrapper.setData({ viewer: SIMPLE_BLOB_VIEWER }); });
simpleBtn.vm.$emit('click');
return wrapper.vm return wrapper.vm.$nextTick().then(() => {
.$nextTick() expect(eventHub.$emit).toHaveBeenCalledWith('switch-viewer', SIMPLE_BLOB_VIEWER);
.then(() => {
richBtn.vm.$emit('click');
})
.then(() => {
expect(wrapper.vm.viewer).toBe(RICH_BLOB_VIEWER);
expect(wrapper.vm.$emit).toHaveBeenCalledWith('switch-viewer', RICH_BLOB_VIEWER);
}); });
}); });
}); });
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue'; 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 BlobEmbeddable from '~/blob/components/blob_embeddable.vue';
import { import {
SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_PRIVATE,
...@@ -15,7 +17,15 @@ describe('Blob Embeddable', () => { ...@@ -15,7 +17,15 @@ describe('Blob Embeddable', () => {
visibilityLevel: SNIPPET_VISIBILITY_PUBLIC, visibilityLevel: SNIPPET_VISIBILITY_PUBLIC,
}; };
function createComponent(props = {}) { function createComponent(props = {}, loading = false) {
const $apollo = {
queries: {
blob: {
loading,
},
},
};
wrapper = shallowMount(SnippetBlobView, { wrapper = shallowMount(SnippetBlobView, {
propsData: { propsData: {
snippet: { snippet: {
...@@ -23,32 +33,44 @@ describe('Blob Embeddable', () => { ...@@ -23,32 +33,44 @@ describe('Blob Embeddable', () => {
...props, ...props,
}, },
}, },
mocks: { $apollo },
}); });
wrapper.vm.$apollo.queries.blob.loading = false;
} }
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('renders blob-embeddable component', () => { describe('rendering', () => {
it('renders correct components', () => {
createComponent(); createComponent();
expect(wrapper.find(BlobEmbeddable).exists()).toBe(true); 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({ createComponent({
visibilityLevel: SNIPPET_VISIBILITY_INTERNAL, visibilityLevel,
}); });
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false); expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
},
);
it('does render blob-embeddable for public snippet', () => {
createComponent({ createComponent({
visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, visibilityLevel: SNIPPET_VISIBILITY_PUBLIC,
});
expect(wrapper.find(BlobEmbeddable).exists()).toBe(true);
}); });
expect(wrapper.find(BlobEmbeddable).exists()).toBe(false);
createComponent({ it('shows loading icon while blob data is in flight', () => {
visibilityLevel: 'foo', 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