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

Improvements to new entry dropdowns in Web IDE

Closes #44845
parent e68a547b
No related branches found
No related tags found
No related merge requests found
Showing
with 347 additions and 218 deletions
<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';
Loading
Loading
@@ -13,6 +14,7 @@ const originalStopCallback = Mousetrap.stopCallback;
 
export default {
components: {
NewModal,
IdeSidebar,
RepoTabs,
IdeStatusBar,
Loading
Loading
@@ -137,5 +139,6 @@ export default {
/>
</div>
<ide-status-bar :file="activeFile"/>
<new-modal />
</article>
</template>
<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']),
Loading
Loading
@@ -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>
<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>
Loading
Loading
@@ -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: {
Loading
Loading
@@ -20,11 +22,13 @@ export default {
required: false,
default: '',
},
mouseOver: {
type: Boolean,
required: true,
},
},
data() {
return {
openModal: false,
modalType: '',
dropdownOpen: false,
};
},
Loading
Loading
@@ -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;
},
Loading
Loading
@@ -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
Loading
Loading
@@ -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>
<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') }}
Loading
Loading
@@ -85,6 +77,6 @@ export default {
class="form-control"
/>
</div>
</form>
</deprecated-modal>
</div>
</gl-modal>
</template>
<script>
export default {
props: {
branchId: {
type: String,
required: true,
},
path: {
type: String,
required: false,
default: '',
},
import Icon from '~/vue_shared/components/icon.vue';
import ItemButton from './button.vue';
export default {
components: {
Icon,
ItemButton,
},
props: {
path: {
type: String,
required: false,
default: '',
},
mounted() {
this.$refs.fileUpload.addEventListener('change', this.openFile);
showLabel: {
type: Boolean,
required: false,
default: true,
},
beforeDestroy() {
this.$refs.fileUpload.removeEventListener('change', this.openFile);
buttonCssClasses: {
type: String,
required: false,
default: null,
},
methods: {
createFile(target, file, isText) {
const { name } = file;
let { result } = target;
},
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];
}
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;
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 });
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();
},
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"
Loading
Loading
Loading
Loading
@@ -40,6 +40,11 @@ export default {
default: false,
},
},
data() {
return {
mouseOver: false,
};
},
computed: {
...mapGetters([
'getChangesInFolder',
Loading
Loading
@@ -142,6 +147,9 @@ export default {
hasUrlAtCurrentRoute() {
return this.$router.currentRoute.path === `/project${this.file.url}`;
},
toggleHover(over) {
this.mouseOver = over;
},
},
};
</script>
Loading
Loading
@@ -153,6 +161,8 @@ export default {
class="file"
role="button"
@click="clickFile"
@mouseover="toggleHover(true)"
@mouseout="toggleHover(false)"
>
<div
class="file-name"
Loading
Loading
@@ -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>
Loading
Loading
Loading
Loading
@@ -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();
Loading
Loading
@@ -81,7 +81,7 @@ export const createTempEntry = (
commit(types.CREATE_TMP_ENTRY, {
data,
projectId: state.currentProjectId,
branchId,
branchId: state.currentBranchId,
});
 
if (type === 'blob') {
Loading
Loading
@@ -100,7 +100,7 @@ export const createTempEntry = (
worker.postMessage({
data: [fullName],
projectId: state.currentProjectId,
branchId,
branchId: state.currentBranchId,
type,
tempFile: true,
base64,
Loading
Loading
@@ -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';
Loading
Loading
Loading
Loading
@@ -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';
Loading
Loading
@@ -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,
Loading
Loading
Loading
Loading
@@ -26,4 +26,8 @@ export default () => ({
rightPane: null,
links: {},
errorMessage: null,
newEntryModal: {
type: '',
path: '',
},
});
Loading
Loading
@@ -45,6 +45,11 @@ export default {
emitSubmit(event) {
this.$emit('submit', event);
},
opened({ propertyName }) {
if (propertyName === 'opacity') {
this.$emit('open');
}
},
},
};
</script>
Loading
Loading
@@ -55,6 +60,7 @@ export default {
class="modal fade"
tabindex="-1"
role="dialog"
@transitionend="opened"
>
<div
:class="modalSizeClass"
Loading
Loading
Loading
Loading
@@ -44,6 +44,7 @@
padding-bottom: $grid-size;
 
.file {
height: 32px;
cursor: pointer;
 
&.file-active {
Loading
Loading
@@ -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;
 
Loading
Loading
@@ -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;
}
---
title: Updated design of new entry dropdown in Web IDE
merge_request: 20526
author:
type: changed
Loading
Loading
@@ -1720,6 +1720,9 @@ msgstr ""
msgid "Create new file"
msgstr ""
 
msgid "Create new file or directory"
msgstr ""
msgid "Create new label"
msgstr ""
 
Loading
Loading
Loading
Loading
@@ -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')
Loading
Loading
@@ -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')
Loading
Loading
Loading
Loading
@@ -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')
Loading
Loading
Loading
Loading
@@ -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
Loading
Loading
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();
});
});
});
Loading
Loading
@@ -13,6 +13,7 @@ describe('new dropdown component', () => {
vm = createComponentWithStore(component, store, {
branch: 'master',
path: '',
mouseOver: false,
});
 
vm.$store.state.currentProjectId = 'abcproject';
Loading
Loading
@@ -21,6 +22,8 @@ describe('new dropdown component', () => {
tree: [],
};
 
spyOn(vm, 'openNewEntryModal');
vm.$mount();
});
 
Loading
Loading
@@ -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();
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();
vm.$el.querySelectorAll('.dropdown-menu button')[2].click();
 
Vue.nextTick()
.then(() => {
expect(vm.$el.querySelector('.modal')).toBeNull();
})
.then(done)
.catch(done.fail);
expect(vm.openNewEntryModal).toHaveBeenCalledWith({ type: 'tree', path: '' });
});
});
 
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