Commit d8bb8d45 authored by Phil Hughes's avatar Phil Hughes

Pull files for repository tree from GraphQL API

parent c509b35b
<script> <script>
import { GlLoadingIcon } from '@gitlab/ui'; import { GlLoadingIcon } 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.graphql'; import getFiles from '../../queries/getFiles.graphql';
import getProjectPath from '../../queries/getProjectPath.graphql';
import TableHeader from './header.vue'; import TableHeader from './header.vue';
import TableRow from './row.vue'; import TableRow from './row.vue';
const PAGE_SIZE = 100;
export default { export default {
components: { components: {
GlLoadingIcon, GlLoadingIcon,
...@@ -14,14 +18,8 @@ export default { ...@@ -14,14 +18,8 @@ export default {
}, },
mixins: [getRefMixin], mixins: [getRefMixin],
apollo: { apollo: {
files: { projectPath: {
query: getFiles, query: getProjectPath,
variables() {
return {
ref: this.ref,
path: this.path,
};
},
}, },
}, },
props: { props: {
...@@ -32,7 +30,14 @@ export default { ...@@ -32,7 +30,14 @@ export default {
}, },
data() { data() {
return { return {
files: [], projectPath: '',
nextPageCursor: '',
entries: {
trees: [],
submodules: [],
blobs: [],
},
isLoadingFiles: false,
}; };
}, },
computed: { computed: {
...@@ -42,8 +47,63 @@ export default { ...@@ -42,8 +47,63 @@ export default {
{ path: this.path, ref: this.ref }, { path: this.path, ref: this.ref },
); );
}, },
isLoadingFiles() { },
return this.$apollo.queries.files.loading; 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 occurding 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);
}, },
}, },
}; };
...@@ -58,18 +118,21 @@ export default { ...@@ -58,18 +118,21 @@ export default {
tableCaption tableCaption
}} }}
</caption> </caption>
<table-header /> <table-header v-once />
<tbody> <tbody>
<template v-for="val in entries">
<table-row <table-row
v-for="entry in files" v-for="entry in val"
:id="entry.id" :id="entry.id"
:key="entry.id" :key="`${entry.flatPath}-${entry.id}`"
:current-path="path"
:path="entry.flatPath" :path="entry.flatPath"
:type="entry.type" :type="entry.type"
/> />
</template>
</tbody> </tbody>
</table> </table>
<gl-loading-icon v-if="isLoadingFiles" class="my-3" size="md" /> <gl-loading-icon v-show="isLoadingFiles" class="my-3" size="md" />
</div> </div>
</div> </div>
</template> </template>
...@@ -6,7 +6,11 @@ export default { ...@@ -6,7 +6,11 @@ export default {
mixins: [getRefMixin], mixins: [getRefMixin],
props: { props: {
id: { id: {
type: Number, type: String,
required: true,
},
currentPath: {
type: String,
required: true, required: true,
}, },
path: { path: {
...@@ -26,7 +30,7 @@ export default { ...@@ -26,7 +30,7 @@ export default {
return `fa-${getIconName(this.type, this.path)}`; return `fa-${getIconName(this.type, this.path)}`;
}, },
isFolder() { isFolder() {
return this.type === 'folder'; return this.type === 'tree';
}, },
isSubmodule() { isSubmodule() {
return this.type === 'commit'; return this.type === 'commit';
...@@ -34,6 +38,12 @@ export default { ...@@ -34,6 +38,12 @@ export default {
linkComponent() { linkComponent() {
return this.isFolder ? 'router-link' : 'a'; return this.isFolder ? 'router-link' : 'a';
}, },
fullPath() {
return this.path.replace(new RegExp(`^${this.currentPath}/`), '');
},
shortSha() {
return this.id.slice(0, 8);
},
}, },
methods: { methods: {
openRow() { openRow() {
...@@ -49,9 +59,11 @@ export default { ...@@ -49,9 +59,11 @@ export default {
<tr v-once :class="`file_${id}`" class="tree-item" @click="openRow"> <tr v-once :class="`file_${id}`" class="tree-item" @click="openRow">
<td class="tree-item-file-name"> <td class="tree-item-file-name">
<i :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i> <i :aria-label="type" role="img" :class="iconName" class="fa fa-fw"></i>
<component :is="linkComponent" :to="routerLinkTo" class="str-truncated">{{ path }}</component> <component :is="linkComponent" :to="routerLinkTo" class="str-truncated">
{{ fullPath }}
</component>
<template v-if="isSubmodule"> <template v-if="isSubmodule">
@ <a href="#" class="commit-sha">{{ id }}</a> @ <a href="#" class="commit-sha">{{ shortSha }}</a>
</template> </template>
</td> </td>
<td class="d-none d-sm-table-cell tree-commit"></td> <td class="d-none d-sm-table-cell tree-commit"></td>
......
{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}}
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 createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json';
Vue.use(VueApollo); Vue.use(VueApollo);
const defaultClient = createDefaultClient({ // We create a fragment matcher so that we can create a fragment from an interface
Query: { // Without this, Apollo throws a heuristic fragment matcher warning
files() { const fragmentMatcher = new IntrospectionFragmentMatcher({
return [ introspectionQueryResultData,
{ });
__typename: 'file',
id: 1, const defaultClient = createDefaultClient(
name: 'app', {},
flatPath: 'app',
type: 'folder',
},
{
__typename: 'file',
id: 2,
name: 'gitlab-svg',
flatPath: 'gitlab-svg',
type: 'commit',
},
{
__typename: 'file',
id: 3,
name: 'index.js',
flatPath: 'index.js',
type: 'blob',
},
{ {
__typename: 'file', cacheConfig: {
id: 4, fragmentMatcher,
name: 'test.pdf', dataIdFromObject: obj => {
flatPath: 'fixtures/test.pdf', // eslint-disable-next-line no-underscore-dangle
type: 'blob', switch (obj.__typename) {
// We need to create a dynamic ID for each entry
// Each entry can have the same ID as the ID is a commit ID
// So we create a unique cache ID with the path and the ID
case 'TreeEntry':
case 'Submodule':
case 'Blob':
return `${obj.flatPath}-${obj.id}`;
default:
// If the type doesn't match any of the above we fallback
// to using the default Apollo ID
// eslint-disable-next-line no-underscore-dangle
return obj.id || obj._id;
}
}, },
];
}, },
}, },
}); );
export default new VueApollo({ export default new VueApollo({
defaultClient, defaultClient,
......
query getFiles($path: String!, $ref: String!) { fragment TreeEntry on Entry {
files(path: $path, ref: $ref) @client {
id id
flatPath flatPath
type type
}
fragment PageInfo on PageInfo {
hasNextPage
endCursor
}
query getFiles(
$projectPath: ID!
$path: String
$ref: String!
$pageSize: Int!
$nextPageCursor: String
) {
project(fullPath: $projectPath) {
repository {
tree(path: $path, ref: $ref) {
trees(first: $pageSize, after: $nextPageCursor) {
edges {
node {
...TreeEntry
}
}
pageInfo {
...PageInfo
}
}
submodules(first: $pageSize, after: $nextPageCursor) {
edges {
node {
...TreeEntry
}
}
pageInfo {
...PageInfo
}
}
blobs(first: $pageSize, after: $nextPageCursor) {
edges {
node {
...TreeEntry
}
}
pageInfo {
...PageInfo
}
}
}
}
} }
} }
...@@ -11,17 +11,12 @@ export default function createRouter(base, baseRef) { ...@@ -11,17 +11,12 @@ export default function createRouter(base, baseRef) {
mode: 'history', mode: 'history',
base: joinPaths(gon.relative_url_root || '', base), base: joinPaths(gon.relative_url_root || '', base),
routes: [ routes: [
{
path: '/',
name: 'projectRoot',
component: IndexPage,
},
{ {
path: `/tree/${baseRef}(/.*)?`, path: `/tree/${baseRef}(/.*)?`,
name: 'treePath', name: 'treePath',
component: TreePage, component: TreePage,
props: route => ({ props: route => ({
path: route.params.pathMatch, path: route.params.pathMatch.replace(/^\//, ''),
}), }),
beforeEnter(to, from, next) { beforeEnter(to, from, next) {
document document
...@@ -31,6 +26,11 @@ export default function createRouter(base, baseRef) { ...@@ -31,6 +26,11 @@ export default function createRouter(base, baseRef) {
next(); next();
}, },
}, },
{
path: '/',
name: 'projectRoot',
component: IndexPage,
},
], ],
}); });
} }
const entryTypeIcons = { const entryTypeIcons = {
folder: 'folder', tree: 'folder',
commit: 'archive', commit: 'archive',
}; };
......
...@@ -835,6 +835,9 @@ msgstr "" ...@@ -835,6 +835,9 @@ msgstr ""
msgid "An error has occurred" msgid "An error has occurred"
msgstr "" msgstr ""
msgid "An error occurding while fetching folder content."
msgstr ""
msgid "An error occurred creating the new branch." msgid "An error occurred creating the new branch."
msgstr "" msgstr ""
......
...@@ -16,7 +16,9 @@ exports[`Repository table row component renders table row 1`] = ` ...@@ -16,7 +16,9 @@ exports[`Repository table row component renders table row 1`] = `
<a <a
class="str-truncated" class="str-truncated"
> >
test test
</a> </a>
<!----> <!---->
......
...@@ -3,18 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui'; ...@@ -3,18 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui';
import Table from '~/repository/components/table/index.vue'; import Table from '~/repository/components/table/index.vue';
let vm; let vm;
let $apollo;
function factory(path, data = () => ({})) {
$apollo = {
query: jest.fn().mockReturnValue(Promise.resolve({ data: data() })),
};
function factory(path, loading = false) {
vm = shallowMount(Table, { vm = shallowMount(Table, {
propsData: { propsData: {
path, path,
}, },
mocks: { mocks: {
$apollo: { $apollo,
queries: {
files: { loading },
},
},
}, },
}); });
} }
...@@ -39,9 +40,41 @@ describe('Repository table component', () => { ...@@ -39,9 +40,41 @@ describe('Repository table component', () => {
); );
}); });
it('renders loading icon', () => { it('shows loading icon', () => {
factory('/', true); factory('/');
expect(vm.find(GlLoadingIcon).exists()).toBe(true); vm.setData({ isLoadingFiles: true });
expect(vm.find(GlLoadingIcon).isVisible()).toBe(true);
});
describe('normalizeData', () => {
it('normalizes edge nodes', () => {
const output = vm.vm.normalizeData('blobs', [{ node: '1' }, { node: '2' }]);
expect(output).toEqual(['1', '2']);
});
});
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);
});
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' });
});
}); });
}); });
...@@ -29,9 +29,10 @@ describe('Repository table row component', () => { ...@@ -29,9 +29,10 @@ describe('Repository table row component', () => {
it('renders table row', () => { it('renders table row', () => {
factory({ factory({
id: 1, id: '1',
path: 'test', path: 'test',
type: 'file', type: 'file',
currentPath: '/',
}); });
expect(vm.element).toMatchSnapshot(); expect(vm.element).toMatchSnapshot();
...@@ -39,14 +40,15 @@ describe('Repository table row component', () => { ...@@ -39,14 +40,15 @@ describe('Repository table row component', () => {
it.each` it.each`
type | component | componentName type | component | componentName
${'folder'} | ${RouterLinkStub} | ${'RouterLink'} ${'tree'} | ${RouterLinkStub} | ${'RouterLink'}
${'file'} | ${'a'} | ${'hyperlink'} ${'file'} | ${'a'} | ${'hyperlink'}
${'commit'} | ${'a'} | ${'hyperlink'} ${'commit'} | ${'a'} | ${'hyperlink'}
`('renders a $componentName for type $type', ({ type, component }) => { `('renders a $componentName for type $type', ({ type, component }) => {
factory({ factory({
id: 1, id: '1',
path: 'test', path: 'test',
type, type,
currentPath: '/',
}); });
expect(vm.find(component).exists()).toBe(true); expect(vm.find(component).exists()).toBe(true);
...@@ -54,14 +56,15 @@ describe('Repository table row component', () => { ...@@ -54,14 +56,15 @@ describe('Repository table row component', () => {
it.each` it.each`
type | pushes type | pushes
${'folder'} | ${true} ${'tree'} | ${true}
${'file'} | ${false} ${'file'} | ${false}
${'commit'} | ${false} ${'commit'} | ${false}
`('pushes new router if type $type is folder', ({ type, pushes }) => { `('pushes new router if type $type is tree', ({ type, pushes }) => {
factory({ factory({
id: 1, id: '1',
path: 'test', path: 'test',
type, type,
currentPath: '/',
}); });
vm.trigger('click'); vm.trigger('click');
...@@ -75,9 +78,10 @@ describe('Repository table row component', () => { ...@@ -75,9 +78,10 @@ describe('Repository table row component', () => {
it('renders commit ID for submodule', () => { it('renders commit ID for submodule', () => {
factory({ factory({
id: 1, id: '1',
path: 'test', path: 'test',
type: 'commit', type: 'commit',
currentPath: '/',
}); });
expect(vm.find('.commit-sha').text()).toContain('1'); expect(vm.find('.commit-sha').text()).toContain('1');
......
...@@ -6,7 +6,7 @@ describe('getIconName', () => { ...@@ -6,7 +6,7 @@ describe('getIconName', () => {
// file types // file types
it.each` it.each`
type | path | icon type | path | icon
${'folder'} | ${''} | ${'folder'} ${'tree'} | ${''} | ${'folder'}
${'commit'} | ${''} | ${'archive'} ${'commit'} | ${''} | ${'archive'}
${'file'} | ${'test.pdf'} | ${'file-pdf-o'} ${'file'} | ${'test.pdf'} | ${'file-pdf-o'}
${'file'} | ${'test.jpg'} | ${'file-image-o'} ${'file'} | ${'test.jpg'} | ${'file-image-o'}
......
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