Commit 6bd15453 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '216453-large-files-snippet' into 'master'

Updated blob rendering in case of a render error

See merge request gitlab-org/gitlab!32345
parents 14cf78ca db49cffa
...@@ -3,12 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui'; ...@@ -3,12 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui';
import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers'; import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import BlobContentError from './blob_content_error.vue'; import BlobContentError from './blob_content_error.vue';
import { BLOB_RENDER_EVENT_LOAD, BLOB_RENDER_EVENT_SHOW_SOURCE } from './constants';
export default { export default {
components: { components: {
GlLoadingIcon, GlLoadingIcon,
BlobContentError, BlobContentError,
}, },
props: { props: {
blob: {
type: Object,
required: false,
default: () => ({}),
},
content: { content: {
type: String, type: String,
default: '', default: '',
...@@ -37,6 +44,8 @@ export default { ...@@ -37,6 +44,8 @@ export default {
return this.activeViewer.renderError; return this.activeViewer.renderError;
}, },
}, },
BLOB_RENDER_EVENT_LOAD,
BLOB_RENDER_EVENT_SHOW_SOURCE,
}; };
</script> </script>
<template> <template>
...@@ -44,7 +53,13 @@ export default { ...@@ -44,7 +53,13 @@ export default {
<gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" /> <gl-loading-icon v-if="loading" size="md" color="dark" class="my-4 mx-auto" />
<template v-else> <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 <component
:is="viewer" :is="viewer"
v-else v-else
......
<script> <script>
import { __ } from '~/locale';
import { GlSprintf, GlLink } from '@gitlab/ui';
import { BLOB_RENDER_ERRORS } from './constants';
export default { export default {
components: {
GlSprintf,
GlLink,
},
props: { props: {
viewerError: { viewerError: {
type: String, type: String,
required: true, 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> </script>
<template> <template>
<div class="file-content code"> <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> </div>
</template> </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_COPY_CONTENTS_TITLE = __('Copy file contents');
export const BTN_RAW_TITLE = __('Open raw'); export const BTN_RAW_TITLE = __('Open raw');
...@@ -9,3 +10,56 @@ export const SIMPLE_BLOB_VIEWER_TITLE = __('Display source'); ...@@ -9,3 +10,56 @@ export const SIMPLE_BLOB_VIEWER_TITLE = __('Display source');
export const RICH_BLOB_VIEWER = 'rich'; export const RICH_BLOB_VIEWER = 'rich';
export const RICH_BLOB_VIEWER_TITLE = __('Display rendered file'); 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'; ...@@ -7,7 +7,12 @@ import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue';
import GetBlobContent from '../queries/snippet.blob.content.query.graphql'; 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 { export default {
components: { components: {
...@@ -27,6 +32,16 @@ export default { ...@@ -27,6 +32,16 @@ export default {
}, },
update: data => update: data =>
data.snippets.edges[0].node.blob.richData || data.snippets.edges[0].node.blob.plainData, 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: { props: {
...@@ -62,9 +77,15 @@ export default { ...@@ -62,9 +77,15 @@ export default {
}, },
methods: { methods: {
switchViewer(newViewer) { 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> </script>
<template> <template>
...@@ -81,7 +102,14 @@ export default { ...@@ -81,7 +102,14 @@ export default {
/> />
</template> </template>
</blob-header> </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> </article>
</div> </div>
</template> </template>
...@@ -17,6 +17,8 @@ fragment SnippetBase on Snippet { ...@@ -17,6 +17,8 @@ fragment SnippetBase on Snippet {
path path
rawPath rawPath
size size
externalStorage
renderedAsText
simpleViewer { simpleViewer {
...BlobViewer ...BlobViewer
} }
......
---
title: Refactored render errors for blob to Vue
merge_request: 32345
author:
type: changed
...@@ -21818,6 +21818,9 @@ msgstr "" ...@@ -21818,6 +21818,9 @@ msgstr ""
msgid "This commit was signed with an <strong>unverified</strong> signature." msgid "This commit was signed with an <strong>unverified</strong> signature."
msgstr "" 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." msgid "This date is after the due date, so this epic won't appear in the roadmap."
msgstr "" msgstr ""
...@@ -25579,6 +25582,9 @@ msgstr "" ...@@ -25579,6 +25582,9 @@ msgstr ""
msgid "done" msgid "done"
msgstr "" msgstr ""
msgid "download it"
msgstr ""
msgid "draft" msgid "draft"
msgid_plural "drafts" msgid_plural "drafts"
msgstr[0] "" msgstr[0] ""
...@@ -25783,6 +25789,12 @@ msgstr "" ...@@ -25783,6 +25789,12 @@ msgstr ""
msgid "issues on track" msgid "issues on track"
msgstr "" msgstr ""
msgid "it is larger than %{limit}"
msgstr ""
msgid "it is stored as a job artifact"
msgstr ""
msgid "it is stored externally" msgid "it is stored externally"
msgstr "" msgstr ""
...@@ -25822,6 +25834,9 @@ msgstr "" ...@@ -25822,6 +25834,9 @@ msgstr ""
msgid "limit of %{project_limit} reached" msgid "limit of %{project_limit} reached"
msgstr "" msgstr ""
msgid "load it anyway"
msgstr ""
msgid "locked by %{path_lock_user_name} %{created_at}" msgid "locked by %{path_lock_user_name} %{created_at}"
msgstr "" msgstr ""
...@@ -26499,6 +26514,9 @@ msgstr "" ...@@ -26499,6 +26514,9 @@ msgstr ""
msgid "view the blob" msgid "view the blob"
msgstr "" msgstr ""
msgid "view the source"
msgstr ""
msgid "vulnerability|Add a comment" msgid "vulnerability|Add a comment"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import BlobContentError from '~/blob/components/blob_content_error.vue'; 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', () => { describe('Blob Content Error component', () => {
let wrapper; let wrapper;
const viewerError = '<h1 id="error">Foo Error</h1>';
function createComponent() { function createComponent(props = {}) {
wrapper = shallowMount(BlobContentError, { wrapper = shallowMount(BlobContentError, {
propsData: { propsData: {
viewerError, ...props,
},
stubs: {
GlSprintf,
}, },
}); });
} }
beforeEach(() => {
createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('renders the passed error without transformations', () => { describe('collapsed and too large blobs', () => {
expect(wrapper.html()).toContain(viewerError); 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'; ...@@ -2,6 +2,12 @@ import { shallowMount } from '@vue/test-utils';
import BlobContent from '~/blob/components/blob_content.vue'; import BlobContent from '~/blob/components/blob_content.vue';
import BlobContentError from '~/blob/components/blob_content_error.vue'; import BlobContentError from '~/blob/components/blob_content_error.vue';
import { import {
BLOB_RENDER_EVENT_LOAD,
BLOB_RENDER_EVENT_SHOW_SOURCE,
BLOB_RENDER_ERRORS,
} from '~/blob/components/constants';
import {
Blob,
RichViewerMock, RichViewerMock,
SimpleViewerMock, SimpleViewerMock,
RichBlobContentMock, RichBlobContentMock,
...@@ -67,4 +73,32 @@ describe('Blob Content component', () => { ...@@ -67,4 +73,32 @@ describe('Blob Content component', () => {
expect(wrapper.find(viewer).html()).toContain(content); 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'; ...@@ -3,6 +3,7 @@ import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue';
import BlobHeader from '~/blob/components/blob_header.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 BlobContent from '~/blob/components/blob_content.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 { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers';
import { import {
SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_PRIVATE,
...@@ -29,6 +30,8 @@ describe('Blob Embeddable', () => { ...@@ -29,6 +30,8 @@ describe('Blob Embeddable', () => {
queries: { queries: {
blobContent: { blobContent: {
loading: contentLoading, loading: contentLoading,
refetch: jest.fn(),
skip: true,
}, },
}, },
}; };
...@@ -143,4 +146,35 @@ describe('Blob Embeddable', () => { ...@@ -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