Merge branch 'vue-repo-list-backend-frontend' into 'master'
Pull files for repository tree from GraphQL API See merge request gitlab-org/gitlab-ce!28638
This commit is contained in:
commit
7307a7627c
13 changed files with 253 additions and 87 deletions
|
@ -1,11 +1,15 @@
|
|||
<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,
|
||||
|
@ -14,14 +18,8 @@ export default {
|
|||
},
|
||||
mixins: [getRefMixin],
|
||||
apollo: {
|
||||
files: {
|
||||
query: getFiles,
|
||||
variables() {
|
||||
return {
|
||||
ref: this.ref,
|
||||
path: this.path,
|
||||
};
|
||||
},
|
||||
projectPath: {
|
||||
query: getProjectPath,
|
||||
},
|
||||
},
|
||||
props: {
|
||||
|
@ -32,7 +30,14 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
files: [],
|
||||
projectPath: '',
|
||||
nextPageCursor: '',
|
||||
entries: {
|
||||
trees: [],
|
||||
submodules: [],
|
||||
blobs: [],
|
||||
},
|
||||
isLoadingFiles: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
@ -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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -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>
|
||||
|
|
|
@ -6,7 +6,11 @@ export default {
|
|||
mixins: [getRefMixin],
|
||||
props: {
|
||||
id: {
|
||||
type: Number,
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
currentPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
path: {
|
||||
|
@ -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';
|
||||
|
@ -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() {
|
||||
|
@ -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>
|
||||
|
|
1
app/assets/javascripts/repository/fragmentTypes.json
Normal file
1
app/assets/javascripts/repository/fragmentTypes.json
Normal file
|
@ -0,0 +1 @@
|
|||
{"__schema":{"types":[{"kind":"INTERFACE","name":"Entry","possibleTypes":[{"name":"Blob"},{"name":"Submodule"},{"name":"TreeEntry"}]}]}}
|
|
@ -1,45 +1,42 @@
|
|||
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,
|
||||
|
|
|
@ -1,7 +1,55 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
query getProjectPath {
|
||||
projectPath
|
||||
}
|
|
@ -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
|
||||
|
@ -31,6 +26,11 @@ export default function createRouter(base, baseRef) {
|
|||
next();
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
name: 'projectRoot',
|
||||
component: IndexPage,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
const entryTypeIcons = {
|
||||
folder: 'folder',
|
||||
tree: 'folder',
|
||||
commit: 'archive',
|
||||
};
|
||||
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -16,7 +16,9 @@ exports[`Repository table row component renders table row 1`] = `
|
|||
<a
|
||||
class="str-truncated"
|
||||
>
|
||||
|
||||
test
|
||||
|
||||
</a>
|
||||
|
||||
<!---->
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -39,9 +40,41 @@ describe('Repository table component', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('renders loading icon', () => {
|
||||
factory('/', true);
|
||||
it('shows loading icon', () => {
|
||||
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', () => {
|
|||
|
||||
it('renders table row', () => {
|
||||
factory({
|
||||
id: 1,
|
||||
id: '1',
|
||||
path: 'test',
|
||||
type: 'file',
|
||||
currentPath: '/',
|
||||
});
|
||||
|
||||
expect(vm.element).toMatchSnapshot();
|
||||
|
@ -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);
|
||||
|
@ -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');
|
||||
|
@ -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');
|
||||
|
|
|
@ -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…
Reference in a new issue