Commit 6f47aa0b authored by Alfredo Sumaran's avatar Alfredo Sumaran

Merge branch 'ee-28732-expandable-folders' into 'master'

Port of 28732-expandable-folders to EE

See merge request !1538
parents 56906ae2 971213c1
...@@ -25,6 +25,7 @@ export default Vue.component('environment-component', { ...@@ -25,6 +25,7 @@ export default Vue.component('environment-component', {
state: store.state, state: store.state,
visibility: 'available', visibility: 'available',
isLoading: false, isLoading: false,
isLoadingFolderContent: false,
cssContainerClass: environmentsData.cssClass, cssContainerClass: environmentsData.cssClass,
endpoint: environmentsData.environmentsDataEndpoint, endpoint: environmentsData.environmentsDataEndpoint,
canCreateDeployment: environmentsData.canCreateDeployment, canCreateDeployment: environmentsData.canCreateDeployment,
...@@ -79,10 +80,12 @@ export default Vue.component('environment-component', { ...@@ -79,10 +80,12 @@ export default Vue.component('environment-component', {
this.fetchEnvironments(); this.fetchEnvironments();
eventHub.$on('refreshEnvironments', this.fetchEnvironments); eventHub.$on('refreshEnvironments', this.fetchEnvironments);
eventHub.$on('toggleFolder', this.toggleFolder);
}, },
beforeDestroyed() { beforeDestroyed() {
eventHub.$off('refreshEnvironments'); eventHub.$off('refreshEnvironments');
eventHub.$off('toggleFolder');
}, },
methods: { methods: {
...@@ -97,6 +100,14 @@ export default Vue.component('environment-component', { ...@@ -97,6 +100,14 @@ export default Vue.component('environment-component', {
return this.store.toggleDeployBoard(model.id); return this.store.toggleDeployBoard(model.id);
}, },
toggleFolder(folder, folderUrl) {
this.store.toggleFolder(folder);
if (!folder.isOpen) {
this.fetchChildEnvironments(folder, folderUrl);
}
},
/** /**
* Will change the page number and update the URL. * Will change the page number and update the URL.
* *
...@@ -135,6 +146,21 @@ export default Vue.component('environment-component', { ...@@ -135,6 +146,21 @@ export default Vue.component('environment-component', {
new Flash('An error occurred while fetching the environments.'); new Flash('An error occurred while fetching the environments.');
}); });
}, },
fetchChildEnvironments(folder, folderUrl) {
this.isLoadingFolderContent = true;
this.service.getFolderContent(folderUrl)
.then(resp => resp.json())
.then((response) => {
this.store.setfolderContent(folder, response.environments);
this.isLoadingFolderContent = false;
})
.catch(() => {
this.isLoadingFolderContent = false;
new Flash('An error occurred while fetching the environments.');
});
},
}, },
template: ` template: `
...@@ -199,7 +225,8 @@ export default Vue.component('environment-component', { ...@@ -199,7 +225,8 @@ export default Vue.component('environment-component', {
:can-read-environment="canReadEnvironmentParsed" :can-read-environment="canReadEnvironmentParsed"
:toggleDeployBoard="toggleDeployBoard" :toggleDeployBoard="toggleDeployBoard"
:store="store" :store="store"
:service="service"/> :service="service"
:is-loading-folder-content="isLoadingFolderContent" />
</div> </div>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1" <table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
......
...@@ -13,6 +13,7 @@ import RollbackComponent from './environment_rollback'; ...@@ -13,6 +13,7 @@ import RollbackComponent from './environment_rollback';
import TerminalButtonComponent from './environment_terminal_button'; import TerminalButtonComponent from './environment_terminal_button';
import MonitoringButtonComponent from './environment_monitoring'; import MonitoringButtonComponent from './environment_monitoring';
import CommitComponent from '../../vue_shared/components/commit'; import CommitComponent from '../../vue_shared/components/commit';
import eventHub from '../event_hub';
const timeagoInstance = new Timeago(); const timeagoInstance = new Timeago();
...@@ -434,8 +435,14 @@ export default { ...@@ -434,8 +435,14 @@ export default {
return true; return true;
}, },
methods: {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model, this.folderUrl);
},
},
template: ` template: `
<tr> <tr :class="{ 'js-child-row': model.isChildren }">
<td> <td>
<span class="deploy-board-icon" <span class="deploy-board-icon"
v-if="model.hasDeployBoard" v-if="model.hasDeployBoard"
...@@ -443,22 +450,38 @@ export default { ...@@ -443,22 +450,38 @@ export default {
<i v-show="!model.isDeployBoardVisible" <i v-show="!model.isDeployBoardVisible"
class="fa fa-caret-right" class="fa fa-caret-right"
aria-hidden="true"> aria-hidden="true" />
</i>
<i v-show="model.isDeployBoardVisible" <i v-show="model.isDeployBoardVisible"
class="fa fa-caret-down" class="fa fa-caret-down"
aria-hidden="true"> aria-hidden="true" />
</i>
</span> </span>
<a v-if="!model.isFolder" <a v-if="!model.isFolder"
class="environment-name" class="environment-name"
:class="{ 'prepend-left-default': model.isChildren }"
:href="environmentPath"> :href="environmentPath">
{{model.name}} {{model.name}}
</a> </a>
<a v-else class="folder-name" :href="folderUrl"> <span v-if="model.isFolder"
class="folder-name"
@click="onClickFolder"
role="button">
<span class="folder-icon">
<i
v-show="model.isOpen"
class="fa fa-caret-down"
aria-hidden="true" />
<i
v-show="!model.isOpen"
class="fa fa-caret-right"
aria-hidden="true"/>
</span>
<span class="folder-icon"> <span class="folder-icon">
<i class="fa fa-folder" aria-hidden="true"></i> <i class="fa fa-folder" aria-hidden="true"></i>
</span> </span>
...@@ -470,7 +493,7 @@ export default { ...@@ -470,7 +493,7 @@ export default {
<span class="badge"> <span class="badge">
{{model.size}} {{model.size}}
</span> </span>
</a> </span>
</td> </td>
<td class="deployment-column"> <td class="deployment-column">
......
...@@ -49,6 +49,18 @@ export default { ...@@ -49,6 +49,18 @@ export default {
required: true, required: true,
default: () => ({}), default: () => ({}),
}, },
isLoadingFolderContent: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
folderUrl(model) {
return `${window.location.pathname}/folders/${model.folderName}`;
},
}, },
template: ` template: `
...@@ -85,6 +97,31 @@ export default { ...@@ -85,6 +97,31 @@ export default {
</deploy-board> </deploy-board>
</td> </td>
</tr> </tr>
<template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
<tr v-if="isLoadingFolderContent">
<td colspan="6" class="text-center">
<i class="fa fa-spin fa-spinner fa-2x" aria-hidden="true"/>
</td>
</tr>
<template v-else>
<tr is="environment-item"
v-for="children in model.children"
:model="children"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
:service="service"></tr>
<tr>
<td colspan="6" class="text-center">
<a :href="folderUrl(model)" class="btn btn-default">
Show all
</a>
</td>
</tr>
</template>
</template>
</template> </template>
</tbody> </tbody>
</table> </table>
......
...@@ -7,6 +7,7 @@ Vue.use(VueResource); ...@@ -7,6 +7,7 @@ Vue.use(VueResource);
export default class EnvironmentsService { export default class EnvironmentsService {
constructor(endpoint) { constructor(endpoint) {
this.environments = Vue.resource(endpoint); this.environments = Vue.resource(endpoint);
this.folderResults = 3;
} }
get(scope, page) { get(scope, page) {
...@@ -20,4 +21,8 @@ export default class EnvironmentsService { ...@@ -20,4 +21,8 @@ export default class EnvironmentsService {
postAction(endpoint) { postAction(endpoint) {
return Vue.http.post(endpoint, {}, { emulateJSON: true }); return Vue.http.post(endpoint, {}, { emulateJSON: true });
} }
getFolderContent(folderUrl) {
return Vue.http.get(`${folderUrl}.json?per_page=${this.folderResults}`);
}
} }
...@@ -45,23 +45,29 @@ export default class EnvironmentsStore { ...@@ -45,23 +45,29 @@ export default class EnvironmentsStore {
const filteredEnvironments = environments.map((env) => { const filteredEnvironments = environments.map((env) => {
let filtered = {}; let filtered = {};
if (env.size > 1) {
filtered = Object.assign({}, env, {
isFolder: true,
folderName: env.name,
isOpen: false,
children: [],
});
}
if (env.latest) { if (env.latest) {
filtered = Object.assign({}, env, env.latest); filtered = Object.assign(filtered, env, env.latest);
delete filtered.latest; delete filtered.latest;
} else { } else {
filtered = Object.assign({}, env); filtered = Object.assign(filtered, env);
} }
if (filtered.size > 1) { if (filtered.size === 1 && filtered.rollout_status_path) {
filtered = Object.assign(filtered, env, { isFolder: true, folderName: env.name }); filtered = Object.assign({}, filtered, {
} else if (filtered.size === 1 && filtered.rollout_status_path) {
filtered = Object.assign({}, env, filtered, {
hasDeployBoard: true, hasDeployBoard: true,
isDeployBoardVisible: false, isDeployBoardVisible: false,
deployBoardData: {}, deployBoardData: {},
}); });
} }
return filtered; return filtered;
}); });
...@@ -155,4 +161,66 @@ export default class EnvironmentsStore { ...@@ -155,4 +161,66 @@ export default class EnvironmentsStore {
}); });
return this.state.environments; return this.state.environments;
} }
/*
* Toggles folder open property for the given folder.
*
* @param {Object} folder
* @return {Array}
*/
toggleFolder(folder) {
return this.updateFolder(folder, 'isOpen', !folder.isOpen);
}
/**
* Updates the folder with the received environments.
*
*
* @param {Object} folder Folder to update
* @param {Array} environments Received environments
* @return {Object}
*/
setfolderContent(folder, environments) {
const updatedEnvironments = environments.map((env) => {
let updated = env;
if (env.latest) {
updated = Object.assign({}, env, env.latest);
delete updated.latest;
} else {
updated = env;
}
updated.isChildren = true;
return updated;
});
return this.updateFolder(folder, 'children', updatedEnvironments);
}
/**
* Given a folder a prop and a new value updates the correct folder.
*
* @param {Object} folder
* @param {String} prop
* @param {String|Boolean|Object|Array} newValue
* @return {Array}
*/
updateFolder(folder, prop, newValue) {
const environments = this.state.environments;
const updatedEnvironments = environments.map((env) => {
const updateEnv = Object.assign({}, env);
if (env.isFolder && env.id === folder.id) {
updateEnv[prop] = newValue;
}
return updateEnv;
});
this.state.environments = updatedEnvironments;
return updatedEnvironments;
}
} }
---
title: Add back expandable folder behavior
merge_request:
author:
...@@ -23,6 +23,42 @@ feature 'Environments page', :feature, :js do ...@@ -23,6 +23,42 @@ feature 'Environments page', :feature, :js do
expect(page).to have_link('Available') expect(page).to have_link('Available')
expect(page).to have_link('Stopped') expect(page).to have_link('Stopped')
end end
describe 'with one available environment' do
given(:environment) { create(:environment, project: project, state: :available) }
describe 'in available tab page' do
it 'should show one environment' do
visit namespace_project_environments_path(project.namespace, project, scope: 'available')
expect(page.all('tbody > tr').length).to eq(1)
end
end
describe 'in stopped tab page' do
it 'should show no environments' do
visit namespace_project_environments_path(project.namespace, project, scope: 'stopped')
expect(page).to have_content('You don\'t have any environments right now')
end
end
end
describe 'with one stopped environment' do
given(:environment) { create(:environment, project: project, state: :stopped) }
describe 'in available tab page' do
it 'should show no environments' do
visit namespace_project_environments_path(project.namespace, project, scope: 'available')
expect(page).to have_content('You don\'t have any environments right now')
end
end
describe 'in stopped tab page' do
it 'should show one environment' do
visit namespace_project_environments_path(project.namespace, project, scope: 'stopped')
expect(page.all('tbody > tr').length).to eq(1)
end
end
end
end end
context 'without environments' do context 'without environments' do
......
import Vue from 'vue'; import Vue from 'vue';
import '~/flash'; import '~/flash';
import EnvironmentsComponent from '~/environments/components/environment'; import EnvironmentsComponent from '~/environments/components/environment';
import { environment } from './mock_data'; import { environment, folder } from './mock_data';
describe('Environment', () => { describe('Environment', () => {
preloadFixtures('static/environments/environments.html.raw'); preloadFixtures('static/environments/environments.html.raw');
...@@ -151,8 +151,8 @@ describe('Environment', () => { ...@@ -151,8 +151,8 @@ describe('Environment', () => {
it('should render arrow to open deploy boards', (done) => { it('should render arrow to open deploy boards', (done) => {
setTimeout(() => { setTimeout(() => {
expect( expect(
component.$el.querySelector('.deploy-board-icon i').classList.contains('fa-caret-right'), component.$el.querySelector('.deploy-board-icon i.fa-caret-right'),
).toEqual(true); ).toBeDefined();
done(); done();
}, 0); }, 0);
}); });
...@@ -190,4 +190,100 @@ describe('Environment', () => { ...@@ -190,4 +190,100 @@ describe('Environment', () => {
}, 0); }, 0);
}); });
}); });
describe('expandable folders', () => {
const environmentsResponseInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
environments: [folder],
stopped_count: 0,
available_count: 1,
}), {
status: 200,
headers: {
'X-nExt-pAge': '2',
'x-page': '1',
'X-Per-Page': '1',
'X-Prev-Page': '',
'X-TOTAL': '37',
'X-Total-Pages': '2',
},
}));
};
beforeEach(() => {
Vue.http.interceptors.push(environmentsResponseInterceptor);
component = new EnvironmentsComponent({
el: document.querySelector('#environments-list-view'),
});
});
afterEach(() => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, environmentsResponseInterceptor,
);
});
it('should open a closed folder', (done) => {
setTimeout(() => {
component.$el.querySelector('.folder-name').click();
Vue.nextTick(() => {
expect(
component.$el.querySelector('.folder-icon i.fa-caret-right').getAttribute('style'),
).toContain('display: none');
expect(
component.$el.querySelector('.folder-icon i.fa-caret-down').getAttribute('style'),
).not.toContain('display: none');
done();
});
});
});
it('should close an opened folder', (done) => {
setTimeout(() => {
// open folder
component.$el.querySelector('.folder-name').click();
Vue.nextTick(() => {
// close folder
component.$el.querySelector('.folder-name').click();
Vue.nextTick(() => {
expect(
component.$el.querySelector('.folder-icon i.fa-caret-down').getAttribute('style'),
).toContain('display: none');
expect(
component.$el.querySelector('.folder-icon i.fa-caret-right').getAttribute('style'),
).not.toContain('display: none');
done();
});
});
});
});
it('should show children environments and a button to show all environments', (done) => {
setTimeout(() => {
// open folder
component.$el.querySelector('.folder-name').click();
Vue.nextTick(() => {
const folderInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
environments: [environment],
}), { status: 200 }));
};
Vue.http.interceptors.push(folderInterceptor);
// wait for next async request
setTimeout(() => {
expect(component.$el.querySelectorAll('.js-child-row').length).toEqual(1);
expect(component.$el.querySelector('td.text-center > a.btn').textContent).toContain('Show all');
done();
Vue.http.interceptors = _.without(Vue.http.interceptors, folderInterceptor);
});
});
});
});
});
}); });
import Store from '~/environments/stores/environments_store'; import Store from '~/environments/stores/environments_store';
import { serverData, deployBoardMockData } from './mock_data'; import { serverData, deployBoardMockData } from './mock_data';
(() => { describe('Store', () => {
describe('Environments Store', () => {
let store; let store;
beforeEach(() => { beforeEach(() => {
...@@ -16,6 +15,41 @@ import { serverData, deployBoardMockData } from './mock_data'; ...@@ -16,6 +15,41 @@ import { serverData, deployBoardMockData } from './mock_data';
expect(store.state.paginationInformation).toEqual({}); expect(store.state.paginationInformation).toEqual({});
}); });
it('should store environments', () => {
const expectedResult = {
name: 'DEV',
size: 1,
id: 7,
state: 'available',
external_url: null,
environment_type: null,
last_deployment: null,
'stop_action?': false,
environment_path: '/root/review-app/environments/7',
stop_path: '/root/review-app/environments/7/stop',
created_at: '2017-01-31T10:53:46.894Z',
updated_at: '2017-01-31T10:53:46.894Z',
rollout_status_path: '/path',
hasDeployBoard: true,
isDeployBoardVisible: false,
deployBoardData: {},
};
store.storeEnvironments(serverData);
expect(store.state.environments.length).toEqual(serverData.length);
expect(store.state.environments[0]).toEqual(expectedResult);
});
it('should store available count', () => {
store.storeAvailableCount(2);
expect(store.state.availableCounter).toEqual(2);
});
it('should store stopped count', () => {
store.storeStoppedCount(2);
expect(store.state.stoppedCounter).toEqual(2);
});
describe('store environments', () => { describe('store environments', () => {
it('should store environments', () => { it('should store environments', () => {
store.storeEnvironments(serverData); store.storeEnvironments(serverData);
...@@ -71,18 +105,31 @@ import { serverData, deployBoardMockData } from './mock_data'; ...@@ -71,18 +105,31 @@ import { serverData, deployBoardMockData } from './mock_data';
it('should store root level name when environment is a folder', () => { it('should store root level name when environment is a folder', () => {
store.storeEnvironments(serverData); store.storeEnvironments(serverData);
expect(store.state.environments[1].name).toEqual(serverData[1].name); expect(store.state.environments[1].folderName).toEqual(serverData[1].name);
}); });
}); });
it('should store available count', () => { describe('toggleFolder', () => {
store.storeAvailableCount(2); it('should toggle folder', () => {
expect(store.state.availableCounter).toEqual(2); store.storeEnvironments(serverData);
store.toggleFolder(store.state.environments[1]);
expect(store.state.environments[1].isOpen).toEqual(true);
store.toggleFolder(store.state.environments[1]);
expect(store.state.environments[1].isOpen).toEqual(false);
});
}); });
it('should store stopped count', () => { describe('setfolderContent', () => {
store.storeStoppedCount(2); it('should store folder content', () => {
expect(store.state.stoppedCounter).toEqual(2); store.storeEnvironments(serverData);
store.setfolderContent(store.state.environments[1], serverData);
expect(store.state.environments[1].children.length).toEqual(serverData.length);
expect(store.state.environments[1].children[0].isChildren).toEqual(true);
});
}); });
describe('store pagination', () => { describe('store pagination', () => {
...@@ -131,5 +178,4 @@ import { serverData, deployBoardMockData } from './mock_data'; ...@@ -131,5 +178,4 @@ import { serverData, deployBoardMockData } from './mock_data';
expect(store.state.environments[0].deployBoardData).toEqual(deployBoardMockData); expect(store.state.environments[0].deployBoardData).toEqual(deployBoardMockData);
}); });
}); });
}); });
})();
...@@ -28,6 +28,7 @@ export const environmentsList = [ ...@@ -28,6 +28,7 @@ export const environmentsList = [
stop_path: '/root/review-app/environments/12/stop', stop_path: '/root/review-app/environments/12/stop',
created_at: '2017-02-01T19:42:18.400Z', created_at: '2017-02-01T19:42:18.400Z',
updated_at: '2017-02-01T19:42:18.400Z', updated_at: '2017-02-01T19:42:18.400Z',
rollout_status_path: '/path',
}, },
]; ];
...@@ -47,6 +48,7 @@ export const serverData = [ ...@@ -47,6 +48,7 @@ export const serverData = [
stop_path: '/root/review-app/environments/7/stop', stop_path: '/root/review-app/environments/7/stop',
created_at: '2017-01-31T10:53:46.894Z', created_at: '2017-01-31T10:53:46.894Z',
updated_at: '2017-01-31T10:53:46.894Z', updated_at: '2017-01-31T10:53:46.894Z',
rollout_status_path: '/path',
}, },
}, },
{ {
...@@ -88,9 +90,7 @@ export const serverData = [ ...@@ -88,9 +90,7 @@ export const serverData = [
export const environment = { export const environment = {
name: 'DEV', name: 'DEV',
size: 1, size: 1,
latest: {
id: 7, id: 7,
name: 'DEV',
state: 'available', state: 'available',
external_url: null, external_url: null,
environment_type: null, environment_type: null,
...@@ -101,7 +101,6 @@ export const environment = { ...@@ -101,7 +101,6 @@ export const environment = {
created_at: '2017-01-31T10:53:46.894Z', created_at: '2017-01-31T10:53:46.894Z',
updated_at: '2017-01-31T10:53:46.894Z', updated_at: '2017-01-31T10:53:46.894Z',
rollout_status_path: '/path', rollout_status_path: '/path',
},
}; };
export const deployBoardMockData = { export const deployBoardMockData = {
...@@ -147,3 +146,19 @@ export const invalidDeployBoardMockData = { ...@@ -147,3 +146,19 @@ export const invalidDeployBoardMockData = {
completion: 100, completion: 100,
valid: false, valid: false,
}; };
export const folder = {
folderName: 'build',
size: 5,
id: 12,
name: 'build/update-README',
state: 'available',
external_url: null,
environment_type: 'build',
last_deployment: null,
'stop_action?': false,
environment_path: '/root/review-app/environments/12',
stop_path: '/root/review-app/environments/12/stop',
created_at: '2017-02-01T19:42:18.400Z',
updated_at: '2017-02-01T19:42:18.400Z',
};
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