Improvements to new entry dropdowns in Web IDE

Closes #44845
This commit is contained in:
Phil Hughes 2018-07-10 14:56:50 +01:00
parent e68a547bc7
commit be74e3936d
No known key found for this signature in database
GPG Key ID: 32245528C52E0F9F
22 changed files with 363 additions and 253 deletions

View File

@ -1,6 +1,7 @@
<script>
import Mousetrap from 'mousetrap';
import { mapActions, mapState, mapGetters } from 'vuex';
import NewModal from './new_dropdown/modal.vue';
import IdeSidebar from './ide_side_bar.vue';
import RepoTabs from './repo_tabs.vue';
import IdeStatusBar from './ide_status_bar.vue';
@ -13,6 +14,7 @@ const originalStopCallback = Mousetrap.stopCallback;
export default {
components: {
NewModal,
IdeSidebar,
RepoTabs,
IdeStatusBar,
@ -137,5 +139,6 @@ export default {
/>
</div>
<ide-status-bar :file="activeFile"/>
<new-modal />
</article>
</template>

View File

@ -1,12 +1,16 @@
<script>
import { mapState, mapGetters, mapActions } from 'vuex';
import NewDropdown from './new_dropdown/index.vue';
import Icon from '~/vue_shared/components/icon.vue';
import IdeTreeList from './ide_tree_list.vue';
import Upload from './new_dropdown/upload.vue';
import NewEntryButton from './new_dropdown/button.vue';
export default {
components: {
NewDropdown,
Icon,
Upload,
IdeTreeList,
NewEntryButton,
},
computed: {
...mapState(['currentBranchId']),
@ -20,23 +24,42 @@ export default {
}
},
methods: {
...mapActions(['updateViewer']),
...mapActions(['updateViewer', 'openNewEntryModal', 'createTempEntry']),
},
};
</script>
<template>
<ide-tree-list
header-class="d-flex w-100"
viewer-type="editor"
>
<template
slot="header"
>
{{ __('Edit') }}
<new-dropdown
:project-id="currentProject.name_with_namespace"
:branch="currentBranchId"
/>
<div class="ml-auto d-flex">
<new-entry-button
:label="__('New file')"
:show-label="false"
class="d-flex border-0 p-0 mr-3"
icon="doc-new"
@click="openNewEntryModal({ type: 'blob' })"
/>
<upload
:show-label="false"
class="d-flex mr-3"
button-css-classes="border-0 p-0"
@create="createTempEntry"
/>
<new-entry-button
:label="__('New directory')"
:show-label="false"
class="d-flex border-0 p-0"
icon="folder-new"
@click="openNewEntryModal({ type: 'tree' })"
/>
</div>
</template>
</ide-tree-list>
</template>

View File

@ -0,0 +1,51 @@
<script>
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
Icon,
},
props: {
label: {
type: String,
required: false,
default: null,
},
icon: {
type: String,
required: true,
},
iconClasses: {
type: String,
required: false,
default: null,
},
showLabel: {
type: Boolean,
required: false,
default: true,
},
},
methods: {
clicked() {
this.$emit('click');
},
},
};
</script>
<template>
<button
:aria-label="label"
type="button"
@click.stop.prevent="clicked"
>
<icon
:name="icon"
:css-classes="iconClasses"
/>
<template v-if="showLabel">
{{ label }}
</template>
</button>
</template>

View File

@ -3,12 +3,14 @@ import { mapActions } from 'vuex';
import icon from '~/vue_shared/components/icon.vue';
import newModal from './modal.vue';
import upload from './upload.vue';
import ItemButton from './button.vue';
export default {
components: {
icon,
newModal,
upload,
ItemButton,
},
props: {
branch: {
@ -20,11 +22,13 @@ export default {
required: false,
default: '',
},
mouseOver: {
type: Boolean,
required: true,
},
},
data() {
return {
openModal: false,
modalType: '',
dropdownOpen: false,
};
},
@ -34,17 +38,18 @@ export default {
this.$refs.dropdownMenu.scrollIntoView();
});
},
mouseOver() {
if (!this.mouseOver) {
this.dropdownOpen = false;
}
},
},
methods: {
...mapActions(['createTempEntry']),
...mapActions(['createTempEntry', 'openNewEntryModal']),
createNewItem(type) {
this.modalType = type;
this.openModal = true;
this.openNewEntryModal({ type, path: this.path });
this.dropdownOpen = false;
},
hideModal() {
this.openModal = false;
},
openDropdown() {
this.dropdownOpen = !this.dropdownOpen;
},
@ -58,23 +63,19 @@ export default {
:class="{
show: dropdownOpen,
}"
class="dropdown"
class="dropdown d-flex"
>
<button
:aria-label="__('Create new file or directory')"
type="button"
class="btn btn-sm btn-default dropdown-toggle add-to-tree"
aria-label="Create new file or directory"
class="rounded border-0 d-flex ide-entry-dropdown-toggle"
@click.stop="openDropdown()"
>
<icon
:size="12"
name="plus"
css-classes="float-left"
name="hamburger"
/>
<icon
:size="12"
name="arrow-down"
css-classes="float-left"
/>
</button>
<ul
@ -82,39 +83,30 @@ export default {
class="dropdown-menu dropdown-menu-right"
>
<li>
<a
href="#"
role="button"
@click.stop.prevent="createNewItem('blob')"
>
{{ __('New file') }}
</a>
<item-button
:label="__('New file')"
class="d-flex"
icon="doc-new"
icon-classes="mr-2"
@click="createNewItem('blob')"
/>
</li>
<li>
<upload
:branch-id="branch"
:path="path"
@create="createTempEntry"
/>
</li>
<li>
<a
href="#"
role="button"
@click.stop.prevent="createNewItem('tree')"
>
{{ __('New directory') }}
</a>
<item-button
:label="__('New directory')"
class="d-flex"
icon="folder-new"
icon-classes="mr-2"
@click="createNewItem('tree')"
/>
</li>
</ul>
</div>
<new-modal
v-if="openModal"
:type="modalType"
:branch-id="branch"
:path="path"
@hide="hideModal"
@create="createTempEntry"
/>
</div>
</template>

View File

@ -1,78 +1,70 @@
<script>
import { __ } from '~/locale';
import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import { mapActions, mapState } from 'vuex';
import GlModal from '~/vue_shared/components/gl_modal.vue';
export default {
components: {
DeprecatedModal,
},
props: {
branchId: {
type: String,
required: true,
},
type: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
GlModal,
},
data() {
return {
entryName: this.path !== '' ? `${this.path}/` : '',
name: '',
};
},
computed: {
...mapState(['newEntryModal']),
entryName: {
get() {
return this.name || (this.newEntryModal.path !== '' ? `${this.newEntryModal.path}/` : '');
},
set(val) {
this.name = val;
},
},
modalTitle() {
if (this.type === 'tree') {
if (this.newEntryModal.type === 'tree') {
return __('Create new directory');
}
return __('Create new file');
},
buttonLabel() {
if (this.type === 'tree') {
if (this.newEntryModal.type === 'tree') {
return __('Create directory');
}
return __('Create file');
},
},
mounted() {
this.$refs.fieldName.focus();
},
methods: {
...mapActions(['createTempEntry']),
createEntryInStore() {
this.$emit('create', {
branchId: this.branchId,
name: this.entryName,
type: this.type,
this.createTempEntry({
name: this.name,
type: this.newEntryModal.type,
});
this.hideModal();
},
hideModal() {
this.$emit('hide');
focusInput() {
setTimeout(() => {
this.$refs.fieldName.focus();
});
},
},
};
</script>
<template>
<deprecated-modal
:title="modalTitle"
:primary-button-label="buttonLabel"
kind="success"
@cancel="hideModal"
<gl-modal
id="ide-new-entry"
:header-title-text="modalTitle"
:footer-primary-button-text="buttonLabel"
footer-primary-button-variant="success"
@submit="createEntryInStore"
@open="focusInput"
>
<form
slot="body"
<div
class="form-group row"
@submit.prevent="createEntryInStore"
>
<label class="label-light col-form-label col-sm-3">
{{ __('Name') }}
@ -85,6 +77,6 @@ export default {
class="form-control"
/>
</div>
</form>
</deprecated-modal>
</div>
</gl-modal>
</template>

View File

@ -1,71 +1,85 @@
<script>
export default {
props: {
branchId: {
type: String,
required: true,
},
path: {
type: String,
required: false,
default: '',
},
},
mounted() {
this.$refs.fileUpload.addEventListener('change', this.openFile);
},
beforeDestroy() {
this.$refs.fileUpload.removeEventListener('change', this.openFile);
},
methods: {
createFile(target, file, isText) {
const { name } = file;
let { result } = target;
import Icon from '~/vue_shared/components/icon.vue';
import ItemButton from './button.vue';
if (!isText) {
// eslint-disable-next-line prefer-destructuring
result = result.split('base64,')[1];
}
this.$emit('create', {
name: `${(this.path ? `${this.path}/` : '')}${name}`,
branchId: this.branchId,
type: 'blob',
content: result,
base64: !isText,
});
},
readFile(file) {
const reader = new FileReader();
const isText = file.type.match(/text.*/) !== null;
reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true });
if (isText) {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
},
openFile() {
Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file));
},
startFileUpload() {
this.$refs.fileUpload.click();
},
export default {
components: {
Icon,
ItemButton,
},
props: {
path: {
type: String,
required: false,
default: '',
},
};
showLabel: {
type: Boolean,
required: false,
default: true,
},
buttonCssClasses: {
type: String,
required: false,
default: null,
},
},
mounted() {
this.$refs.fileUpload.addEventListener('change', this.openFile);
},
beforeDestroy() {
this.$refs.fileUpload.removeEventListener('change', this.openFile);
},
methods: {
createFile(target, file, isText) {
const { name } = file;
let { result } = target;
if (!isText) {
// eslint-disable-next-line prefer-destructuring
result = result.split('base64,')[1];
}
this.$emit('create', {
name: `${this.path ? `${this.path}/` : ''}${name}`,
type: 'blob',
content: result,
base64: !isText,
});
},
readFile(file) {
const reader = new FileReader();
const isText = file.type.match(/text.*/) !== null;
reader.addEventListener('load', e => this.createFile(e.target, file, isText), { once: true });
if (isText) {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
},
openFile() {
Array.from(this.$refs.fileUpload.files).forEach(file => this.readFile(file));
},
startFileUpload() {
this.$refs.fileUpload.click();
},
},
};
</script>
<template>
<div>
<a
href="#"
role="button"
@click.stop.prevent="startFileUpload"
>
{{ __('Upload file') }}
</a>
<item-button
:class="buttonCssClasses"
:show-label="showLabel"
:icon-classes="showLabel ? 'mr-2' : ''"
:label="__('Upload file')"
class="d-flex"
icon="upload"
@click="startFileUpload"
/>
<input
id="file-upload"
ref="fileUpload"

View File

@ -40,6 +40,11 @@ export default {
default: false,
},
},
data() {
return {
mouseOver: false,
};
},
computed: {
...mapGetters([
'getChangesInFolder',
@ -142,6 +147,9 @@ export default {
hasUrlAtCurrentRoute() {
return this.$router.currentRoute.path === `/project${this.file.url}`;
},
toggleHover(over) {
this.mouseOver = over;
},
},
};
</script>
@ -153,6 +161,8 @@ export default {
class="file"
role="button"
@click="clickFile"
@mouseover="toggleHover(true)"
@mouseout="toggleHover(false)"
>
<div
class="file-name"
@ -206,6 +216,7 @@ export default {
:project-id="file.projectId"
:branch="file.branchId"
:path="file.path"
:mouse-over="mouseOver"
class="float-right prepend-left-8"
/>
</div>

View File

@ -52,7 +52,7 @@ export const setResizingStatus = ({ commit }, resizing) => {
export const createTempEntry = (
{ state, commit, dispatch },
{ branchId, name, type, content = '', base64 = false },
{ name, type, content = '', base64 = false },
) =>
new Promise(resolve => {
const worker = new FilesDecoratorWorker();
@ -81,7 +81,7 @@ export const createTempEntry = (
commit(types.CREATE_TMP_ENTRY, {
data,
projectId: state.currentProjectId,
branchId,
branchId: state.currentBranchId,
});
if (type === 'blob') {
@ -100,7 +100,7 @@ export const createTempEntry = (
worker.postMessage({
data: [fullName],
projectId: state.currentProjectId,
branchId,
branchId: state.currentBranchId,
type,
tempFile: true,
base64,
@ -178,6 +178,13 @@ export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links);
export const setErrorMessage = ({ commit }, errorMessage) =>
commit(types.SET_ERROR_MESSAGE, errorMessage);
export const openNewEntryModal = ({ commit }, { type, path = '' }) => {
commit(types.OPEN_NEW_ENTRY_MODAL, { type, path });
// open the modal manually so we don't mess around with dropdown/rows
$('#ide-new-entry').modal('show');
};
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';

View File

@ -74,3 +74,5 @@ export const CLEAR_PROJECTS = 'CLEAR_PROJECTS';
export const RESET_OPEN_FILES = 'RESET_OPEN_FILES';
export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE';
export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL';

View File

@ -166,6 +166,11 @@ export default {
[types.SET_ERROR_MESSAGE](state, errorMessage) {
Object.assign(state, { errorMessage });
},
[types.OPEN_NEW_ENTRY_MODAL](state, { type, path }) {
Object.assign(state, {
newEntryModal: { type, path },
});
},
...projectMutations,
...mergeRequestMutation,
...fileMutations,

View File

@ -26,4 +26,8 @@ export default () => ({
rightPane: null,
links: {},
errorMessage: null,
newEntryModal: {
type: '',
path: '',
},
});

View File

@ -45,6 +45,11 @@ export default {
emitSubmit(event) {
this.$emit('submit', event);
},
opened({ propertyName }) {
if (propertyName === 'opacity') {
this.$emit('open');
}
},
},
};
</script>
@ -55,6 +60,7 @@ export default {
class="modal fade"
tabindex="-1"
role="dialog"
@transitionend="opened"
>
<div
:class="modalSizeClass"

View File

@ -44,6 +44,7 @@
padding-bottom: $grid-size;
.file {
height: 32px;
cursor: pointer;
&.file-active {
@ -716,32 +717,6 @@
justify-content: center;
}
.ide-new-btn {
.btn {
padding-top: 3px;
padding-bottom: 3px;
}
.dropdown {
display: flex;
}
.dropdown-toggle svg {
top: 0;
}
.dropdown-menu {
left: auto;
right: 0;
label {
font-weight: $gl-font-weight-normal;
padding: 5px 8px;
margin-bottom: 0;
}
}
}
.ide {
overflow: hidden;
@ -1340,3 +1315,24 @@
overflow: auto;
}
}
.ide-entry-dropdown-toggle {
padding: $gl-padding-4;
background-color: $theme-gray-100;
&:hover {
background-color: $theme-gray-200;
}
&:active,
&:focus {
color: $white-normal;
background-color: $blue-500;
outline: 0;
}
}
.ide-new-btn .dropdown.show .ide-entry-dropdown-toggle {
color: $white-normal;
background-color: $blue-500;
}

View File

@ -0,0 +1,5 @@
---
title: Updated design of new entry dropdown in Web IDE
merge_request: 20526
author:
type: changed

View File

@ -1720,6 +1720,9 @@ msgstr ""
msgid "Create new file"
msgstr ""
msgid "Create new file or directory"
msgstr ""
msgid "Create new label"
msgstr ""

View File

@ -22,9 +22,7 @@ describe 'Multi-file editor new directory', :js do
end
it 'creates directory in current directory' do
find('.add-to-tree').click
click_link('New directory')
all('.ide-tree-header button').last.click
page.within('.modal') do
find('.form-control').set('folder name')
@ -32,9 +30,7 @@ describe 'Multi-file editor new directory', :js do
click_button('Create directory')
end
find('.add-to-tree').click
click_link('New file')
first('.ide-tree-header button').click
page.within('.modal-dialog') do
find('.form-control').set('file name')

View File

@ -22,9 +22,7 @@ describe 'Multi-file editor new file', :js do
end
it 'creates file in current directory' do
find('.add-to-tree').click
click_link('New file')
first('.ide-tree-header button').click
page.within('.modal') do
find('.form-control').set('file name')

View File

@ -24,14 +24,10 @@ describe 'Multi-file editor upload file', :js do
end
it 'uploads text file' do
find('.add-to-tree').click
# make the field visible so capybara can use it
execute_script('document.querySelector("#file-upload").classList.remove("hidden")')
attach_file('file-upload', txt_file)
find('.add-to-tree').click
expect(page).to have_selector('.multi-file-tab', text: 'doc_sample.txt')
expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline))
end

View File

@ -0,0 +1,49 @@
import Vue from 'vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import Button from '~/ide/components/new_dropdown/button.vue';
describe('IDE new entry dropdown button component', () => {
let Component;
let vm;
beforeAll(() => {
Component = Vue.extend(Button);
});
beforeEach(() => {
vm = mountComponent(Component, {
label: 'Testing',
icon: 'doc-new',
});
spyOn(vm, '$emit');
});
afterEach(() => {
vm.$destroy();
});
it('renders button with label', () => {
expect(vm.$el.textContent).toContain('Testing');
});
it('renders icon', () => {
expect(vm.$el.querySelector('.ic-doc-new')).not.toBe(null);
});
it('emits click event', () => {
vm.$el.click();
expect(vm.$emit).toHaveBeenCalledWith('click');
});
it('hides label if showLabel is false', done => {
vm.showLabel = false;
vm.$nextTick(() => {
expect(vm.$el.textContent).not.toContain('Testing');
done();
});
});
});

View File

@ -13,6 +13,7 @@ describe('new dropdown component', () => {
vm = createComponentWithStore(component, store, {
branch: 'master',
path: '',
mouseOver: false,
});
vm.$store.state.currentProjectId = 'abcproject';
@ -21,6 +22,8 @@ describe('new dropdown component', () => {
tree: [],
};
spyOn(vm, 'openNewEntryModal');
vm.$mount();
});
@ -31,50 +34,23 @@ describe('new dropdown component', () => {
});
it('renders new file, upload and new directory links', () => {
expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file');
expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe('Upload file');
expect(vm.$el.querySelectorAll('a')[2].textContent.trim()).toBe('New directory');
const buttons = vm.$el.querySelectorAll('.dropdown-menu button');
expect(buttons[0].textContent.trim()).toBe('New file');
expect(buttons[1].textContent.trim()).toBe('Upload file');
expect(buttons[2].textContent.trim()).toBe('New directory');
});
describe('createNewItem', () => {
it('sets modalType to blob when new file is clicked', () => {
vm.$el.querySelectorAll('a')[0].click();
vm.$el.querySelectorAll('.dropdown-menu button')[0].click();
expect(vm.modalType).toBe('blob');
expect(vm.openNewEntryModal).toHaveBeenCalledWith({ type: 'blob', path: '' });
});
it('sets modalType to tree when new directory is clicked', () => {
vm.$el.querySelectorAll('a')[2].click();
vm.$el.querySelectorAll('.dropdown-menu button')[2].click();
expect(vm.modalType).toBe('tree');
});
it('opens modal when link is clicked', done => {
vm.$el.querySelectorAll('a')[0].click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.modal')).not.toBeNull();
done();
});
});
});
describe('hideModal', () => {
beforeAll(done => {
vm.openModal = true;
Vue.nextTick(done);
});
it('closes modal after toggling', done => {
vm.hideModal();
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.modal')).toBeNull();
})
.then(done)
.catch(done.fail);
expect(vm.openNewEntryModal).toHaveBeenCalledWith({ type: 'tree', path: '' });
});
});

View File

@ -1,6 +1,7 @@
import Vue from 'vue';
import { createStore } from '~/ide/stores';
import modal from '~/ide/components/new_dropdown/modal.vue';
import createComponent from 'spec/helpers/vue_mount_component_helper';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
describe('new file modal component', () => {
const Component = Vue.extend(modal);
@ -13,13 +14,15 @@ describe('new file modal component', () => {
['tree', 'blob'].forEach(type => {
describe(type, () => {
beforeEach(() => {
vm = createComponent(Component, {
const store = createStore();
store.state.newEntryModal = {
type,
branchId: 'master',
path: '',
});
};
vm.entryName = 'testing';
vm = createComponentWithStore(Component, store).$mount();
vm.name = 'testing';
});
it(`sets modal title as ${type}`, () => {
@ -40,12 +43,11 @@ describe('new file modal component', () => {
describe('createEntryInStore', () => {
it('$emits create', () => {
spyOn(vm, '$emit');
spyOn(vm, 'createTempEntry');
vm.createEntryInStore();
expect(vm.$emit).toHaveBeenCalledWith('create', {
branchId: 'master',
expect(vm.createTempEntry).toHaveBeenCalledWith({
name: 'testing',
type,
});
@ -53,22 +55,4 @@ describe('new file modal component', () => {
});
});
});
it('focuses field on mount', () => {
document.body.innerHTML += '<div class="js-test"></div>';
vm = createComponent(
Component,
{
type: 'tree',
branchId: 'master',
path: '',
},
'.js-test',
);
expect(document.activeElement).toBe(vm.$refs.fieldName);
vm.$el.remove();
});
});

View File

@ -9,7 +9,6 @@ describe('new dropdown upload', () => {
const Component = Vue.extend(upload);
vm = createComponent(Component, {
branchId: 'master',
path: '',
});
@ -65,7 +64,6 @@ describe('new dropdown upload', () => {
expect(vm.$emit).toHaveBeenCalledWith('create', {
name: file.name,
branchId: 'master',
type: 'blob',
content: target.result,
base64: false,
@ -77,7 +75,6 @@ describe('new dropdown upload', () => {
expect(vm.$emit).toHaveBeenCalledWith('create', {
name: file.name,
branchId: 'master',
type: 'blob',
content: binaryTarget.result.split('base64,')[1],
base64: true,