Commit db49cffa authored by Denys Mishunov's avatar Denys Mishunov Committed by Kushal Pandya

Pass the blob object down to blob_content_error

In order to construct a readable error message when blob returns a
rendering error, we need the simplified blob object (the one returned
with the snippet query, for example)
parent bc6f3b76
......@@ -3,12 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import BlobContentError from './blob_content_error.vue';
import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from './constants';
export default {
components: {
GlLoadingIcon,
BlobContentError,
},
props: {
blob: {
type: Object,
required: false,
default: () => ({}),
},
content: {
type: String,
default: '',
......@@ -37,6 +44,8 @@ export default {
return this.activeViewer.renderError;
},
},
BLOB_RENDER_EVENT_LOAD,
BLOB_RENDER_EVENT_SHOW_SOURCE,
};
</script>
<template>
......@@ -44,7 +53,13 @@ export default {
<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" />
<blob-content-error
v-if="viewerError"
:viewer-error="viewerError"
:blob="blob"
@[$options.BLOB_RENDER_EVENT_LOAD]="$emit($options.BLOB_RENDER_EVENT_LOAD)"
@[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="$emit($options.BLOB_RENDER_EVENT_SHOW_SOURCE)"
/>
<component
:is="viewer"
v-else
......
<script>
import { __ } from '~/locale';
import { GlSprintf, GlLink } from '@gitlab/ui';
import { BLOB_RENDER_ERRORS } from './constants';
export default {
components: {
GlSprintf,
GlLink,
},
props: {
viewerError: {
type: String,
required: true,
},
blob: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
notStoredExternally() {
return this.viewerError !== BLOB_RENDER_ERRORS.REASONS.EXTERNAL.id;
},
renderErrorReason() {
const defaultReasonPath = Object.keys(BLOB_RENDER_ERRORS.REASONS).find(
reason => BLOB_RENDER_ERRORS.REASONS[reason].id === this.viewerError,
);
const defaultReason = BLOB_RENDER_ERRORS.REASONS[defaultReasonPath].text;
return this.notStoredExternally
? defaultReason
: defaultReason[this.blob.externalStorage || 'default'];
},
renderErrorOptions() {
const load = {
...BLOB_RENDER_ERRORS.OPTIONS.LOAD,
condition: this.shouldShowLoadBtn,
};
const showSource = {
...BLOB_RENDER_ERRORS.OPTIONS.SHOW_SOURCE,
condition: this.shouldShowSourceBtn,
};
const download = {
...BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD,
href: this.blob.rawPath,
};
return [load, showSource, download];
},
shouldShowLoadBtn() {
return this.viewerError === BLOB_RENDER_ERRORS.REASONS.COLLAPSED.id;
},
shouldShowSourceBtn() {
return this.blob.richViewer && this.blob.renderedAsText && this.notStoredExternally;
},
},
errorMessage: __(
'This content could not be displayed because %{reason}. You can %{options} instead.',
),
};
</script>
<template>
<div class="file-content code">
<div class="text-center py-4" v-html="viewerError"></div>
<div class="text-center py-4">
<gl-sprintf :message="$options.errorMessage">
<template #reason>{{ renderErrorReason }}</template>
<template #options>
<template v-for="option in renderErrorOptions">
<span v-if="option.condition" :key="option.text">
<gl-link
:href="option.href"
:target="option.target"
:data-test-id="`option-${option.id}`"
@click="option.event && $emit(option.event)"
>{{ option.text }}</gl-link
>
{{ option.conjunction }}
</span>
</template>
</template>
</gl-sprintf>
</div>
</div>
</template>
import { __ } from '~/locale';
import { __, sprintf } from '~/locale';
import { numberToHumanSize } from '~/lib/utils/number_utils';
export const BTN_COPY_CONTENTS_TITLE = __('Copy file contents');
export const BTN_RAW_TITLE = __('Open raw');
......@@ -9,3 +10,56 @@ export const SIMPLE_BLOB_VIEWER_TITLE = __('Display source');
export const RICH_BLOB_VIEWER = 'rich';
export const RICH_BLOB_VIEWER_TITLE = __('Display rendered file');
export const BLOB_RENDER_EVENT_LOAD = 'force-content-fetch';
export const BLOB_RENDER_EVENT_SHOW_SOURCE = 'force-switch-viewer';
export const BLOB_RENDER_ERRORS = {
REASONS: {
COLLAPSED: {
id: 'collapsed',
text: sprintf(__('it is larger than %{limit}'), {
limit: numberToHumanSize(1048576), // 1MB in bytes
}),
},
TOO_LARGE: {
id: 'too_large',
text: sprintf(__('it is larger than %{limit}'), {
limit: numberToHumanSize(104857600), // 100MB in bytes
}),
},
EXTERNAL: {
id: 'server_side_but_stored_externally',
text: {
lfs: __('it is stored in LFS'),
build_artifact: __('it is stored as a job artifact'),
default: __('it is stored externally'),
},
},
},
OPTIONS: {
LOAD: {
id: 'load',
text: __('load it anyway'),
conjunction: __('or'),
href: '#',
target: '',
event: BLOB_RENDER_EVENT_LOAD,
},
SHOW_SOURCE: {
id: 'show_source',
text: __('view the source'),
conjunction: __('or'),
href: '#',
target: '',
event: BLOB_RENDER_EVENT_SHOW_SOURCE,
},
DOWNLOAD: {
id: 'download',
text: __('download it'),
conjunction: '',
target: '_blank',
condition: true,
},
},
};
......@@ -7,7 +7,12 @@ import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
import GetBlobContent from '../queries/snippet.blob.content.query.graphql';
import { SIMPLE_BLOB_VIEWER, RICH_BLOB_VIEWER } from '~/blob/components/constants';
import {
SIMPLE_BLOB_VIEWER,
RICH_BLOB_VIEWER,
BLOB_RENDER_EVENT_LOAD,
BLOB_RENDER_EVENT_SHOW_SOURCE,
} from '~/blob/components/constants';
export default {
components: {
......@@ -27,6 +32,16 @@ export default {
},
update: data =>
data.snippets.edges[0].node.blob.richData || data.snippets.edges[0].node.blob.plainData,
result() {
if (this.activeViewerType === RICH_BLOB_VIEWER) {
this.blob.richViewer.renderError = null;
} else {
this.blob.simpleViewer.renderError = null;
}
},
skip() {
return this.viewer.renderError;
},
},
},
props: {
......@@ -62,9 +77,15 @@ export default {
},
methods: {
switchViewer(newViewer) {
this.activeViewerType = newViewer;
this.activeViewerType = newViewer || SIMPLE_BLOB_VIEWER;
},
forceQuery() {
this.$apollo.queries.blobContent.skip = false;
this.$apollo.queries.blobContent.refetch();
},
},
BLOB_RENDER_EVENT_LOAD,
BLOB_RENDER_EVENT_SHOW_SOURCE,
};
</script>
<template>
......@@ -81,7 +102,14 @@ export default {
/>
</template>
</blob-header>
<blob-content :loading="isContentLoading" :content="blobContent" :active-viewer="viewer" />
<blob-content
:loading="isContentLoading"
:content="blobContent"
:active-viewer="viewer"
:blob="blob"
@[$options.BLOB_RENDER_EVENT_LOAD]="forceQuery"
@[$options.BLOB_RENDER_EVENT_SHOW_SOURCE]="switchViewer"
/>
</article>
</div>
</template>
......@@ -17,6 +17,8 @@ fragment SnippetBase on Snippet {
path
rawPath
size
externalStorage
renderedAsText
simpleViewer {
...BlobViewer
}
......
---
title: Refactored render errors for blob to Vue
merge_request: 32345
author:
type: changed
......@@ -21782,6 +21782,9 @@ msgstr ""
msgid "This commit was signed with an <strong>unverified</strong> signature."
msgstr ""
msgid "This content could not be displayed because %{reason}. You can %{options} instead."
msgstr ""
msgid "This date is after the due date, so this epic won't appear in the roadmap."
msgstr ""
......@@ -25540,6 +25543,9 @@ msgstr ""
msgid "done"
msgstr ""
msgid "download it"
msgstr ""
msgid "draft"
msgid_plural "drafts"
msgstr[0] ""
......@@ -25744,6 +25750,12 @@ msgstr ""
msgid "issues on track"
msgstr ""
msgid "it is larger than %{limit}"
msgstr ""
msgid "it is stored as a job artifact"
msgstr ""
msgid "it is stored externally"
msgstr ""
......@@ -25783,6 +25795,9 @@ msgstr ""
msgid "limit of %{project_limit} reached"
msgstr ""
msgid "load it anyway"
msgstr ""
msgid "locked by %{path_lock_user_name} %{created_at}"
msgstr ""
......@@ -26460,6 +26475,9 @@ msgstr ""
msgid "view the blob"
msgstr ""
msgid "view the source"
msgstr ""
msgid "vulnerability|Add a comment"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import BlobContentError from '~/blob/components/blob_content_error.vue';
import { GlSprintf } from '@gitlab/ui';
import { BLOB_RENDER_ERRORS } from '~/blob/components/constants';
describe('Blob Content Error component', () => {
let wrapper;
const viewerError = '<h1 id="error">Foo Error</h1>';
function createComponent() {
function createComponent(props = {}) {
wrapper = shallowMount(BlobContentError, {
propsData: {
viewerError,
...props,
},
stubs: {
GlSprintf,
},
});
}
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders the passed error without transformations', () => {
expect(wrapper.html()).toContain(viewerError);
describe('collapsed and too large blobs', () => {
it.each`
error | reason | options
${BLOB_RENDER_ERRORS.REASONS.COLLAPSED} | ${'it is larger than 1.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.LOAD.text, BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
${BLOB_RENDER_ERRORS.REASONS.TOO_LARGE} | ${'it is larger than 100.00 MiB'} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
`('renders correct reason for $error.id', ({ error, reason, options }) => {
createComponent({
viewerError: error.id,
});
expect(wrapper.text()).toContain(reason);
options.forEach(option => {
expect(wrapper.text()).toContain(option);
});
});
});
describe('external blob', () => {
it.each`
storageType | reason | options
${'lfs'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.lfs} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
${'build_artifact'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.build_artifact} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
${'default'} | ${BLOB_RENDER_ERRORS.REASONS.EXTERNAL.text.default} | ${[BLOB_RENDER_ERRORS.OPTIONS.DOWNLOAD.text]}
`('renders correct reason for $storageType blob', ({ storageType, reason, options }) => {
createComponent({
viewerError: BLOB_RENDER_ERRORS.REASONS.EXTERNAL.id,
blob: {
externalStorage: storageType,
},
});
expect(wrapper.text()).toContain(reason);
options.forEach(option => {
expect(wrapper.text()).toContain(option);
});
});
});
});
......@@ -2,6 +2,12 @@ import { shallowMount } from '@vue/test-utils';
import BlobContent from '~/blob/components/blob_content.vue';
import BlobContentError from '~/blob/components/blob_content_error.vue';
import {
BLOB_RENDER_EVENT_LOAD,
BLOB_RENDER_EVENT_SHOW_SOURCE,
BLOB_RENDER_ERRORS,
} from '~/blob/components/constants';
import {
Blob,
RichViewerMock,
SimpleViewerMock,
RichBlobContentMock,
......@@ -67,4 +73,32 @@ describe('Blob Content component', () => {
expect(wrapper.find(viewer).html()).toContain(content);
});
});
describe('functionality', () => {
describe('render error', () => {
const findErrorEl = () => wrapper.find(BlobContentError);
const renderError = BLOB_RENDER_ERRORS.REASONS.COLLAPSED.id;
const viewer = { ...SimpleViewerMock, renderError };
beforeEach(() => {
createComponent({ blob: Blob }, viewer);
});
it('correctly sets blob on the blob-content-error component', () => {
expect(findErrorEl().props('blob')).toEqual(Blob);
});
it(`properly proxies ${BLOB_RENDER_EVENT_LOAD} event`, () => {
expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeUndefined();
findErrorEl().vm.$emit(BLOB_RENDER_EVENT_LOAD);
expect(wrapper.emitted(BLOB_RENDER_EVENT_LOAD)).toBeTruthy();
});
it(`properly proxies ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => {
expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeUndefined();
findErrorEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE);
expect(wrapper.emitted(BLOB_RENDER_EVENT_SHOW_SOURCE)).toBeTruthy();
});
});
});
});
......@@ -3,6 +3,7 @@ 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 { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from '~/blob/components/constants';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import {
SNIPPET_VISIBILITY_PRIVATE,
......@@ -29,6 +30,8 @@ describe('Blob Embeddable', () => {
queries: {
blobContent: {
loading: contentLoading,
refetch: jest.fn(),
skip: true,
},
},
};
......@@ -143,4 +146,35 @@ describe('Blob Embeddable', () => {
});
});
});
describe('functionality', () => {
describe('render error', () => {
const findContentEl = () => wrapper.find(BlobContent);
it('correctly sets blob on the blob-content-error component', () => {
createComponent();
expect(findContentEl().props('blob')).toEqual(BlobMock);
});
it(`refetches blob content on ${BLOB_RENDER_EVENT_LOAD} event`, () => {
createComponent();
expect(wrapper.vm.$apollo.queries.blobContent.refetch).not.toHaveBeenCalled();
findContentEl().vm.$emit(BLOB_RENDER_EVENT_LOAD);
expect(wrapper.vm.$apollo.queries.blobContent.refetch).toHaveBeenCalledTimes(1);
});
it(`sets '${SimpleViewerMock.type}' as active on ${BLOB_RENDER_EVENT_SHOW_SOURCE} event`, () => {
createComponent(
{},
{
activeViewerType: RichViewerMock.type,
},
);
findContentEl().vm.$emit(BLOB_RENDER_EVENT_SHOW_SOURCE);
expect(wrapper.vm.activeViewerType).toEqual(SimpleViewerMock.type);
});
});
});
});
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