Commit 19d7a99f authored by Phil Hughes's avatar Phil Hughes

Merge branch '2677-deploy-boards' into 'master'

Improve state management for deploy boards with realtime data

Closes #2677

See merge request !2311
parents d5628865 e71d8d94
<script>
/* eslint-disable no-new, no-undef */
/* global Flash */
/**
* Renders a deploy board.
*
* A deploy board is composed by:
* - Information area with percentage of completion.
* - Instances with status.
* - Button Actions.
* [Mockup](https://gitlab.com/gitlab-org/gitlab-ce/uploads/2f655655c0eadf655d0ae7467b53002a/environments__deploy-graphic.png)
*
* The data of each deploy board needs to be fetched when we render the component.
*
* The endpoint response can sometimes be 204, in those cases we need to retry the request.
* This should be done using backoff pooling and we should make no more than 3 request
* for each deploy board.
* After the third request we need to show a message saying we can't fetch the data.
* Please refer to this [comment](https://gitlab.com/gitlab-org/gitlab-ee/issues/1589#note_23630610)
* for more information
*/
import Visibility from 'visibilityjs';
import deployBoardSvg from 'empty_states/icons/_deploy_board.svg';
import instanceComponent from './deploy_board_instance_component.vue';
import Poll from '../../lib/utils/poll';
import '../../flash';
export default {
components: {
instanceComponent,
},
props: {
store: {
type: Object,
required: true,
},
service: {
type: Object,
required: true,
/**
* Renders a deploy board.
*
* A deploy board is composed by:
* - Information area with percentage of completion.
* - Instances with status.
* - Button Actions.
* [Mockup](https://gitlab.com/gitlab-org/gitlab-ce/uploads/2f655655c0eadf655d0ae7467b53002a/environments__deploy-graphic.png)
*/
import deployBoardSvg from 'empty_states/icons/_deploy_board.svg';
import instanceComponent from './deploy_board_instance_component.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue';
export default {
components: {
instanceComponent,
loadingIcon,
},
deployBoardData: {
type: Object,
required: true,
props: {
deployBoardData: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: true,
},
hasError: {
type: Boolean,
required: true,
},
},
environmentID: {
type: Number,
required: true,
data() {
return {
deployBoardSvg,
};
},
endpoint: {
type: String,
required: true,
},
},
data() {
return {
isLoading: false,
hasError: false,
deployBoardSvg,
};
},
created() {
const poll = new Poll({
resource: this.service,
method: 'getDeployBoard',
data: this.endpoint,
successCallback: this.successCallback,
errorCallback: this.errorCallback,
});
if (!Visibility.hidden()) {
this.isLoading = true;
poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
poll.restart();
} else {
poll.stop();
}
});
},
methods: {
successCallback(response) {
const data = response.json();
this.store.storeDeployBoard(this.environmentID, data);
this.isLoading = false;
},
errorCallback() {
this.isLoading = false;
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the deploy board.');
},
},
computed: {
canRenderDeployBoard() {
return !this.isLoading && !this.hasError && this.deployBoardData.valid;
},
canRenderEmptyState() {
return !this.isLoading && !this.hasError && !this.deployBoardData.valid;
},
canRenderErrorState() {
return !this.isLoading && this.hasError;
},
instanceTitle() {
let title;
if (this.deployBoardData.instances.length === 1) {
title = 'Instance';
} else {
title = 'Instances';
}
return title;
},
projectName() {
return '<projectname>';
computed: {
canRenderDeployBoard() {
return !this.isLoading && !this.hasError && this.deployBoardData.valid;
},
canRenderEmptyState() {
return !this.isLoading && !this.hasError && !this.deployBoardData.valid;
},
canRenderErrorState() {
return !this.isLoading && this.hasError;
},
instanceTitle() {
let title;
if (this.deployBoardData.instances.length === 1) {
title = 'Instance';
} else {
title = 'Instances';
}
return title;
},
projectName() {
return '<projectname>';
},
},
},
};
};
</script>
<template>
<div class="js-deploy-board deploy-board">
<div v-if="isLoading">
<i
class="fa fa-spinner fa-spin"
aria-hidden="true" />
<loading-icon />
</div>
<div v-if="canRenderDeployBoard">
......@@ -161,7 +87,8 @@ export default {
<instance-component
:status="instance.status"
:tooltip-text="instance.tooltip"
:stable="instance.stable" />
:stable="instance.stable"
/>
</template>
</div>
</section>
......
......@@ -33,7 +33,6 @@ export default {
state: store.state,
visibility: 'available',
isLoading: false,
isLoadingFolderContent: false,
cssContainerClass: environmentsData.cssClass,
endpoint: environmentsData.environmentsDataEndpoint,
canCreateDeployment: environmentsData.canCreateDeployment,
......@@ -97,9 +96,6 @@ export default {
errorCallback: this.errorCallback,
notificationCallback: (isMakingRequest) => {
this.isMakingRequest = isMakingRequest;
// We need to verify if any folder is open to also fecth it
this.openFolders = this.store.getOpenFolders();
},
});
......@@ -118,11 +114,13 @@ export default {
eventHub.$on('toggleFolder', this.toggleFolder);
eventHub.$on('postAction', this.postAction);
eventHub.$on('toggleDeployBoard', this.toggleDeployBoard);
},
beforeDestroy() {
eventHub.$off('toggleFolder');
eventHub.$off('postAction');
eventHub.$off('toggleDeployBoard');
},
methods: {
......@@ -134,14 +132,18 @@ export default {
* @return {Object}
*/
toggleDeployBoard(model) {
return this.store.toggleDeployBoard(model.id);
this.store.toggleDeployBoard(model.id);
if (!model.isDeployboardVisible) {
this.fetchDeployBoard(model, true);
}
},
toggleFolder(folder, folderUrl) {
this.store.toggleFolder(folder);
if (!folder.isOpen) {
this.fetchChildEnvironments(folder, folderUrl);
this.fetchChildEnvironments(folder, folderUrl, true);
}
},
......@@ -169,19 +171,17 @@ export default {
.catch(this.errorCallback);
},
fetchChildEnvironments(folder, folderUrl) {
this.isLoadingFolderContent = true;
fetchChildEnvironments(folder, folderUrl, showLoader = false) {
this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', showLoader);
this.service.getFolderContent(folderUrl)
.then(resp => resp.json())
.then((response) => {
this.store.setfolderContent(folder, response.environments);
this.isLoadingFolderContent = false;
})
.then(response => this.store.setfolderContent(folder, response.environments))
.then(() => this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false))
.catch(() => {
this.isLoadingFolderContent = false;
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.');
this.store.updateEnvironmentProp(folder, 'isLoadingFolderContent', false);
});
},
......@@ -198,16 +198,21 @@ export default {
successCallback(resp) {
this.saveData(resp);
// If folders are open while polling we need to open them again
if (this.openFolders.length) {
this.openFolders.map((folder) => {
// We need to verify if any folder is open to also update it
const openFolders = this.store.getOpenFolders();
if (openFolders.length) {
openFolders.forEach((folder) => {
// TODO - Move this to the backend
const folderUrl = `${window.location.pathname}/folders/${folder.folderName}`;
this.store.updateFolder(folder, 'isOpen', true);
return this.fetchChildEnvironments(folder, folderUrl);
});
}
const openDeployBoards = this.store.getOpenDeployBoards();
if (openDeployBoards.length) {
openDeployBoards.forEach(env => this.fetchDeployBoard(env));
}
},
errorCallback() {
......@@ -215,6 +220,23 @@ export default {
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the environments.');
},
fetchDeployBoard(environment, showLoader = false) {
this.store.updateEnvironmentProp(environment, 'isLoadingDeployBoard', showLoader);
this.service.getDeployBoard(environment.rollout_status_path)
.then(resp => resp.json())
.then((data) => {
this.store.storeDeployBoard(environment.id, data);
this.store.updateEnvironmentProp(environment, 'isLoadingDeployBoard', false);
})
.catch(() => {
this.store.updateEnvironmentProp(environment, 'isLoadingDeployBoard', false);
this.store.updateEnvironmentProp(environment, 'hasErrorDeployBoard', true);
// eslint-disable-next-line no-new
new Flash('An error occurred while fetching the deploy board.');
});
},
},
};
</script>
......@@ -289,10 +311,7 @@ export default {
:environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
:toggleDeployBoard="toggleDeployBoard"
:store="store"
:service="service"
:is-loading-folder-content="isLoadingFolderContent" />
/>
</div>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
......
......@@ -44,12 +44,6 @@ export default {
required: false,
default: false,
},
toggleDeployBoard: {
type: Function,
required: false,
},
},
computed: {
......@@ -426,6 +420,9 @@ export default {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model, this.folderUrl);
},
toggleDeployBoard() {
eventHub.$emit('toggleDeployBoard', this.model);
},
},
};
</script>
......@@ -443,17 +440,19 @@ export default {
<span
class="deploy-board-icon"
v-if="model.hasDeployBoard"
@click="toggleDeployBoard(model)">
@click="toggleDeployBoard">
<i
v-show="!model.isDeployBoardVisible"
class="fa fa-caret-right"
aria-hidden="true" />
aria-hidden="true">
</i>
<i
v-show="model.isDeployBoardVisible"
class="fa fa-caret-down"
aria-hidden="true" />
aria-hidden="true">
</i>
</span>
<a
v-if="!model.isFolder"
......
......@@ -31,29 +31,6 @@ export default {
required: false,
default: false,
},
isLoadingFolderContent: {
type: Boolean,
required: false,
default: false,
},
toggleDeployBoard: {
type: Function,
required: false,
default: () => {},
},
store: {
type: Object,
required: false,
default: () => ({}),
},
service: {
type: Object,
required: true,
},
},
methods: {
......@@ -90,23 +67,20 @@ export default {
:model="model"
:can-create-deployment="canCreateDeployment"
:can-read-environment="canReadEnvironment"
:toggleDeployBoard="toggleDeployBoard"
/>
<div v-if="model.hasDeployBoard && model.isDeployBoardVisible" class="js-deploy-board-row">
<div class="deploy-board-container">
<deploy-board
:store="store"
:service="service"
:environmentID="model.id"
:deployBoardData="model.deployBoardData"
:endpoint="model.rollout_status_path"
:deploy-board-data="model.deployBoardData"
:is-loading="model.isLoadingDeployBoard"
:has-error="model.hasErrorDeployBoard"
/>
</div>
</div>
<template v-if="model.isFolder && model.isOpen && model.children && model.children.length > 0">
<div v-if="isLoadingFolderContent">
<div v-if="model.isLoadingFolderContent">
<loading-icon size="2" />
</div>
......
......@@ -43,14 +43,18 @@ export default class EnvironmentsStore {
*/
storeEnvironments(environments = []) {
const filteredEnvironments = environments.map((env) => {
const oldEnvironmentState = this.state.environments
.find(element => element.id === env.latest.id) || {};
let filtered = {};
if (env.size > 1) {
filtered = Object.assign({}, env, {
isFolder: true,
isLoadingFolderContent: oldEnvironmentState.isLoading || false,
folderName: env.name,
isOpen: false,
children: [],
isOpen: oldEnvironmentState.isOpen || false,
children: oldEnvironmentState.children || [],
});
}
......@@ -64,8 +68,12 @@ export default class EnvironmentsStore {
if (filtered.size === 1 && filtered.rollout_status_path) {
filtered = Object.assign({}, filtered, {
hasDeployBoard: true,
isDeployBoardVisible: true,
deployBoardData: {},
isDeployBoardVisible: oldEnvironmentState.isDeployBoardVisible === false ?
oldEnvironmentState.isDeployBoardVisible :
true,
deployBoardData: oldEnvironmentState.deployBoardData || {},
isLoadingDeployBoard: oldEnvironmentState.isLoadingDeployBoard || false,
hasErrorDeployBoard: oldEnvironmentState.hasErrorDeployBoard || false,
});
}
return filtered;
......@@ -169,7 +177,7 @@ export default class EnvironmentsStore {
* @return {Array}
*/
toggleFolder(folder) {
return this.updateFolder(folder, 'isOpen', !folder.isOpen);
return this.updateEnvironmentProp(folder, 'isOpen', !folder.isOpen);
}
/**
......@@ -196,23 +204,23 @@ export default class EnvironmentsStore {
return updated;
});
return this.updateFolder(folder, 'children', updatedEnvironments);
return this.updateEnvironmentProp(folder, 'children', updatedEnvironments);
}
/**
* Given a folder a prop and a new value updates the correct folder.
* Given a environment, a prop and a new value updates the correct environment.
*
* @param {Object} folder
* @param {Object} environment
* @param {String} prop
* @param {String|Boolean|Object|Array} newValue
* @return {Array}
*/
updateFolder(folder, prop, newValue) {
updateEnvironmentProp(environment, prop, newValue) {
const environments = this.state.environments;
const updatedEnvironments = environments.map((env) => {
const updateEnv = Object.assign({}, env);
if (env.isFolder && env.id === folder.id) {
if (env.id === environment.id) {
updateEnv[prop] = newValue;
}
......@@ -220,8 +228,6 @@ export default class EnvironmentsStore {
});
this.state.environments = updatedEnvironments;
return updatedEnvironments;
}
getOpenFolders() {
......@@ -229,4 +235,8 @@ export default class EnvironmentsStore {
return environments.filter(env => env.isFolder && env.isOpen);
}
getOpenDeployBoards() {
return this.state.environments.filter(env => env.isDeployBoardVisible);
}
}
---
title: Merge states to allow realtime with deploy boards
merge_request:
author:
import Vue from 'vue';
import DeployBoard from '~/environments/components/deploy_board_component.vue';
import Service from '~/environments/services/environments_service';
import { deployBoardMockData, invalidDeployBoardMockData } from './mock_data';
describe('Deploy Board', () => {
......@@ -10,147 +9,77 @@ describe('Deploy Board', () => {
DeployBoardComponent = Vue.extend(DeployBoard);
});
describe('successfull request', () => {
const deployBoardInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify(deployBoardMockData), {
status: 200,
}));
};
describe('with valid data', () => {
let component;
beforeEach(() => {
Vue.http.interceptors.push(deployBoardInterceptor);
this.service = new Service('environments');
component = new DeployBoardComponent({
propsData: {
store: {},
service: this.service,
deployBoardData: deployBoardMockData,
environmentID: 1,
endpoint: 'endpoint',
isLoading: false,
hasError: false,
},
}).$mount();
});
afterEach(() => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, deployBoardInterceptor,
);
it('should render percentage with completion value provided', () => {
expect(
component.$el.querySelector('.deploy-board-information .percentage').textContent,
).toEqual(`${deployBoardMockData.completion}%`);
});
it('should render percentage with completion value provided', (done) => {
setTimeout(() => {
expect(
component.$el.querySelector('.deploy-board-information .percentage').textContent,
).toEqual(`${deployBoardMockData.completion}%`);
done();
}, 0);
});
it('should render all instances', () => {
const instances = component.$el.querySelectorAll('.deploy-board-instances-container div');
it('should render all instances', (done) => {
setTimeout(() => {
const instances = component.$el.querySelectorAll('.deploy-board-instances-container div');
expect(instances.length).toEqual(deployBoardMockData.instances.length);
expect(instances.length).toEqual(deployBoardMockData.instances.length);
expect(
instances[2].classList.contains(`deploy-board-instance-${deployBoardMockData.instances[2].status}`),
).toBe(true);
done();
}, 0);
expect(
instances[2].classList.contains(`deploy-board-instance-${deployBoardMockData.instances[2].status}`),
).toBe(true);
});
it('should render an abort and a rollback button with the provided url', (done) => {
setTimeout(() => {
const buttons = component.$el.querySelectorAll('.deploy-board-actions a');
it('should render an abort and a rollback button with the provided url', () => {
const buttons = component.$el.querySelectorAll('.deploy-board-actions a');
expect(buttons[0].getAttribute('href')).toEqual(deployBoardMockData.rollback_url);
expect(buttons[1].getAttribute('href')).toEqual(deployBoardMockData.abort_url);
done();
}, 0);
expect(buttons[0].getAttribute('href')).toEqual(deployBoardMockData.rollback_url);
expect(buttons[1].getAttribute('href')).toEqual(deployBoardMockData.abort_url);
});
});
describe('successfull request without valid data', () => {
const deployBoardInterceptorInvalidData = (request, next) => {
next(request.respondWith(JSON.stringify(invalidDeployBoardMockData), {
status: 200,
}));
};
describe('without valid data', () => {
let component;
beforeEach(() => {
Vue.http.interceptors.push(deployBoardInterceptorInvalidData);
this.service = new Service('environments');
component = new DeployBoardComponent({
propsData: {
store: {},
service: new Service('environments'),
deployBoardData: invalidDeployBoardMockData,
environmentID: 1,
endpoint: 'endpoint',
isLoading: false,
hasError: false,
},
}).$mount();
});
afterEach(() => {
Vue.http.interceptors = _.without(
Vue.http.interceptors, deployBoardInterceptorInvalidData,
);
});
it('should render the empty state', (done) => {
setTimeout(() => {
expect(component.$el.querySelector('.deploy-board-empty-state-svg svg')).toBeDefined();
expect(component.$el.querySelector('.deploy-board-empty-state-text .title').textContent).toContain('Kubernetes deployment not found');
done();
}, 0);
it('should render the empty state', () => {
expect(component.$el.querySelector('.deploy-board-empty-state-svg svg')).toBeDefined();
expect(component.$el.querySelector('.deploy-board-empty-state-text .title').textContent).toContain('Kubernetes deployment not found');
});
});
describe('unsuccessfull request', () => {
const deployBoardErrorInterceptor = (request, next) => {
next(request.respondWith(JSON.stringify({}), {
status: 500,
}));
};
describe('with error', () => {
let component;
beforeEach(() => {
Vue.http.interceptors.push(deployBoardErrorInterceptor);
this.service = new Service('environments');
component = new DeployBoardComponent({
propsData: {
store: {},
service: this.service,
deployBoardData: {},
environmentID: 1,
endpoint: 'endpoint',
isLoading: false,
hasError: true,
},
}).$mount();
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, deployBoardErrorInterceptor);
});
it('should render empty state', (done) => {
setTimeout(() => {
expect(component.$el.children.length).toEqual(1);
done();
}, 0);
it('should render empty state', () => {
expect(component.$el.children.length).toEqual(1);
});
});
});
import Vue from 'vue';
import environmentTableComp from '~/environments/components/environments_table.vue';
import eventHub from '~/environments/event_hub';
import { deployBoardMockData } from './mock_data';
describe('Environment item', () => {
......@@ -24,9 +25,6 @@ describe('Environment item', () => {
environments: [mockItem],
canCreateDeployment: false,
canReadEnvironment: true,
toggleDeployBoard: () => {},
store: {},
service: {},
},
}).$mount();
......@@ -43,6 +41,8 @@ describe('Environment item', () => {
hasDeployBoard: true,
deployBoardData: deployBoardMockData,
isDeployBoardVisible: true,
isLoadingDeployBoard: false,
hasErrorDeployBoard: false,
};
const component = new EnvironmentTable({
......@@ -51,11 +51,6 @@ describe('Environment item', () => {
environments: [mockItem],
canCreateDeployment: true,
canReadEnvironment: true,
toggleDeployBoard: () => {},
store: {},
service: {
getDeployBoard: () => Promise.resolve(),
},
},
}).$mount();
......@@ -85,7 +80,9 @@ describe('Environment item', () => {
isDeployBoardVisible: false,
};
const spy = jasmine.createSpy('spy');
eventHub.$on('toggleDeployBoard', (env) => {
expect(env.id).toEqual(mockItem.id);
});
const component = new EnvironmentTable({
el: document.querySelector('.test-dom-element'),
......@@ -93,16 +90,9 @@ describe('Environment item', () => {
environments: [mockItem],
canCreateDeployment: true,
canReadEnvironment: true,
toggleDeployBoard: spy,
store: {},
service: {
getDeployBoard: () => Promise.resolve(),
},
},
}).$mount();
component.$el.querySelector('.deploy-board-icon').click();
expect(spy).toHaveBeenCalled();
});
});
......@@ -33,6 +33,8 @@ describe('Store', () => {
hasDeployBoard: true,
isDeployBoardVisible: true,
deployBoardData: {},
isLoadingDeployBoard: false,
hasErrorDeployBoard: false,
};
store.storeEnvironments(serverData);
......@@ -60,8 +62,10 @@ describe('Store', () => {
const environment = {
name: 'foo',
size: 1,
id: 1,
rollout_status_path: 'url',
latest: {
id: 1,
rollout_status_path: 'url',
},
};
store.storeEnvironments([environment]);
......@@ -74,7 +78,9 @@ describe('Store', () => {
const environment = {
name: 'bar',
size: 3,
id: 2,
latest: {
id: 2,
},
};
store.storeEnvironments([environment]);
......@@ -119,6 +125,16 @@ describe('Store', () => {
store.toggleFolder(store.state.environments[1]);
expect(store.state.environments[1].isOpen).toEqual(false);
});
it('should keep folder open when environments are updated', () => {
store.storeEnvironments(serverData);
store.toggleFolder(store.state.environments[1]);
expect(store.state.environments[1].isOpen).toEqual(true);
store.storeEnvironments(serverData);
expect(store.state.environments[1].isOpen).toEqual(true);
});
});
describe('setfolderContent', () => {
......@@ -130,6 +146,17 @@ describe('Store', () => {
expect(store.state.environments[1].children.length).toEqual(serverData.length);
expect(store.state.environments[1].children[0].isChildren).toEqual(true);
});
it('should keep folder content when environments are updated', () => {
store.storeEnvironments(serverData);
store.setfolderContent(store.state.environments[1], serverData);
expect(store.state.environments[1].children.length).toEqual(serverData.length);
// poll
store.storeEnvironments(serverData);
expect(store.state.environments[1].children.length).toEqual(serverData.length);
});
});
describe('store pagination', () => {
......@@ -162,7 +189,10 @@ describe('Store', () => {
const environment = {
name: 'foo',
size: 1,
id: 1,
latest: {
id: 1,
},
rollout_status_path: 'path',
};
store.storeEnvironments([environment]);
......@@ -170,13 +200,30 @@ describe('Store', () => {
it('should toggle deploy board property for given environment id', () => {
store.toggleDeployBoard(1);
expect(store.state.environments[0].isDeployBoardVisible).toEqual(true);
expect(store.state.environments[0].isDeployBoardVisible).toEqual(false);
});
it('should store deploy board data for given environment id', () => {
store.storeDeployBoard(1, deployBoardMockData);
expect(store.state.environments[0].deployBoardData).toEqual(deployBoardMockData);
});
it('should keep deploy board data when updating environments', () => {
store.storeDeployBoard(1, deployBoardMockData);
expect(store.state.environments[0].deployBoardData).toEqual(deployBoardMockData);
const environment = {
name: 'foo',
size: 1,
latest: {
id: 1,
},
rollout_status_path: 'path',
};
store.storeEnvironments([environment]);
expect(store.state.environments[0].deployBoardData).toEqual(deployBoardMockData);
});
});
describe('getOpenFolders', () => {
......@@ -187,4 +234,22 @@ describe('Store', () => {
expect(store.getOpenFolders()[0]).toEqual(store.state.environments[1]);
});
});
describe('getOpenDeployBoards', () => {
it('should return open deploy boards', () => {
const environment = {
name: 'foo',
size: 1,
latest: {
id: 1,
},
rollout_status_path: 'path',
};
store.storeEnvironments([environment]);
expect(store.getOpenDeployBoards().length).toEqual(1);
expect(store.getOpenDeployBoards()[0].id).toEqual(environment.latest.id);
});
});
});
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