Commit f4255379 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'ph/35638/readmeVueLoad' into 'master'

Vue file listing refactor load README files

Closes #35638

See merge request gitlab-org/gitlab!19689
parents ccb00f35 a660415c
<script>
import { GlLink, GlLoadingIcon } from '@gitlab/ui';
import getReadmeQuery from '../../queries/getReadme.query.graphql';
export default {
apollo: {
readme: {
query: getReadmeQuery,
variables() {
return {
url: this.blob.webUrl,
};
},
loadingKey: 'loading',
},
},
components: {
GlLink,
GlLoadingIcon,
},
props: {
blob: {
type: Object,
required: true,
},
},
data() {
return {
readme: null,
loading: 0,
};
},
};
</script>
<template>
<article class="file-holder js-hide-on-navigation limited-width-container readme-holder">
<div class="file-title">
<i aria-hidden="true" class="fa fa-file-text-o fa-fw"></i>
<gl-link :href="blob.webUrl">
<strong>{{ blob.name }}</strong>
</gl-link>
</div>
<div class="blob-viewer">
<gl-loading-icon v-if="loading > 0" size="md" class="my-4 mx-auto" />
<div v-else-if="readme" v-html="readme.html"></div>
</div>
</article>
</template>
<script> <script>
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlSkeletonLoading } from '@gitlab/ui';
import createFlash from '~/flash';
import { sprintf, __ } from '../../../locale'; import { sprintf, __ } from '../../../locale';
import getRefMixin from '../../mixins/get_ref'; import getRefMixin from '../../mixins/get_ref';
import getFiles from '../../queries/getFiles.query.graphql';
import getProjectPath from '../../queries/getProjectPath.query.graphql'; import getProjectPath from '../../queries/getProjectPath.query.graphql';
import TableHeader from './header.vue'; import TableHeader from './header.vue';
import TableRow from './row.vue'; import TableRow from './row.vue';
import ParentRow from './parent_row.vue'; import ParentRow from './parent_row.vue';
const PAGE_SIZE = 100;
export default { export default {
components: { components: {
GlSkeletonLoading, GlSkeletonLoading,
...@@ -29,22 +25,24 @@ export default { ...@@ -29,22 +25,24 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
entries: {
type: Object,
required: false,
default: () => ({}),
},
isLoading: {
type: Boolean,
required: true,
},
}, },
data() { data() {
return { return {
projectPath: '', projectPath: '',
nextPageCursor: '',
entries: {
trees: [],
submodules: [],
blobs: [],
},
isLoadingFiles: false,
}; };
}, },
computed: { computed: {
tableCaption() { tableCaption() {
if (this.isLoadingFiles) { if (this.isLoading) {
return sprintf( return sprintf(
__( __(
'Loading files, directories, and submodules in the path %{path} for commit reference %{ref}', 'Loading files, directories, and submodules in the path %{path} for commit reference %{ref}',
...@@ -59,65 +57,7 @@ export default { ...@@ -59,65 +57,7 @@ export default {
); );
}, },
showParentRow() { showParentRow() {
return !this.isLoadingFiles && ['', '/'].indexOf(this.path) === -1; return !this.isLoading && ['', '/'].indexOf(this.path) === -1;
},
},
watch: {
$route: function routeChange() {
this.entries.trees = [];
this.entries.submodules = [];
this.entries.blobs = [];
this.nextPageCursor = '';
this.fetchFiles();
},
},
mounted() {
// We need to wait for `ref` and `projectPath` to be set
this.$nextTick(() => this.fetchFiles());
},
methods: {
fetchFiles() {
this.isLoadingFiles = true;
return this.$apollo
.query({
query: getFiles,
variables: {
projectPath: this.projectPath,
ref: this.ref,
path: this.path || '/',
nextPageCursor: this.nextPageCursor,
pageSize: PAGE_SIZE,
},
})
.then(({ data }) => {
if (!data) return;
const pageInfo = this.hasNextPage(data.project.repository.tree);
this.isLoadingFiles = false;
this.entries = Object.keys(this.entries).reduce(
(acc, key) => ({
...acc,
[key]: this.normalizeData(key, data.project.repository.tree[key].edges),
}),
{},
);
if (pageInfo && pageInfo.hasNextPage) {
this.nextPageCursor = pageInfo.endCursor;
this.fetchFiles();
}
})
.catch(() => createFlash(__('An error occurred while fetching folder content.')));
},
normalizeData(key, data) {
return this.entries[key].concat(data.map(({ node }) => node));
},
hasNextPage(data) {
return []
.concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
.find(({ hasNextPage }) => hasNextPage);
}, },
}, },
}; };
...@@ -145,7 +85,7 @@ export default { ...@@ -145,7 +85,7 @@ export default {
:lfs-oid="entry.lfsOid" :lfs-oid="entry.lfsOid"
/> />
</template> </template>
<template v-if="isLoadingFiles"> <template v-if="isLoading">
<tr v-for="i in 5" :key="i" aria-hidden="true"> <tr v-for="i in 5" :key="i" aria-hidden="true">
<td><gl-skeleton-loading :lines="1" class="h-auto" /></td> <td><gl-skeleton-loading :lines="1" class="h-auto" /></td>
<td><gl-skeleton-loading :lines="1" class="h-auto" /></td> <td><gl-skeleton-loading :lines="1" class="h-auto" /></td>
......
<script>
import createFlash from '~/flash';
import { __ } from '../../locale';
import FileTable from './table/index.vue';
import getRefMixin from '../mixins/get_ref';
import getFiles from '../queries/getFiles.query.graphql';
import getProjectPath from '../queries/getProjectPath.query.graphql';
import FilePreview from './preview/index.vue';
import { readmeFile } from '../utils/readme';
const PAGE_SIZE = 100;
export default {
components: {
FileTable,
FilePreview,
},
mixins: [getRefMixin],
apollo: {
projectPath: {
query: getProjectPath,
},
},
props: {
path: {
type: String,
required: false,
default: '/',
},
},
data() {
return {
projectPath: '',
nextPageCursor: '',
entries: {
trees: [],
submodules: [],
blobs: [],
},
isLoadingFiles: false,
};
},
computed: {
readme() {
return readmeFile(this.entries.blobs);
},
},
watch: {
$route: function routeChange() {
this.entries.trees = [];
this.entries.submodules = [];
this.entries.blobs = [];
this.nextPageCursor = '';
this.fetchFiles();
},
},
mounted() {
// We need to wait for `ref` and `projectPath` to be set
this.$nextTick(() => this.fetchFiles());
},
methods: {
fetchFiles() {
this.isLoadingFiles = true;
return this.$apollo
.query({
query: getFiles,
variables: {
projectPath: this.projectPath,
ref: this.ref,
path: this.path || '/',
nextPageCursor: this.nextPageCursor,
pageSize: PAGE_SIZE,
},
})
.then(({ data }) => {
if (!data) return;
const pageInfo = this.hasNextPage(data.project.repository.tree);
this.isLoadingFiles = false;
this.entries = Object.keys(this.entries).reduce(
(acc, key) => ({
...acc,
[key]: this.normalizeData(key, data.project.repository.tree[key].edges),
}),
{},
);
if (pageInfo && pageInfo.hasNextPage) {
this.nextPageCursor = pageInfo.endCursor;
this.fetchFiles();
}
})
.catch(() => createFlash(__('An error occurred while fetching folder content.')));
},
normalizeData(key, data) {
return this.entries[key].concat(data.map(({ node }) => node));
},
hasNextPage(data) {
return []
.concat(data.trees.pageInfo, data.submodules.pageInfo, data.blobs.pageInfo)
.find(({ hasNextPage }) => hasNextPage);
},
},
};
</script>
<template>
<div>
<file-table :path="path" :entries="entries" :is-loading="isLoadingFiles" />
<file-preview v-if="readme" :blob="readme" />
</div>
</template>
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import axios from '~/lib/utils/axios_utils';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json'; import introspectionQueryResultData from './fragmentTypes.json';
import { fetchLogsTree } from './log_tree'; import { fetchLogsTree } from './log_tree';
...@@ -27,6 +28,11 @@ const defaultClient = createDefaultClient( ...@@ -27,6 +28,11 @@ const defaultClient = createDefaultClient(
}); });
}); });
}, },
readme(_, { url }) {
return axios
.get(url, { params: { viewer: 'rich', format: 'json' } })
.then(({ data }) => ({ ...data, __typename: 'ReadmeFile' }));
},
}, },
}, },
{ {
......
<script> <script>
import FileTable from '../components/table/index.vue'; import TreeContent from '../components/tree_content.vue';
export default { export default {
components: { components: {
FileTable, TreeContent,
},
data() {
return {
ref: '',
};
}, },
}; };
</script> </script>
<template> <template>
<file-table path="/" /> <tree-content />
</template> </template>
<script> <script>
import FileTable from '../components/table/index.vue'; import TreeContent from '../components/tree_content.vue';
export default { export default {
components: { components: {
FileTable, TreeContent,
}, },
props: { props: {
path: { path: {
...@@ -16,5 +16,5 @@ export default { ...@@ -16,5 +16,5 @@ export default {
</script> </script>
<template> <template>
<file-table :path="path" /> <tree-content :path="path" />
</template> </template>
query getReadme($url: String!) {
readme(url: $url) @client {
html
}
}
const MARKDOWN_EXTENSIONS = ['mdown', 'mkd', 'mkdn', 'md', 'markdown'];
const ASCIIDOC_EXTENSIONS = ['adoc', 'ad', 'asciidoc'];
const OTHER_EXTENSIONS = ['textile', 'rdoc', 'org', 'creole', 'wiki', 'mediawiki', 'rst'];
const EXTENSIONS = [...MARKDOWN_EXTENSIONS, ...ASCIIDOC_EXTENSIONS, ...OTHER_EXTENSIONS];
const PLAIN_FILENAMES = ['readme', 'index'];
const FILE_REGEXP = new RegExp(`^(${PLAIN_FILENAMES.join('|')})`, 'i');
const EXTENSIONS_REGEXP = new RegExp(`.(${EXTENSIONS.join('|')})$`, 'i');
// eslint-disable-next-line import/prefer-default-export
export const readmeFile = blobs => {
const readMeFiles = blobs.filter(f => f.name.search(FILE_REGEXP) !== -1);
const previewableReadme = readMeFiles.find(f => f.name.search(EXTENSIONS_REGEXP) !== -1);
const plainReadme = readMeFiles.find(f => f.name.search(FILE_REGEXP) !== -1);
return previewableReadme || plainReadme;
};
...@@ -23,7 +23,5 @@ ...@@ -23,7 +23,5 @@
- if can_edit_tree? - if can_edit_tree?
= render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post = render 'projects/blob/upload', title: _('Upload New File'), placeholder: _('Upload New File'), button_title: _('Upload file'), form_path: project_create_blob_path(@project, @id), method: :post
= render 'projects/blob/new_dir' = render 'projects/blob/new_dir'
- if @tree.readme
= render "projects/tree/readme", readme: @tree.readme
- else - else
= render 'projects/tree/tree_content', tree: @tree, content_url: content_url = render 'projects/tree/tree_content', tree: @tree, content_url: content_url
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Repository file preview component renders file HTML 1`] = `
<article
class="file-holder js-hide-on-navigation limited-width-container readme-holder"
>
<div
class="file-title"
>
<i
aria-hidden="true"
class="fa fa-file-text-o fa-fw"
/>
<gllink-stub
href="http://test.com"
>
<strong>
README.md
</strong>
</gllink-stub>
</div>
<div
class="blob-viewer"
>
<div>
<div
class="blob"
>
test
</div>
</div>
</div>
</article>
`;
import { shallowMount } from '@vue/test-utils';
import { GlLoadingIcon } from '@gitlab/ui';
import Preview from '~/repository/components/preview/index.vue';
let vm;
let $apollo;
function factory(blob) {
$apollo = {
query: jest.fn().mockReturnValue(Promise.resolve({})),
};
vm = shallowMount(Preview, {
propsData: {
blob,
},
mocks: {
$apollo,
},
});
}
describe('Repository file preview component', () => {
afterEach(() => {
vm.destroy();
});
it('renders file HTML', () => {
factory({
webUrl: 'http://test.com',
name: 'README.md',
});
vm.setData({ readme: { html: '<div class="blob">test</div>' } });
expect(vm.element).toMatchSnapshot();
});
it('renders loading icon', () => {
factory({
webUrl: 'http://test.com',
name: 'README.md',
});
vm.setData({ loading: 1 });
expect(vm.find(GlLoadingIcon).exists()).toBe(true);
});
});
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlSkeletonLoading } from '@gitlab/ui'; import { GlSkeletonLoading } from '@gitlab/ui';
import Table from '~/repository/components/table/index.vue'; import Table from '~/repository/components/table/index.vue';
import TableRow from '~/repository/components/table/row.vue';
let vm; let vm;
let $apollo; let $apollo;
function factory(path, data = () => ({})) { const MOCK_BLOBS = [
$apollo = { {
query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })), id: '123abc',
}; flatPath: 'blob',
name: 'blob.md',
type: 'blob',
webUrl: 'http://test.com',
},
{
id: '124abc',
flatPath: 'blob2',
name: 'blob2.md',
type: 'blob',
webUrl: 'http://test.com',
},
];
function factory({ path, isLoading = false, entries = {} }) {
vm = shallowMount(Table, { vm = shallowMount(Table, {
propsData: { propsData: {
path, path,
isLoading,
entries,
}, },
mocks: { mocks: {
$apollo, $apollo,
...@@ -31,7 +47,7 @@ describe('Repository table component', () => { ...@@ -31,7 +47,7 @@ describe('Repository table component', () => {
${'app/assets'} | ${'master'} ${'app/assets'} | ${'master'}
${'/'} | ${'test'} ${'/'} | ${'test'}
`('renders table caption for $ref in $path', ({ path, ref }) => { `('renders table caption for $ref in $path', ({ path, ref }) => {
factory(path); factory({ path });
vm.setData({ ref }); vm.setData({ ref });
...@@ -41,40 +57,20 @@ describe('Repository table component', () => { ...@@ -41,40 +57,20 @@ describe('Repository table component', () => {
}); });
it('shows loading icon', () => { it('shows loading icon', () => {
factory('/'); factory({ path: '/', isLoading: true });
vm.setData({ isLoadingFiles: true });
expect(vm.find(GlSkeletonLoading).exists()).toBe(true); expect(vm.find(GlSkeletonLoading).exists()).toBe(true);
}); });
describe('normalizeData', () => { it('renders table rows', () => {
it('normalizes edge nodes', () => { factory({
const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]); path: '/',
entries: {
expect(output).toEqual(['1', '2']); blobs: MOCK_BLOBS,
},
}); });
});
describe('hasNextPage', () => {
it('returns undefined when hasNextPage is false', () => {
const output = vm.vm.hasNextPage({
trees: { pageInfo: { hasNextPage: false } },
submodules: { pageInfo: { hasNextPage: false } },
blobs: { pageInfo: { hasNextPage: false } },
});
expect(output).toBe(undefined); expect(vm.find(TableRow).exists()).toBe(true);
}); expect(vm.findAll(TableRow).length).toBe(2);
it('returns pageInfo object when hasNextPage is true', () => {
const output = vm.vm.hasNextPage({
trees: { pageInfo: { hasNextPage: false } },
submodules: { pageInfo: { hasNextPage: false } },
blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } },
});
expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' });
});
}); });
}); });
import { shallowMount } from '@vue/test-utils';
import TreeContent from '~/repository/components/tree_content.vue';
import FilePreview from '~/repository/components/preview/index.vue';
let vm;
let $apollo;
function factory(path, data = () => ({})) {
$apollo = {
query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })),
};
vm = shallowMount(TreeContent, {
propsData: {
path,
},
mocks: {
$apollo,
},
});
}
describe('Repository table component', () => {
afterEach(() => {
vm.destroy();
});
it('renders file preview', () => {
factory('/');
vm.setData({ entries: { blobs: [{ name: 'README.md ' }] } });
expect(vm.find(FilePreview).exists()).toBe(true);
});
describe('normalizeData', () => {
it('normalizes edge nodes', () => {
factory('/');
const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]);
expect(output).toEqual(['1', '2']);
});
});
describe('hasNextPage', () => {
it('returns undefined when hasNextPage is false', () => {
factory('/');
const output = vm.vm.hasNextPage({
trees: { pageInfo: { hasNextPage: false } },
submodules: { pageInfo: { hasNextPage: false } },
blobs: { pageInfo: { hasNextPage: false } },
});
expect(output).toBe(undefined);
});
it('returns pageInfo object when hasNextPage is true', () => {
factory('/');
const output = vm.vm.hasNextPage({
trees: { pageInfo: { hasNextPage: false } },
submodules: { pageInfo: { hasNextPage: false } },
blobs: { pageInfo: { hasNextPage: true, nextCursor: 'test' } },
});
expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' });
});
});
});
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