Commit a660415c authored by Phil Hughes's avatar Phil Hughes

Vue file listing refactor load README files

This changes the way README files are loaded with the Vue
refactor. Previously README files would get rendered server side
with this change we can show README when changing folders.

Closes https://gitlab.com/gitlab-org/gitlab/issues/35638
parent 2e4f1684
<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