Skip to content
Snippets Groups Projects
Unverified Commit d8bb8d45 authored by Phil Hughes's avatar Phil Hughes
Browse files

Pull files for repository tree from GraphQL API

parent c509b35b
No related branches found
No related tags found
No related merge requests found
Showing
with 253 additions and 87 deletions
<script>
import { GlLoadingIcon } from '@gitlab/ui';
import createFlash from '~/flash';
import { sprintf, __ } from '../../../locale';
import getRefMixin from '../../mixins/get_ref';
import getFiles from '../../queries/getFiles.graphql';
import getProjectPath from '../../queries/getProjectPath.graphql';
import TableHeader from './header.vue';
import TableRow from './row.vue';
 
const PAGE_SIZE = 100;
export default {
components: {
GlLoadingIcon,
Loading
Loading
@@ -14,14 +18,8 @@ export default {
},
mixins: [getRefMixin],
apollo: {
files: {
query: getFiles,
variables() {
return {
ref: this.ref,
path: this.path,
};
},
projectPath: {
query: getProjectPath,
},
},
props: {
Loading
Loading
@@ -32,7 +30,14 @@ export default {
},
data() {
return {
files: [],
projectPath: '',
nextPageCursor: '',
entries: {
trees: [],
submodules: [],
blobs: [],
},
isLoadingFiles: false,
};
},
computed: {
Loading
Loading
@@ -42,8 +47,63 @@ export default {
{ 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);
},
},
};
Loading
Loading
@@ -58,18 +118,21 @@ export default {
tableCaption
}}
</caption>
<table-header />
<table-header v-once />
<tbody>
<table-row
v-for="entry in files"
:id="entry.id"
:key="entry.id"
:path="entry.flatPath"
:type="entry.type"
/>
<template v-for="val in entries">
<table-row
v-for="entry in val"
:id="entry.id"
:key="`${entry.flatPath}-${entry.id}`"
:current-path="path"
:path="entry.flatPath"
:type="entry.type"
/>
</template>
</tbody>
</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>
</template>
Loading
Loading
@@ -6,7 +6,11 @@ export default {
mixins: [getRefMixin],
props: {
id: {
type: Number,
type: String,
required: true,
},
currentPath: {
type: String,
required: true,
},
path: {
Loading
Loading
@@ -26,7 +30,7 @@ export default {
return `fa-${getIconName(this.type, this.path)}`;
},
isFolder() {
return this.type === 'folder';
return this.type === 'tree';
},
isSubmodule() {
return this.type === 'commit';
Loading
Loading
@@ -34,6 +38,12 @@ export default {
linkComponent() {
return this.isFolder ? 'router-link' : 'a';
},
fullPath() {
return this.path.replace(new RegExp(`^${this.currentPath}/`), '');
},
shortSha() {
return this.id.slice(0, 8);
},
},
methods: {
openRow() {
Loading
Loading
@@ -49,9 +59,11 @@ export default {
<tr v-once :class="`file_${id}`" class="tree-item" @click="openRow">
<td class="tree-item-file-name">
<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">
@ <a href="#" class="commit-sha">{{ id }}</a>
@ <a href="#" class="commit-sha">{{ shortSha }}</a>
</template>
</td>
<td class="d-none d-sm-table-cell tree-commit"></td>
Loading
Loading
{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}}
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { IntrospectionFragmentMatcher } from 'apollo-cache-inmemory';
import createDefaultClient from '~/lib/graphql';
import introspectionQueryResultData from './fragmentTypes.json';
 
Vue.use(VueApollo);
 
const defaultClient = createDefaultClient({
Query: {
files() {
return [
{
__typename: 'file',
id: 1,
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',
id: 4,
name: 'test.pdf',
flatPath: 'fixtures/test.pdf',
type: 'blob',
},
];
// We create a fragment matcher so that we can create a fragment from an interface
// Without this, Apollo throws a heuristic fragment matcher warning
const fragmentMatcher = new IntrospectionFragmentMatcher({
introspectionQueryResultData,
});
const defaultClient = createDefaultClient(
{},
{
cacheConfig: {
fragmentMatcher,
dataIdFromObject: obj => {
// eslint-disable-next-line no-underscore-dangle
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({
defaultClient,
Loading
Loading
query getFiles($path: String!, $ref: String!) {
files(path: $path, ref: $ref) @client {
id
flatPath
type
fragment TreeEntry on Entry {
id
flatPath
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
}
}
}
}
}
}
query getProjectPath {
projectPath
}
Loading
Loading
@@ -11,17 +11,12 @@ export default function createRouter(base, baseRef) {
mode: 'history',
base: joinPaths(gon.relative_url_root || '', base),
routes: [
{
path: '/',
name: 'projectRoot',
component: IndexPage,
},
{
path: `/tree/${baseRef}(/.*)?`,
name: 'treePath',
component: TreePage,
props: route => ({
path: route.params.pathMatch,
path: route.params.pathMatch.replace(/^\//, ''),
}),
beforeEnter(to, from, next) {
document
Loading
Loading
@@ -31,6 +26,11 @@ export default function createRouter(base, baseRef) {
next();
},
},
{
path: '/',
name: 'projectRoot',
component: IndexPage,
},
],
});
}
const entryTypeIcons = {
folder: 'folder',
tree: 'folder',
commit: 'archive',
};
 
Loading
Loading
Loading
Loading
@@ -835,6 +835,9 @@ msgstr ""
msgid "An error has occurred"
msgstr ""
 
msgid "An error occurding while fetching folder content."
msgstr ""
msgid "An error occurred creating the new branch."
msgstr ""
 
Loading
Loading
Loading
Loading
@@ -16,7 +16,9 @@ exports[`Repository table row component renders table row 1`] = `
<a
class="str-truncated"
>
test
</a>
<!---->
Loading
Loading
Loading
Loading
@@ -3,18 +3,19 @@ import { GlLoadingIcon } from '@gitlab/ui';
import Table from '~/repository/components/table/index.vue';
 
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, {
propsData: {
path,
},
mocks: {
$apollo: {
queries: {
files: { loading },
},
},
$apollo,
},
});
}
Loading
Loading
@@ -39,9 +40,41 @@ describe('Repository table component', () => {
);
});
 
it('renders loading icon', () => {
factory('/', true);
it('shows loading icon', () => {
factory('/');
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(vm.find(GlLoadingIcon).exists()).toBe(true);
expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' });
});
});
});
Loading
Loading
@@ -29,9 +29,10 @@ describe('Repository table row component', () => {
 
it('renders table row', () => {
factory({
id: 1,
id: '1',
path: 'test',
type: 'file',
currentPath: '/',
});
 
expect(vm.element).toMatchSnapshot();
Loading
Loading
@@ -39,14 +40,15 @@ describe('Repository table row component', () => {
 
it.each`
type | component | componentName
${'folder'} | ${RouterLinkStub} | ${'RouterLink'}
${'tree'} | ${RouterLinkStub} | ${'RouterLink'}
${'file'} | ${'a'} | ${'hyperlink'}
${'commit'} | ${'a'} | ${'hyperlink'}
`('renders a $componentName for type $type', ({ type, component }) => {
factory({
id: 1,
id: '1',
path: 'test',
type,
currentPath: '/',
});
 
expect(vm.find(component).exists()).toBe(true);
Loading
Loading
@@ -54,14 +56,15 @@ describe('Repository table row component', () => {
 
it.each`
type | pushes
${'folder'} | ${true}
${'tree'} | ${true}
${'file'} | ${false}
${'commit'} | ${false}
`('pushes new router if type $type is folder', ({ type, pushes }) => {
`('pushes new router if type $type is tree', ({ type, pushes }) => {
factory({
id: 1,
id: '1',
path: 'test',
type,
currentPath: '/',
});
 
vm.trigger('click');
Loading
Loading
@@ -75,9 +78,10 @@ describe('Repository table row component', () => {
 
it('renders commit ID for submodule', () => {
factory({
id: 1,
id: '1',
path: 'test',
type: 'commit',
currentPath: '/',
});
 
expect(vm.find('.commit-sha').text()).toContain('1');
Loading
Loading
Loading
Loading
@@ -6,7 +6,7 @@ describe('getIconName', () => {
// file types
it.each`
type | path | icon
${'folder'} | ${''} | ${'folder'}
${'tree'} | ${''} | ${'folder'}
${'commit'} | ${''} | ${'archive'}
${'file'} | ${'test.pdf'} | ${'file-pdf-o'}
${'file'} | ${'test.jpg'} | ${'file-image-o'}
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment