Commit 12edecd0 authored by Phil Hughes's avatar Phil Hughes

Add headers to files in the tree list on merge requests

Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/54807
parent 1d2ef4c6
...@@ -34,14 +34,18 @@ export default { ...@@ -34,14 +34,18 @@ export default {
if (search === '') return this.renderTreeList ? this.tree : this.allBlobs; if (search === '') return this.renderTreeList ? this.tree : this.allBlobs;
return this.allBlobs.filter(f => f.path.toLowerCase().indexOf(search) >= 0); return this.allBlobs.reduce((acc, folder) => {
}, const tree = folder.tree.filter(f => f.path.toLowerCase().indexOf(search) >= 0);
rowDisplayTextKey() {
if (this.renderTreeList && this.search.trim() === '') { if (tree.length) {
return 'name'; return acc.concat({
...folder,
tree,
});
} }
return 'path'; return acc;
}, []);
}, },
}, },
methods: { methods: {
...@@ -119,7 +123,7 @@ export default { ...@@ -119,7 +123,7 @@ export default {
</button> </button>
</div> </div>
</div> </div>
<div class="tree-list-scroll"> <div :class="{ 'pt-0 tree-list-blobs': !renderTreeList }" class="tree-list-scroll">
<template v-if="filteredTreeList.length"> <template v-if="filteredTreeList.length">
<file-row <file-row
v-for="file in filteredTreeList" v-for="file in filteredTreeList"
...@@ -129,8 +133,6 @@ export default { ...@@ -129,8 +133,6 @@ export default {
:hide-extra-on-tree="true" :hide-extra-on-tree="true"
:extra-component="$options.FileRowStats" :extra-component="$options.FileRowStats"
:show-changed-icon="true" :show-changed-icon="true"
:display-text-key="rowDisplayTextKey"
:should-truncate-start="true"
@toggleTreeOpen="toggleTreeOpen" @toggleTreeOpen="toggleTreeOpen"
@clickFile="scrollToFile" @clickFile="scrollToFile"
/> />
...@@ -148,3 +150,9 @@ export default { ...@@ -148,3 +150,9 @@ export default {
</div> </div>
</div> </div>
</template> </template>
<style>
.tree-list-blobs .file-row-name {
margin-left: 12px;
}
</style>
...@@ -74,7 +74,24 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) = ...@@ -74,7 +74,24 @@ export const getDiffFileDiscussions = (state, getters, rootState, rootGetters) =
export const getDiffFileByHash = state => fileHash => export const getDiffFileByHash = state => fileHash =>
state.diffFiles.find(file => file.file_hash === fileHash); state.diffFiles.find(file => file.file_hash === fileHash);
export const allBlobs = state => Object.values(state.treeEntries).filter(f => f.type === 'blob'); export const allBlobs = state =>
Object.values(state.treeEntries)
.filter(f => f.type === 'blob')
.reduce((acc, file) => {
const { parentPath } = file;
if (parentPath && !acc.some(f => f.path === parentPath)) {
acc.push({
path: parentPath,
isHeader: true,
tree: [],
});
}
acc.find(f => f.path === parentPath).tree.push(file);
return acc;
}, []);
export const diffFilesLength = state => state.diffFiles.length; export const diffFilesLength = state => state.diffFiles.length;
......
...@@ -318,6 +318,7 @@ export const generateTreeList = files => ...@@ -318,6 +318,7 @@ export const generateTreeList = files =>
fileHash: file.file_hash, fileHash: file.file_hash,
addedLines: file.added_lines, addedLines: file.added_lines,
removedLines: file.removed_lines, removedLines: file.removed_lines,
parentPath: parent ? `${parent.path}/` : '/',
}); });
} else { } else {
Object.assign(entry, { Object.assign(entry, {
......
...@@ -72,6 +72,29 @@ export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3 ...@@ -72,6 +72,29 @@ export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3
*/ */
export const truncateSha = sha => sha.substr(0, 8); export const truncateSha = sha => sha.substr(0, 8);
const ELLIPSIS_CHAR = '';
export const truncatePathMiddleToLength = (text, maxWidth) => {
let returnText = text;
let ellipsisCount = 0;
while (returnText.length >= maxWidth) {
const textSplit = returnText.split('/').filter(s => s !== ELLIPSIS_CHAR);
const middleIndex = Math.floor(textSplit.length / 2);
returnText = textSplit
.slice(0, middleIndex)
.concat(
new Array(ellipsisCount + 1).fill().map(() => ELLIPSIS_CHAR),
textSplit.slice(middleIndex + 1),
)
.join('/');
ellipsisCount += 1;
}
return returnText;
};
/** /**
* Capitalizes first character * Capitalizes first character
* *
......
<script> <script>
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import FileHeader from '~/vue_shared/components/file_row_header.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
export default { export default {
name: 'FileRow', name: 'FileRow',
components: { components: {
FileHeader,
FileIcon, FileIcon,
Icon, Icon,
ChangedFileIcon, ChangedFileIcon,
...@@ -34,21 +36,10 @@ export default { ...@@ -34,21 +36,10 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
displayTextKey: {
type: String,
required: false,
default: 'name',
},
shouldTruncateStart: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
mouseOver: false, mouseOver: false,
truncateStart: 0,
}; };
}, },
computed: { computed: {
...@@ -60,7 +51,7 @@ export default { ...@@ -60,7 +51,7 @@ export default {
}, },
levelIndentation() { levelIndentation() {
return { return {
marginLeft: `${this.level * 16}px`, marginLeft: this.level ? `${this.level * 16}px` : null,
}; };
}, },
fileClass() { fileClass() {
...@@ -71,14 +62,8 @@ export default { ...@@ -71,14 +62,8 @@ export default {
'is-open': this.file.opened, 'is-open': this.file.opened,
}; };
}, },
outputText() { childFilesLevel() {
const text = this.file[this.displayTextKey]; return this.file.isHeader ? 0 : this.level + 1;
if (this.truncateStart === 0) {
return text;
}
return `...${text.substring(this.truncateStart, text.length)}`;
}, },
}, },
watch: { watch: {
...@@ -92,15 +77,6 @@ export default { ...@@ -92,15 +77,6 @@ export default {
if (this.hasPathAtCurrentRoute()) { if (this.hasPathAtCurrentRoute()) {
this.scrollIntoView(true); this.scrollIntoView(true);
} }
if (this.shouldTruncateStart) {
const { scrollWidth, offsetWidth } = this.$refs.textOutput;
const textOverflow = scrollWidth - offsetWidth;
if (textOverflow > 0) {
this.truncateStart = Math.ceil(textOverflow / 5) + 3;
}
}
}, },
methods: { methods: {
toggleTreeOpen(path) { toggleTreeOpen(path) {
...@@ -156,7 +132,9 @@ export default { ...@@ -156,7 +132,9 @@ export default {
<template> <template>
<div> <div>
<file-header v-if="file.isHeader" :path="file.path" />
<div <div
v-else
:class="fileClass" :class="fileClass"
class="file-row" class="file-row"
role="button" role="button"
...@@ -175,7 +153,7 @@ export default { ...@@ -175,7 +153,7 @@ export default {
:size="16" :size="16"
/> />
<changed-file-icon v-else :file="file" :size="16" class="append-right-5" /> <changed-file-icon v-else :file="file" :size="16" class="append-right-5" />
{{ outputText }} {{ file.name }}
</span> </span>
<component <component
:is="extraComponent" :is="extraComponent"
...@@ -185,17 +163,15 @@ export default { ...@@ -185,17 +163,15 @@ export default {
/> />
</div> </div>
</div> </div>
<template v-if="file.opened"> <template v-if="file.opened || file.isHeader">
<file-row <file-row
v-for="childFile in file.tree" v-for="childFile in file.tree"
:key="childFile.key" :key="childFile.key"
:file="childFile" :file="childFile"
:level="level + 1" :level="childFilesLevel"
:hide-extra-on-tree="hideExtraOnTree" :hide-extra-on-tree="hideExtraOnTree"
:extra-component="extraComponent" :extra-component="extraComponent"
:show-changed-icon="showChangedIcon" :show-changed-icon="showChangedIcon"
:display-text-key="displayTextKey"
:should-truncate-start="shouldTruncateStart"
@toggleTreeOpen="toggleTreeOpen" @toggleTreeOpen="toggleTreeOpen"
@clickFile="clickedFile" @clickFile="clickedFile"
/> />
......
<script>
import { truncatePathMiddleToLength } from '~/lib/utils/text_utility';
const MAX_PATH_LENGTH = 40;
export default {
props: {
path: {
type: String,
required: true,
},
},
computed: {
truncatedPath() {
return truncatePathMiddleToLength(this.path, MAX_PATH_LENGTH);
},
},
};
</script>
<template>
<div class="file-row-header bg-white sticky-top p-2 js-file-row-header">
<span class="bold">{{ truncatedPath }}</span>
</div>
</template>
---
title: Add folder header to files in merge request tree list
merge_request:
author:
type: changed
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`File row header component adds multiple ellipsises after 40 characters 1`] = `
<div
class="file-row-header bg-white sticky-top p-2 js-file-row-header"
>
<span
class="bold"
>
app/assets/javascripts/…/…/diffs/notes
</span>
</div>
`;
exports[`File row header component renders file path 1`] = `
<div
class="file-row-header bg-white sticky-top p-2 js-file-row-header"
>
<span
class="bold"
>
app/assets
</span>
</div>
`;
exports[`File row header component trucates path after 40 characters 1`] = `
<div
class="file-row-header bg-white sticky-top p-2 js-file-row-header"
>
<span
class="bold"
>
app/assets/javascripts/merge_requests
</span>
</div>
`;
import { shallowMount } from '@vue/test-utils';
import FileRowHeader from '~/vue_shared/components/file_row_header.vue';
describe('File row header component', () => {
let vm;
function createComponent(path) {
vm = shallowMount(FileRowHeader, {
propsData: {
path,
},
});
}
afterEach(() => {
vm.destroy();
});
it('renders file path', () => {
createComponent('app/assets');
expect(vm.element).toMatchSnapshot();
});
it('trucates path after 40 characters', () => {
createComponent('app/assets/javascripts/merge_requests');
expect(vm.element).toMatchSnapshot();
});
it('adds multiple ellipsises after 40 characters', () => {
createComponent('app/assets/javascripts/merge_requests/widget/diffs/notes');
expect(vm.element).toMatchSnapshot();
});
});
...@@ -26,6 +26,8 @@ describe('Diffs tree list component', () => { ...@@ -26,6 +26,8 @@ describe('Diffs tree list component', () => {
store.state.diffs.removedLines = 20; store.state.diffs.removedLines = 20;
store.state.diffs.diffFiles.push('test'); store.state.diffs.diffFiles.push('test');
localStorage.removeItem('mr_diff_tree_list');
vm = mountComponentWithStore(Component, { store }); vm = mountComponentWithStore(Component, { store });
}); });
...@@ -57,6 +59,7 @@ describe('Diffs tree list component', () => { ...@@ -57,6 +59,7 @@ describe('Diffs tree list component', () => {
removedLines: 0, removedLines: 0,
tempFile: true, tempFile: true,
type: 'blob', type: 'blob',
parentPath: 'app',
}, },
app: { app: {
key: 'app', key: 'app',
...@@ -121,7 +124,7 @@ describe('Diffs tree list component', () => { ...@@ -121,7 +124,7 @@ describe('Diffs tree list component', () => {
vm.renderTreeList = false; vm.renderTreeList = false;
vm.$nextTick(() => { vm.$nextTick(() => {
expect(vm.$el.querySelector('.file-row').textContent).toContain('app/index.js'); expect(vm.$el.querySelector('.file-row').textContent).toContain('index.js');
done(); done();
}); });
......
...@@ -230,16 +230,31 @@ describe('Diffs Module Getters', () => { ...@@ -230,16 +230,31 @@ describe('Diffs Module Getters', () => {
localState.treeEntries = { localState.treeEntries = {
file: { file: {
type: 'blob', type: 'blob',
path: 'file',
parentPath: '/',
tree: [],
}, },
tree: { tree: {
type: 'tree', type: 'tree',
path: 'tree',
parentPath: '/',
tree: [],
}, },
}; };
expect(getters.allBlobs(localState)).toEqual([ expect(getters.allBlobs(localState)).toEqual([
{ {
isHeader: true,
path: '/',
tree: [
{
parentPath: '/',
path: 'file',
tree: [],
type: 'blob', type: 'blob',
}, },
],
},
]); ]);
}); });
}); });
......
...@@ -502,6 +502,7 @@ describe('DiffsStoreUtils', () => { ...@@ -502,6 +502,7 @@ describe('DiffsStoreUtils', () => {
fileHash: 'test', fileHash: 'test',
key: 'app/index.js', key: 'app/index.js',
name: 'index.js', name: 'index.js',
parentPath: 'app/',
path: 'app/index.js', path: 'app/index.js',
removedLines: 10, removedLines: 10,
tempFile: false, tempFile: false,
...@@ -522,6 +523,7 @@ describe('DiffsStoreUtils', () => { ...@@ -522,6 +523,7 @@ describe('DiffsStoreUtils', () => {
fileHash: 'test', fileHash: 'test',
key: 'app/test/index.js', key: 'app/test/index.js',
name: 'index.js', name: 'index.js',
parentPath: 'app/test/',
path: 'app/test/index.js', path: 'app/test/index.js',
removedLines: 0, removedLines: 0,
tempFile: true, tempFile: true,
...@@ -535,6 +537,7 @@ describe('DiffsStoreUtils', () => { ...@@ -535,6 +537,7 @@ describe('DiffsStoreUtils', () => {
fileHash: 'test', fileHash: 'test',
key: 'app/test/filepathneedstruncating.js', key: 'app/test/filepathneedstruncating.js',
name: 'filepathneedstruncating.js', name: 'filepathneedstruncating.js',
parentPath: 'app/test/',
path: 'app/test/filepathneedstruncating.js', path: 'app/test/filepathneedstruncating.js',
removedLines: 0, removedLines: 0,
tempFile: true, tempFile: true,
...@@ -548,6 +551,7 @@ describe('DiffsStoreUtils', () => { ...@@ -548,6 +551,7 @@ describe('DiffsStoreUtils', () => {
}, },
{ {
key: 'package.json', key: 'package.json',
parentPath: '/',
path: 'package.json', path: 'package.json',
name: 'package.json', name: 'package.json',
type: 'blob', type: 'blob',
......
...@@ -135,4 +135,20 @@ describe('text_utility', () => { ...@@ -135,4 +135,20 @@ describe('text_utility', () => {
expect(textUtils.getFirstCharacterCapitalized(null)).toEqual(''); expect(textUtils.getFirstCharacterCapitalized(null)).toEqual('');
}); });
}); });
describe('truncatePathMiddleToLength', () => {
it('does not truncate text', () => {
expect(textUtils.truncatePathMiddleToLength('app/test', 50)).toEqual('app/test');
});
it('truncates middle of the path', () => {
expect(textUtils.truncatePathMiddleToLength('app/test/diff', 13)).toEqual('app/…/diff');
});
it('truncates multiple times in the middle of the path', () => {
expect(textUtils.truncatePathMiddleToLength('app/test/merge_request/diff', 13)).toEqual(
'app/…/…/diff',
);
});
});
}); });
...@@ -3,7 +3,7 @@ import FileRow from '~/vue_shared/components/file_row.vue'; ...@@ -3,7 +3,7 @@ import FileRow from '~/vue_shared/components/file_row.vue';
import { file } from 'spec/ide/helpers'; import { file } from 'spec/ide/helpers';
import mountComponent from '../../helpers/vue_mount_component_helper'; import mountComponent from '../../helpers/vue_mount_component_helper';
describe('RepoFile', () => { describe('File row component', () => {
let vm; let vm;
function createComponent(propsData) { function createComponent(propsData) {
...@@ -72,39 +72,16 @@ describe('RepoFile', () => { ...@@ -72,39 +72,16 @@ describe('RepoFile', () => {
expect(vm.$el.querySelector('.file-row-name').style.marginLeft).toBe('32px'); expect(vm.$el.querySelector('.file-row-name').style.marginLeft).toBe('32px');
}); });
describe('outputText', () => { it('renders header for file', () => {
beforeEach(done => {
createComponent({ createComponent({
file: { file: {
...file(), isHeader: true,
path: 'app/assets/index.js', path: 'app/assets',
tree: [],
}, },
level: 0, level: 0,
}); });
vm.displayTextKey = 'path'; expect(vm.$el.querySelector('.js-file-row-header')).not.toBe(null);
vm.$nextTick(done);
});
it('returns text if truncateStart is 0', done => {
vm.truncateStart = 0;
vm.$nextTick(() => {
expect(vm.outputText).toBe('app/assets/index.js');
done();
});
});
it('returns text truncated at start', done => {
vm.truncateStart = 5;
vm.$nextTick(() => {
expect(vm.outputText).toBe('...ssets/index.js');
done();
});
});
}); });
}); });
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