Merge branch 'decouple-file-row-from-ide' into 'master'

Decouple file row from IDE

See merge request gitlab-org/gitlab-ce!21742
This commit is contained in:
Filipa Lacerda 2018-09-17 16:19:27 +00:00
commit 07c6c9db57
8 changed files with 602 additions and 471 deletions

View file

@ -0,0 +1,104 @@
<script>
import { mapGetters } from 'vuex';
import { n__, __, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import Icon from '~/vue_shared/components/icon.vue';
import NewDropdown from './new_dropdown/index.vue';
import ChangedFileIcon from './changed_file_icon.vue';
import MrFileIcon from './mr_file_icon.vue';
export default {
name: 'FileRowExtra',
directives: {
tooltip,
},
components: {
Icon,
NewDropdown,
ChangedFileIcon,
MrFileIcon,
},
props: {
file: {
type: Object,
required: true,
},
mouseOver: {
type: Boolean,
required: true,
},
},
computed: {
...mapGetters([
'getChangesInFolder',
'getUnstagedFilesCountForPath',
'getStagedFilesCountForPath',
]),
folderUnstagedCount() {
return this.getUnstagedFilesCountForPath(this.file.path);
},
folderStagedCount() {
return this.getStagedFilesCountForPath(this.file.path);
},
changesCount() {
return this.getChangesInFolder(this.file.path);
},
folderChangesTooltip() {
if (this.changesCount === 0) return undefined;
if (this.folderUnstagedCount > 0 && this.folderStagedCount === 0) {
return n__('%d unstaged change', '%d unstaged changes', this.folderUnstagedCount);
} else if (this.folderUnstagedCount === 0 && this.folderStagedCount > 0) {
return n__('%d staged change', '%d staged changes', this.folderStagedCount);
}
return sprintf(__('%{unstaged} unstaged and %{staged} staged changes'), {
unstaged: this.folderUnstagedCount,
staged: this.folderStagedCount,
});
},
showTreeChangesCount() {
return this.file.type === 'tree' && this.changesCount > 0 && !this.file.opened;
},
showChangedFileIcon() {
return this.file.changed || this.file.tempFile || this.file.staged;
},
},
};
</script>
<template>
<div class="float-right ide-file-icon-holder">
<mr-file-icon
v-if="file.mrChange"
/>
<span
v-if="showTreeChangesCount"
class="ide-tree-changes"
>
{{ changesCount }}
<icon
v-tooltip
:title="folderChangesTooltip"
:size="12"
data-container="body"
data-placement="right"
name="file-modified"
css-classes="prepend-left-5 ide-file-modified"
/>
</span>
<changed-file-icon
v-else-if="showChangedFileIcon"
:file="file"
:show-tooltip="true"
:show-staged-icon="true"
:force-modified-icon="true"
/>
<new-dropdown
:type="file.type"
:path="file.path"
:mouse-over="mouseOver"
class="prepend-left-8"
/>
</div>
</template>

View file

@ -2,15 +2,16 @@
import { mapActions, mapGetters, mapState } from 'vuex';
import Icon from '~/vue_shared/components/icon.vue';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import RepoFile from './repo_file.vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import NavDropdown from './nav_dropdown.vue';
import FileRowExtra from './file_row_extra.vue';
export default {
components: {
Icon,
RepoFile,
SkeletonLoadingContainer,
NavDropdown,
FileRow,
},
props: {
viewerType: {
@ -34,8 +35,9 @@ export default {
this.updateViewer(this.viewerType);
},
methods: {
...mapActions(['updateViewer']),
...mapActions(['updateViewer', 'toggleTreeOpen']),
},
FileRowExtra,
};
</script>
@ -63,11 +65,13 @@ export default {
<div
class="ide-tree-body h-100"
>
<repo-file
<file-row
v-for="file in currentTree.tree"
:key="file.key"
:file="file"
:level="0"
:extra-component="$options.FileRowExtra"
@toggleTreeOpen="toggleTreeOpen"
/>
</div>
</template>

View file

@ -1,227 +0,0 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import { n__, __, sprintf } from '~/locale';
import tooltip from '~/vue_shared/directives/tooltip';
import SkeletonLoadingContainer from '~/vue_shared/components/skeleton_loading_container.vue';
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import router from '../ide_router';
import NewDropdown from './new_dropdown/index.vue';
import FileStatusIcon from './repo_file_status_icon.vue';
import ChangedFileIcon from './changed_file_icon.vue';
import MrFileIcon from './mr_file_icon.vue';
export default {
name: 'RepoFile',
directives: {
tooltip,
},
components: {
SkeletonLoadingContainer,
NewDropdown,
FileStatusIcon,
FileIcon,
ChangedFileIcon,
MrFileIcon,
Icon,
},
props: {
file: {
type: Object,
required: true,
},
level: {
type: Number,
required: true,
},
},
data() {
return {
mouseOver: false,
};
},
computed: {
...mapGetters([
'getChangesInFolder',
'getUnstagedFilesCountForPath',
'getStagedFilesCountForPath',
]),
folderUnstagedCount() {
return this.getUnstagedFilesCountForPath(this.file.path);
},
folderStagedCount() {
return this.getStagedFilesCountForPath(this.file.path);
},
changesCount() {
return this.getChangesInFolder(this.file.path);
},
folderChangesTooltip() {
if (this.changesCount === 0) return undefined;
if (this.folderUnstagedCount > 0 && this.folderStagedCount === 0) {
return n__('%d unstaged change', '%d unstaged changes', this.folderUnstagedCount);
} else if (this.folderUnstagedCount === 0 && this.folderStagedCount > 0) {
return n__('%d staged change', '%d staged changes', this.folderStagedCount);
}
return sprintf(__('%{unstaged} unstaged and %{staged} staged changes'), {
unstaged: this.folderUnstagedCount,
staged: this.folderStagedCount,
});
},
isTree() {
return this.file.type === 'tree';
},
isBlob() {
return this.file.type === 'blob';
},
levelIndentation() {
return {
marginLeft: `${this.level * 16}px`,
};
},
fileClass() {
return {
'file-open': this.isBlob && this.file.opened,
'file-active': this.isBlob && this.file.active,
folder: this.isTree,
'is-open': this.file.opened,
};
},
showTreeChangesCount() {
return this.isTree && this.changesCount > 0 && !this.file.opened;
},
showChangedFileIcon() {
return this.file.changed || this.file.tempFile || this.file.staged;
},
},
watch: {
'file.active': function fileActiveWatch(active) {
if (this.file.type === 'blob' && active) {
this.scrollIntoView();
}
},
},
mounted() {
if (this.hasPathAtCurrentRoute()) {
this.scrollIntoView(true);
}
},
methods: {
...mapActions(['toggleTreeOpen']),
clickFile() {
// Manual Action if a tree is selected/opened
if (this.isTree && this.hasUrlAtCurrentRoute()) {
this.toggleTreeOpen(this.file.path);
}
router.push(`/project${this.file.url}`);
},
scrollIntoView(isInit = false) {
const block = isInit && this.isTree ? 'center' : 'nearest';
this.$el.scrollIntoView({
behavior: 'smooth',
block,
});
},
hasPathAtCurrentRoute() {
if (!this.$router || !this.$router.currentRoute) {
return false;
}
// - strip route up to "/-/" and ending "/"
const routePath = this.$router.currentRoute.path
.replace(/^.*?[/]-[/]/g, '')
.replace(/[/]$/g, '');
// - strip ending "/"
const filePath = this.file.path.replace(/[/]$/g, '');
return filePath === routePath;
},
hasUrlAtCurrentRoute() {
return this.$router.currentRoute.path === `/project${this.file.url}`;
},
toggleHover(over) {
this.mouseOver = over;
},
},
};
</script>
<template>
<div>
<div
:class="fileClass"
class="file"
role="button"
@click="clickFile"
@mouseover="toggleHover(true)"
@mouseout="toggleHover(false)"
>
<div
class="file-name"
>
<span
:style="levelIndentation"
class="ide-file-name str-truncated"
>
<file-icon
:file-name="file.name"
:loading="file.loading"
:folder="isTree"
:opened="file.opened"
:size="16"
/>
{{ file.name }}
<file-status-icon
:file="file"
/>
</span>
<span class="float-right ide-file-icon-holder">
<mr-file-icon
v-if="file.mrChange"
/>
<span
v-if="showTreeChangesCount"
class="ide-tree-changes"
>
{{ changesCount }}
<icon
v-tooltip
:title="folderChangesTooltip"
:size="12"
data-container="body"
data-placement="right"
name="file-modified"
css-classes="prepend-left-5 ide-file-modified"
/>
</span>
<changed-file-icon
v-else-if="showChangedFileIcon"
:file="file"
:show-tooltip="true"
:show-staged-icon="true"
:force-modified-icon="true"
class="float-right"
/>
</span>
<new-dropdown
:type="file.type"
:path="file.path"
:mouse-over="mouseOver"
class="float-right prepend-left-8"
/>
</div>
</div>
<template v-if="file.opened">
<repo-file
v-for="childFile in file.tree"
:key="childFile.key"
:file="childFile"
:level="level + 1"
/>
</template>
</div>
</template>

View file

@ -0,0 +1,210 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
export default {
name: 'FileRow',
components: {
FileIcon,
Icon,
},
props: {
file: {
type: Object,
required: true,
},
level: {
type: Number,
required: true,
},
extraComponent: {
type: Object,
required: false,
default: null,
},
},
data() {
return {
mouseOver: false,
};
},
computed: {
isTree() {
return this.file.type === 'tree';
},
isBlob() {
return this.file.type === 'blob';
},
levelIndentation() {
return {
marginLeft: `${this.level * 16}px`,
};
},
fileClass() {
return {
'file-open': this.isBlob && this.file.opened,
'is-active': this.isBlob && this.file.active,
folder: this.isTree,
'is-open': this.file.opened,
};
},
},
watch: {
'file.active': function fileActiveWatch(active) {
if (this.file.type === 'blob' && active) {
this.scrollIntoView();
}
},
},
mounted() {
if (this.hasPathAtCurrentRoute()) {
this.scrollIntoView(true);
}
},
methods: {
toggleTreeOpen(path) {
this.$emit('toggleTreeOpen', path);
},
clickFile() {
// Manual Action if a tree is selected/opened
if (this.isTree && this.hasUrlAtCurrentRoute()) {
this.toggleTreeOpen(this.file.path);
}
if (this.$router) this.$router.push(`/project${this.file.url}`);
},
scrollIntoView(isInit = false) {
const block = isInit && this.isTree ? 'center' : 'nearest';
this.$el.scrollIntoView({
behavior: 'smooth',
block,
});
},
hasPathAtCurrentRoute() {
if (!this.$router || !this.$router.currentRoute) {
return false;
}
// - strip route up to "/-/" and ending "/"
const routePath = this.$router.currentRoute.path
.replace(/^.*?[/]-[/]/g, '')
.replace(/[/]$/g, '');
// - strip ending "/"
const filePath = this.file.path.replace(/[/]$/g, '');
return filePath === routePath;
},
hasUrlAtCurrentRoute() {
if (!this.$router || !this.$router.currentRoute) return true;
return this.$router.currentRoute.path === `/project${this.file.url}`;
},
toggleHover(over) {
this.mouseOver = over;
},
},
};
</script>
<template>
<div>
<div
:class="fileClass"
class="file-row"
role="button"
@click="clickFile"
@mouseover="toggleHover(true)"
@mouseout="toggleHover(false)"
>
<div
class="file-row-name-container"
>
<span
:style="levelIndentation"
class="file-row-name str-truncated"
>
<file-icon
:file-name="file.name"
:loading="file.loading"
:folder="isTree"
:opened="file.opened"
:size="16"
/>
{{ file.name }}
</span>
<component
v-if="extraComponent"
:is="extraComponent"
:file="file"
:mouse-over="mouseOver"
/>
</div>
</div>
<template v-if="file.opened">
<file-row
v-for="childFile in file.tree"
:key="childFile.key"
:file="childFile"
:level="level + 1"
:extra-component="extraComponent"
@toggleTreeOpen="toggleTreeOpen"
/>
</template>
</div>
</template>
<style>
.file-row {
display: flex;
align-items: center;
height: 32px;
padding: 4px 8px;
margin-left: -8px;
margin-right: -8px;
border-radius: 3px;
text-align: left;
cursor: pointer;
}
.file-row:hover,
.file-row:focus {
background: #f2f2f2;
}
.file-row:active {
background: #dfdfdf;
}
.file-row.is-active {
background: #f2f2f2;
}
.file-row-name-container {
display: flex;
width: 100%;
align-items: center;
overflow: visible;
}
.file-row-name {
display: inline-block;
flex: 1;
max-width: inherit;
height: 18px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-row-name svg {
margin-right: 2px;
vertical-align: middle;
}
.file-row-name .loading-container {
display: inline-block;
margin-right: 4px;
}
</style>

View file

@ -53,83 +53,9 @@ $ide-commit-header-height: 48px;
flex: 1;
min-height: 0; // firefox fix
.file {
height: 32px;
cursor: pointer;
&.file-active {
background: $theme-gray-100;
}
.ide-file-name {
flex: 1;
white-space: nowrap;
text-overflow: ellipsis;
max-width: inherit;
line-height: 16px;
display: inline-block;
height: 18px;
svg {
vertical-align: middle;
margin-right: 2px;
}
.loading-container {
margin-right: 4px;
display: inline-block;
}
}
.ide-file-icon-holder {
display: flex;
align-items: center;
color: $theme-gray-700;
}
.ide-file-changed-icon {
margin-left: auto;
> svg {
display: block;
}
}
.ide-new-btn {
display: none;
.btn {
padding: 2px 5px;
}
}
&:hover,
&:focus {
.ide-new-btn {
display: block;
}
}
.folder-icon {
fill: $gl-text-color-secondary;
}
}
a {
color: $gl-text-color;
}
th {
position: sticky;
top: 0;
}
}
.file-name {
display: flex;
overflow: visible;
align-items: center;
width: 100%;
}
.multi-file-loading-container {
@ -625,8 +551,7 @@ $ide-commit-header-height: 48px;
}
}
.multi-file-commit-list-path,
.ide-file-list .file {
.multi-file-commit-list-path {
display: flex;
align-items: center;
margin-left: -$grid-size;
@ -634,28 +559,14 @@ $ide-commit-header-height: 48px;
padding: $grid-size / 2 $grid-size;
border-radius: $border-radius-default;
text-align: left;
&:hover,
&:focus {
background: $theme-gray-100;
}
&:active {
background: $theme-gray-200;
}
}
.multi-file-commit-list-path {
cursor: pointer;
height: $ide-commit-row-height;
padding-right: 0;
&.is-active {
background-color: $white-normal;
}
&:hover,
&:focus {
background: $theme-gray-100;
outline: 0;
.multi-file-discard-btn {
@ -665,6 +576,14 @@ $ide-commit-header-height: 48px;
}
}
&:active {
background: $theme-gray-200;
}
&.is-active {
background-color: $white-normal;
}
svg {
min-width: 16px;
vertical-align: middle;
@ -1398,9 +1317,17 @@ $ide-commit-header-height: 48px;
}
}
.ide-new-btn .dropdown.show .ide-entry-dropdown-toggle {
color: $white-normal;
background-color: $blue-500;
.ide-new-btn {
display: none;
.btn {
padding: 2px 5px;
}
.dropdown.show .ide-entry-dropdown-toggle {
color: $white-normal;
background-color: $blue-500;
}
}
.ide-preview-header {
@ -1465,3 +1392,28 @@ $ide-commit-header-height: 48px;
width: $ide-commit-row-height;
height: $ide-commit-row-height;
}
.ide-file-icon-holder {
display: flex;
align-items: center;
color: $theme-gray-700;
}
.ide-file-changed-icon {
margin-left: auto;
> svg {
display: block;
}
}
.file-row:hover,
.file-row:focus {
.ide-new-btn {
display: block;
}
.folder-icon {
fill: $gl-text-color-secondary;
}
}

View file

@ -0,0 +1,159 @@
import Vue from 'vue';
import { createStore } from '~/ide/stores';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import FileRowExtra from '~/ide/components/file_row_extra.vue';
import { file, resetStore } from '../helpers';
describe('IDE extra file row component', () => {
let Component;
let vm;
let unstagedFilesCount = 0;
let stagedFilesCount = 0;
let changesCount = 0;
beforeAll(() => {
Component = Vue.extend(FileRowExtra);
});
beforeEach(() => {
vm = createComponentWithStore(Component, createStore(), {
file: {
...file('test'),
},
mouseOver: false,
});
spyOnProperty(vm, 'getUnstagedFilesCountForPath').and.returnValue(() => unstagedFilesCount);
spyOnProperty(vm, 'getStagedFilesCountForPath').and.returnValue(() => stagedFilesCount);
spyOnProperty(vm, 'getChangesInFolder').and.returnValue(() => changesCount);
vm.$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
stagedFilesCount = 0;
unstagedFilesCount = 0;
changesCount = 0;
});
describe('folderChangesTooltip', () => {
it('returns undefined when changes count is 0', () => {
expect(vm.folderChangesTooltip).toBe(undefined);
});
it('returns unstaged changes text', () => {
changesCount = 1;
unstagedFilesCount = 1;
expect(vm.folderChangesTooltip).toBe('1 unstaged change');
});
it('returns staged changes text', () => {
changesCount = 1;
stagedFilesCount = 1;
expect(vm.folderChangesTooltip).toBe('1 staged change');
});
it('returns staged and unstaged changes text', () => {
changesCount = 1;
stagedFilesCount = 1;
unstagedFilesCount = 1;
expect(vm.folderChangesTooltip).toBe('1 unstaged and 1 staged changes');
});
});
describe('show tree changes count', () => {
it('does not show for blobs', () => {
vm.file.type = 'blob';
expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
});
it('does not show when changes count is 0', () => {
vm.file.type = 'tree';
expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
});
it('does not show when tree is open', done => {
vm.file.type = 'tree';
vm.file.opened = true;
changesCount = 1;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.ide-tree-changes')).toBe(null);
done();
});
});
it('shows for trees with changes', done => {
vm.file.type = 'tree';
vm.file.opened = false;
changesCount = 1;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.ide-tree-changes')).not.toBe(null);
done();
});
});
});
describe('changes file icon', () => {
it('hides when file is not changed', () => {
expect(vm.$el.querySelector('.ide-file-changed-icon')).toBe(null);
});
it('shows when file is changed', done => {
vm.file.changed = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.ide-file-changed-icon')).not.toBe(null);
done();
});
});
it('shows when file is staged', done => {
vm.file.staged = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.ide-file-changed-icon')).not.toBe(null);
done();
});
});
it('shows when file is a tempFile', done => {
vm.file.tempFile = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.ide-file-changed-icon')).not.toBe(null);
done();
});
});
});
describe('merge request icon', () => {
it('hides when not a merge request change', () => {
expect(vm.$el.querySelector('.ic-git-merge')).toBe(null);
});
it('shows when a merge request change', done => {
vm.file.mrChange = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.ic-git-merge')).not.toBe(null);
done();
});
});
});
});

View file

@ -1,145 +0,0 @@
import Vue from 'vue';
import store from '~/ide/stores';
import repoFile from '~/ide/components/repo_file.vue';
import router from '~/ide/ide_router';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { file } from '../helpers';
describe('RepoFile', () => {
let vm;
function createComponent(propsData) {
const RepoFile = Vue.extend(repoFile);
vm = createComponentWithStore(RepoFile, store, propsData);
vm.$mount();
}
afterEach(() => {
vm.$destroy();
});
it('renders link, icon and name', () => {
createComponent({
file: file('t4'),
level: 0,
});
const name = vm.$el.querySelector('.ide-file-name');
expect(name.href).toMatch('');
expect(name.textContent.trim()).toEqual(vm.file.name);
});
it('fires clickFile when the link is clicked', done => {
spyOn(router, 'push');
createComponent({
file: file('t3'),
level: 0,
});
vm.$el.querySelector('.file-name').click();
setTimeout(() => {
expect(router.push).toHaveBeenCalledWith(`/project${vm.file.url}`);
done();
});
});
describe('folder', () => {
it('renders changes count inside folder', () => {
const f = {
...file('folder'),
path: 'testing',
type: 'tree',
branchId: 'master',
projectId: 'project',
};
store.state.changedFiles.push({
...file('fileName'),
path: 'testing/fileName',
});
createComponent({
file: f,
level: 0,
});
const treeChangesEl = vm.$el.querySelector('.ide-tree-changes');
expect(treeChangesEl).not.toBeNull();
expect(treeChangesEl.textContent).toContain('1');
});
it('renders action dropdown', done => {
createComponent({
file: {
...file('t4'),
type: 'tree',
branchId: 'master',
projectId: 'project',
},
level: 0,
});
setTimeout(() => {
expect(vm.$el.querySelector('.ide-new-btn')).not.toBeNull();
done();
});
});
});
describe('locked file', () => {
let f;
beforeEach(() => {
f = file('locked file');
f.file_lock = {
user: {
name: 'testuser',
updated_at: new Date(),
},
};
createComponent({
file: f,
level: 0,
});
});
it('renders lock icon', () => {
expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull();
});
it('renders a tooltip', () => {
expect(
vm.$el.querySelector('.ide-file-name span:nth-child(2)').dataset.originalTitle,
).toContain('Locked by testuser');
});
});
it('calls scrollIntoView if made active', done => {
createComponent({
file: {
...file(),
type: 'blob',
active: false,
},
level: 0,
});
spyOn(vm, 'scrollIntoView');
vm.file.active = true;
vm.$nextTick(() => {
expect(vm.scrollIntoView).toHaveBeenCalled();
done();
});
});
});

View file

@ -0,0 +1,74 @@
import Vue from 'vue';
import FileRow from '~/vue_shared/components/file_row.vue';
import { file } from 'spec/ide/helpers';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('RepoFile', () => {
let vm;
function createComponent(propsData) {
const FileRowComponent = Vue.extend(FileRow);
vm = mountComponent(FileRowComponent, propsData);
}
afterEach(() => {
vm.$destroy();
});
it('renders name', () => {
createComponent({
file: file('t4'),
level: 0,
});
const name = vm.$el.querySelector('.file-row-name');
expect(name.textContent.trim()).toEqual(vm.file.name);
});
it('emits toggleTreeOpen on click', () => {
createComponent({
file: {
...file('t3'),
type: 'tree',
},
level: 0,
});
spyOn(vm, '$emit').and.stub();
vm.$el.querySelector('.file-row').click();
expect(vm.$emit).toHaveBeenCalledWith('toggleTreeOpen', vm.file.path);
});
it('calls scrollIntoView if made active', done => {
createComponent({
file: {
...file(),
type: 'blob',
active: false,
},
level: 0,
});
spyOn(vm, 'scrollIntoView').and.stub();
vm.file.active = true;
vm.$nextTick(() => {
expect(vm.scrollIntoView).toHaveBeenCalled();
done();
});
});
it('indents row based on level', () => {
createComponent({
file: file('t4'),
level: 2,
});
expect(vm.$el.querySelector('.file-row-name').style.marginLeft).toBe('32px');
});
});