Commit 96f18406 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '196797-blob-content' into 'master'

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

Closes #28819

See merge request gitlab-org/gitlab!25188
parents 22944249 bfad030e
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import BlobContentError from './blob_content_error.vue';
export default {
components: {
GlLoadingIcon,
BlobContentError,
},
props: {
content: {
type: String,
default: '',
required: false,
},
loading: {
type: Boolean,
default: true,
required: false,
},
activeViewer: {
type: Object,
required: true,
},
},
computed: {
viewer() {
switch (this.activeViewer.type) {
case 'rich':
return RichViewer;
default:
return SimpleViewer;
}
},
viewerError() {
return this.activeViewer.renderError;
},
},
};
</script>
<template>
<div class="blob-viewer" :data-type="activeViewer.type">
<gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" />
<template v-else>
<blob-content-error v-if="viewerError" :viewer-error="viewerError" />
<component :is="viewer" v-else ref="contentViewer" :content="content" />
</template>
</div>
</template>
<script>
export default {
props: {
viewerError: {
type: String,
required: true,
},
},
};
</script>
<template>
<div class="file-content code">
<div class="text-center py-4" v-html="viewerError"></div>
</div>
</template>
......@@ -36,11 +36,6 @@ export default {
return this.activeViewer === RICH_BLOB_VIEWER;
},
},
methods: {
requestCopyContents() {
this.$emit('copy');
},
},
BTN_COPY_CONTENTS_TITLE,
BTN_DOWNLOAD_TITLE,
BTN_RAW_TITLE,
......@@ -53,7 +48,7 @@ export default {
:aria-label="$options.BTN_COPY_CONTENTS_TITLE"
:title="$options.BTN_COPY_CONTENTS_TITLE"
:disabled="copyDisabled"
@click="requestCopyContents"
data-clipboard-target="#blob-code-content"
>
<gl-icon name="copy-to-clipboard" :size="14" />
</gl-button>
......
fragment BlobViewer on SnippetBlobViewer {
collapsed
loadingPartialName
renderError
tooLarge
type
fileType
}
......@@ -2,13 +2,19 @@
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 BlobContent from '~/blob/components/blob_content.vue';
import { GlLoadingIcon } from '@gitlab/ui';
import GetSnippetBlobQuery from '../queries/snippet.blob.query.graphql';
import GetBlobContent from '../queries/snippet.blob.content.query.graphql';
import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
export default {
components: {
BlobEmbeddable,
BlobHeader,
BlobContent,
GlLoadingIcon,
},
apollo: {
......@@ -20,6 +26,23 @@ export default {
};
},
update: data => data.snippets.edges[0].node.blob,
result(res) {
const viewer = res.data.snippets.edges[0].node.blob.richViewer
? RICH_BLOB_VIEWER
: SIMPLE_BLOB_VIEWER;
this.switchViewer(viewer, true);
},
},
blobContent: {
query: GetBlobContent,
variables() {
return {
ids: this.snippet.id,
rich: this.activeViewerType === RICH_BLOB_VIEWER,
};
},
update: data =>
data.snippets.edges[0].node.blob.richData || data.snippets.edges[0].node.blob.plainData,
},
},
props: {
......@@ -31,6 +54,8 @@ export default {
data() {
return {
blob: {},
blobContent: '',
activeViewerType: window.location.hash ? SIMPLE_BLOB_VIEWER : '',
};
},
computed: {
......@@ -40,6 +65,18 @@ export default {
isBlobLoading() {
return this.$apollo.queries.blob.loading;
},
isContentLoading() {
return this.$apollo.queries.blobContent.loading;
},
viewer() {
const { richViewer, simpleViewer } = this.blob;
return this.activeViewerType === RICH_BLOB_VIEWER ? richViewer : simpleViewer;
},
},
methods: {
switchViewer(newViewer, respectHash = false) {
this.activeViewerType = respectHash && window.location.hash ? SIMPLE_BLOB_VIEWER : newViewer;
},
},
};
</script>
......@@ -49,11 +86,12 @@ export default {
<gl-loading-icon
v-if="isBlobLoading"
:label="__('Loading blob')"
:size="2"
size="lg"
class="prepend-top-20 append-bottom-20"
/>
<article v-else class="file-holder snippet-file-content">
<blob-header :blob="blob" />
<blob-header :blob="blob" :active-viewer-type="viewer.type" @viewer-changed="switchViewer" />
<blob-content :loading="isContentLoading" :content="blobContent" :active-viewer="viewer" />
</article>
</div>
</template>
query SnippetBlobContent($ids: [ID!], $rich: Boolean!) {
snippets(ids: $ids) {
edges {
node {
id
blob {
richData @include(if: $rich)
plainData @skip(if: $rich)
}
}
}
}
}
export const HIGHLIGHT_CLASS_NAME = 'hll';
export default {};
import RichViewer from './rich_viewer.vue';
import SimpleViewer from './simple_viewer.vue';
export { RichViewer, SimpleViewer };
export default {
props: {
content: {
type: String,
required: true,
},
},
};
<script>
import ViewerMixin from './mixins';
export default {
mixins: [ViewerMixin],
};
</script>
<template>
<div v-html="content"></div>
</template>
<script>
import ViewerMixin from './mixins';
import { GlIcon } from '@gitlab/ui';
import { HIGHLIGHT_CLASS_NAME } from './constants';
export default {
components: {
GlIcon,
},
mixins: [ViewerMixin],
data() {
return {
highlightedLine: null,
};
},
computed: {
lineNumbers() {
return this.content.split('\n').length;
},
},
mounted() {
const { hash } = window.location;
if (hash) this.scrollToLine(hash, true);
},
methods: {
scrollToLine(hash, scroll = false) {
const lineToHighlight = hash && this.$el.querySelector(hash);
const currentlyHighlighted = this.highlightedLine;
if (lineToHighlight) {
if (currentlyHighlighted) {
currentlyHighlighted.classList.remove(HIGHLIGHT_CLASS_NAME);
}
lineToHighlight.classList.add(HIGHLIGHT_CLASS_NAME);
this.highlightedLine = lineToHighlight;
if (scroll) {
lineToHighlight.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
},
},
userColorScheme: window.gon.user_color_scheme,
};
</script>
<template>
<div
class="file-content code js-syntax-highlight qa-file-content"
:class="$options.userColorScheme"
>
<div class="line-numbers">
<a
v-for="line in lineNumbers"
:id="`L${line}`"
:key="line"
class="diff-line-num js-line-number"
:href="`#LC${line}`"
:data-line-number="line"
@click="scrollToLine(`#LC${line}`)"
>
<gl-icon :size="12" name="link" />
{{ line }}
</a>
</div>
<div class="blob-content">
<pre class="code highlight"><code id="blob-code-content" v-html="content"></code></pre>
</div>
</div>
</template>
......@@ -51,7 +51,8 @@
min-height: 1.5em;
white-space: nowrap;
i {
i,
svg {
float: left;
margin-top: 3px;
margin-right: 5px;
......@@ -62,7 +63,8 @@
&:focus {
outline: none;
i {
i,
svg {
visibility: visible;
}
}
......
......@@ -12,7 +12,7 @@
%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
......@@ -13,7 +13,7 @@
%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 => false
#notes.limited-width-notes= render "shared/notes/notes_with_form", :autocomplete => false
---
title: Refactored snippets view to Vue
merge_request: 25188
author:
type: other
import { shallowMount } from '@vue/test-utils';
import BlobContentError from '~/blob/components/blob_content_error.vue';
describe('Blob Content Error component', () => {
let wrapper;
const viewerError = '<h1 id="error">Foo Error</h1>';
function createComponent() {
wrapper = shallowMount(BlobContentError, {
propsData: {
viewerError,
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders the passed error without transformations', () => {
expect(wrapper.html()).toContain(viewerError);
});
});
import { shallowMount } from '@vue/test-utils';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobContentError from '~/blob/components/blob_content_error.vue';
import {
RichViewerMock,
SimpleViewerMock,
RichBlobContentMock,
SimpleBlobContentMock,
} from './mock_data';
import { GlLoadingIcon } from '@gitlab/ui';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
describe('Blob Content component', () => {
let wrapper;
function createComponent(propsData = {}, activeViewer = SimpleViewerMock) {
wrapper = shallowMount(BlobContent, {
propsData: {
loading: false,
activeViewer,
...propsData,
},
});
}
afterEach(() => {
wrapper.destroy();
});
describe('rendering', () => {
it('renders loader if `loading: true`', () => {
createComponent({ loading: true });
expect(wrapper.contains(GlLoadingIcon)).toBe(true);
expect(wrapper.contains(BlobContentError)).toBe(false);
expect(wrapper.contains(RichViewer)).toBe(false);
expect(wrapper.contains(SimpleViewer)).toBe(false);
});
it('renders error if there is any in the viewer', () => {
const renderError = 'Oops';
const viewer = Object.assign({}, SimpleViewerMock, { renderError });
createComponent({}, viewer);
expect(wrapper.contains(GlLoadingIcon)).toBe(false);
expect(wrapper.contains(BlobContentError)).toBe(true);
expect(wrapper.contains(RichViewer)).toBe(false);
expect(wrapper.contains(SimpleViewer)).toBe(false);
});
it.each`
type | mock | viewer
${'simple'} | ${SimpleViewerMock} | ${SimpleViewer}
${'rich'} | ${RichViewerMock} | ${RichViewer}
`(
'renders $type viewer when activeViewer is $type and no loading or error detected',
({ mock, viewer }) => {
createComponent({}, mock);
expect(wrapper.contains(viewer)).toBe(true);
},
);
it.each`
content | mock | viewer
${SimpleBlobContentMock.plainData} | ${SimpleViewerMock} | ${SimpleViewer}
${RichBlobContentMock.richData} | ${RichViewerMock} | ${RichViewer}
`('renders correct content that is passed to the component', ({ content, mock, viewer }) => {
createComponent({ content }, mock);
expect(wrapper.find(viewer).html()).toContain(content);
});
});
});
......@@ -67,13 +67,4 @@ describe('Blob Header Default Actions', () => {
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');
buttons.at(0).vm.$emit('click');
expect(wrapper.vm.$emit).toHaveBeenCalledWith('copy');
});
});
});
import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
export const SimpleViewerMock = {
collapsed: false,
loadingPartialName: 'loading',
renderError: null,
tooLarge: false,
type: SIMPLE_BLOB_VIEWER,
fileType: 'text',
};
export const RichViewerMock = {
collapsed: false,
loadingPartialName: 'loading',
renderError: null,
tooLarge: false,
type: RICH_BLOB_VIEWER,
fileType: 'markdown',
};
export const Blob = {
binary: false,
highlightedData:
'<h1 data-sourcepos="1:1-1:19" dir="auto">\n<a id="user-content-this-one-is-dummy" class="anchor" href="#this-one-is-dummy" aria-hidden="true"></a>This one is dummy</h1>\n<h2 data-sourcepos="3:1-3:21" dir="auto">\n<a id="user-content-and-has-sub-header" class="anchor" href="#and-has-sub-header" aria-hidden="true"></a>And has sub-header</h2>\n<p data-sourcepos="5:1-5:27" dir="auto">Even some stupid text here</p>',
name: 'dummy.md',
path: 'dummy.md',
rawPath: '/flightjs/flight/snippets/51/raw',
size: 75,
simpleViewer: {
collapsed: false,
fileType: 'text',
loadAsync: true,
loadingPartialName: 'loading',
renderError: null,
tooLarge: false,
type: 'simple',
...SimpleViewerMock,
},
richViewer: {
collapsed: false,
fileType: 'markup',
loadAsync: true,
loadingPartialName: 'loading',
renderError: null,
tooLarge: false,
type: 'rich',
...RichViewerMock,
},
};
export const RichBlobContentMock = {
richData: '<h1>Rich</h1>',
};
export const SimpleBlobContentMock = {
plainData: 'Plain',
};
export default {};
import { shallowMount } from '@vue/test-utils';
import { mount } 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 BlobContent from '~/blob/components/blob_content.vue';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import {
SNIPPET_VISIBILITY_PRIVATE,
SNIPPET_VISIBILITY_INTERNAL,
SNIPPET_VISIBILITY_PUBLIC,
} from '~/snippets/constants';
import { Blob as BlobMock, SimpleViewerMock, RichViewerMock } from 'jest/blob/components/mock_data';
describe('Blob Embeddable', () => {
let wrapper;
const snippet = {
......@@ -16,27 +20,42 @@ describe('Blob Embeddable', () => {
webUrl: 'https://foo.bar',
visibilityLevel: SNIPPET_VISIBILITY_PUBLIC,
};
const dataMock = {
blob: BlobMock,
activeViewerType: SimpleViewerMock.type,
};
function createComponent(props = {}, loading = false) {
function createComponent(
props = {},
data = dataMock,
blobLoading = false,
contentLoading = false,
) {
const $apollo = {
queries: {
blob: {
loading,
loading: blobLoading,
},
blobContent: {
loading: contentLoading,
},
},
};
wrapper = shallowMount(SnippetBlobView, {
wrapper = mount(SnippetBlobView, {
propsData: {
snippet: {
...snippet,
...props,
},
},
data() {
return {
...data,
};
},
mocks: { $apollo },
});
wrapper.vm.$apollo.queries.blob.loading = false;
}
afterEach(() => {
......@@ -48,6 +67,7 @@ describe('Blob Embeddable', () => {
createComponent();
expect(wrapper.find(BlobEmbeddable).exists()).toBe(true);
expect(wrapper.find(BlobHeader).exists()).toBe(true);
expect(wrapper.find(BlobContent).exists()).toBe(true);
});
it.each([SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PRIVATE, 'foo'])(
......@@ -68,9 +88,92 @@ describe('Blob Embeddable', () => {
});
it('shows loading icon while blob data is in flight', () => {
createComponent({}, true);
createComponent({}, dataMock, true);
expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
expect(wrapper.find('.snippet-file-content').exists()).toBe(false);
});
it('sets simple viewer correctly', () => {
createComponent();
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
});
it('sets rich viewer correctly', () => {
const data = Object.assign({}, dataMock, {
activeViewerType: RichViewerMock.type,
});
createComponent({}, data);
expect(wrapper.find(RichViewer).exists()).toBe(true);
});
it('correctly switches viewer type', () => {
createComponent();
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
wrapper.vm.switchViewer(RichViewerMock.type);
return wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.find(RichViewer).exists()).toBe(true);
wrapper.vm.switchViewer(SimpleViewerMock.type);
})
.then(() => {
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
});
});
describe('URLS with hash', () => {
beforeEach(() => {
window.location.hash = '#LC2';
});
afterEach(() => {
window.location.hash = '';
});
it('renders simple viewer by default if URL contains hash', () => {
createComponent();
expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type);
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
});
describe('switchViewer()', () => {
it('by default switches to the passed viewer', () => {
createComponent();
wrapper.vm.switchViewer(RichViewerMock.type);
return wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.vm.activeViewerType).toBe(RichViewerMock.type);
expect(wrapper.find(RichViewer).exists()).toBe(true);
wrapper.vm.switchViewer(SimpleViewerMock.type);
})
.then(() => {
expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type);
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
});
});
it('respects hash over richViewer in the blob when corresponding parameter is passed', () => {
createComponent(
{},
{
blob: BlobMock,
},
);
expect(wrapper.vm.blob.richViewer).toEqual(expect.any(Object));
wrapper.vm.switchViewer(RichViewerMock.type, true);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.activeViewerType).toBe(SimpleViewerMock.type);
expect(wrapper.find(SimpleViewer).exists()).toBe(true);
});
});
});
});
});
});
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Blob Simple Viewer component rendering matches the snapshot 1`] = `
<div
class="file-content code js-syntax-highlight qa-file-content"
>
<div
class="line-numbers"
>
<a
class="diff-line-num js-line-number"
data-line-number="1"
href="#LC1"
id="L1"
>
<gl-icon-stub
name="link"
size="12"
/>
1
</a>
<a
class="diff-line-num js-line-number"
data-line-number="2"
href="#LC2"
id="L2"
>
<gl-icon-stub
name="link"
size="12"
/>
2
</a>
<a
class="diff-line-num js-line-number"
data-line-number="3"
href="#LC3"
id="L3"
>
<gl-icon-stub
name="link"
size="12"
/>
3
</a>
</div>
<div
class="blob-content"
>
<pre
class="code highlight"
>
<code
id="blob-code-content"
>
<span
id="LC1"
>
First
</span>
<span
id="LC2"
>
Second
</span>
<span
id="LC3"
>
Third
</span>
</code>
</pre>
</div>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import RichViewer from '~/vue_shared/components/blob_viewers/rich_viewer.vue';
describe('Blob Rich Viewer component', () => {
let wrapper;
const content = '<h1 id="markdown">Foo Bar</h1>';
function createComponent() {
wrapper = shallowMount(RichViewer, {
propsData: {
content,
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders the passed content without transformations', () => {
expect(wrapper.html()).toContain(content);
});
});
import { shallowMount } from '@vue/test-utils';
import SimpleViewer from '~/vue_shared/components/blob_viewers/simple_viewer.vue';
import { HIGHLIGHT_CLASS_NAME } from '~/vue_shared/components/blob_viewers/constants';
describe('Blob Simple Viewer component', () => {
let wrapper;
const contentMock = `<span id="LC1">First</span>\n<span id="LC2">Second</span>\n<span id="LC3">Third</span>`;
function createComponent(content = contentMock) {
wrapper = shallowMount(SimpleViewer, {
propsData: {
content,
},
});
}
afterEach(() => {
wrapper.destroy();
});
it('does not fail if content is empty', () => {
const spy = jest.spyOn(window.console, 'error');
createComponent('');
expect(spy).not.toHaveBeenCalled();
});
describe('rendering', () => {
beforeEach(() => {
createComponent();
});
it('matches the snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('renders exactly three lines', () => {
expect(wrapper.findAll('.js-line-number')).toHaveLength(3);
});
it('renders the content without transformations', () => {
expect(wrapper.html()).toContain(contentMock);
});
});
describe('functionality', () => {
const scrollIntoViewMock = jest.fn();
HTMLElement.prototype.scrollIntoView = scrollIntoViewMock;
beforeEach(() => {
window.location.hash = '#LC2';
createComponent();
});
afterEach(() => {
window.location.hash = '';
});
it('scrolls to requested line when rendered', () => {
const linetoBeHighlighted = wrapper.find('#LC2');
expect(scrollIntoViewMock).toHaveBeenCalled();
expect(wrapper.vm.highlightedLine).toBe(linetoBeHighlighted.element);
expect(linetoBeHighlighted.classes()).toContain(HIGHLIGHT_CLASS_NAME);
});
it('switches highlighting when another line is selected', () => {
const currentlyHighlighted = wrapper.find('#LC2');
const hash = '#LC3';
const linetoBeHighlighted = wrapper.find(hash);
expect(wrapper.vm.highlightedLine).toBe(currentlyHighlighted.element);
wrapper.vm.scrollToLine(hash);
return wrapper.vm.$nextTick(() => {
expect(wrapper.vm.highlightedLine).toBe(linetoBeHighlighted.element);
expect(currentlyHighlighted.classes()).not.toContain(HIGHLIGHT_CLASS_NAME);
expect(linetoBeHighlighted.classes()).toContain(HIGHLIGHT_CLASS_NAME);
});
});
});
});
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