Changes after Frontend and UX review:
- Moves toggle button to a shared location - Adds tests for toggle button - Transforms Clusters class into function - Improves UX
This commit is contained in:
parent
8796e7278e
commit
18967d6894
|
@ -2,6 +2,29 @@ import Flash from '../flash';
|
|||
import { s__ } from '../locale';
|
||||
import ClustersService from './services/clusters_service';
|
||||
|
||||
/**
|
||||
* Toggles loading and disabled classes.
|
||||
* @param {HTMLElement} button
|
||||
*/
|
||||
const toggleLoadingButton = (button) => {
|
||||
if (button.getAttribute('disabled')) {
|
||||
button.removeAttribute('disabled');
|
||||
} else {
|
||||
button.setAttribute('disabled', true);
|
||||
}
|
||||
|
||||
button.classList.toggle('is-disabled');
|
||||
button.classList.toggle('is-loading');
|
||||
};
|
||||
|
||||
/**
|
||||
* Toggles checked class for the given button
|
||||
* @param {HTMLElement} button
|
||||
*/
|
||||
const toggleValue = (button) => {
|
||||
button.classList.toggle('is-checked');
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles toggle buttons in the cluster's table.
|
||||
*
|
||||
|
@ -13,56 +36,24 @@ import ClustersService from './services/clusters_service';
|
|||
* 1) Show updated status in case of successfull response
|
||||
* 2) Show initial status in case of failed response
|
||||
*/
|
||||
export default class ClusterTable {
|
||||
constructor() {
|
||||
this.container = '.js-clusters-list';
|
||||
document.querySelectorAll(`${this.container} .js-toggle-cluster-list`).forEach(button => button.addEventListener('click', e => ClusterTable.updateCluster(e)));
|
||||
}
|
||||
/**
|
||||
* When the toggle button is clicked,
|
||||
* updates the status and makes a request to the API to update the cluster
|
||||
* @param {Event} e
|
||||
*/
|
||||
static updateCluster(e) {
|
||||
const toggleButton = e.currentTarget;
|
||||
const value = toggleButton.classList.contains('checked').toString();
|
||||
const endpoint = toggleButton.getAttribute('data-endpoint');
|
||||
export default function setClusterTableToggles() {
|
||||
document.querySelectorAll('.js-toggle-cluster-list')
|
||||
.forEach(button => button.addEventListener('click', (e) => {
|
||||
const toggleButton = e.currentTarget;
|
||||
const value = toggleButton.classList.contains('checked').toString();
|
||||
const endpoint = toggleButton.getAttribute('data-endpoint');
|
||||
|
||||
ClusterTable.toggleValue(toggleButton);
|
||||
ClusterTable.toggleLoadingButton(toggleButton);
|
||||
toggleValue(toggleButton);
|
||||
toggleLoadingButton(toggleButton);
|
||||
|
||||
ClustersService.updateCluster(endpoint, { cluster: { enabled: value } })
|
||||
.then(() => {
|
||||
ClusterTable.toggleLoadingButton(toggleButton);
|
||||
})
|
||||
.catch(() => {
|
||||
ClusterTable.toggleLoadingButton(toggleButton);
|
||||
ClusterTable.toggleValue(toggleButton);
|
||||
Flash(s__('ClusterIntegration|Something went wrong on our end.'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles loading and disabled classes.
|
||||
* @param {HTMLElement} button
|
||||
*/
|
||||
static toggleLoadingButton(button) {
|
||||
if (button.getAttribute('disabled')) {
|
||||
button.removeAttribute('disabled');
|
||||
} else {
|
||||
button.setAttribute('disabled', true);
|
||||
}
|
||||
|
||||
button.classList.toggle('disabled');
|
||||
button.classList.toggle('is-loading');
|
||||
button.querySelector('.loading-icon').classList.toggle('hidden');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles checked class for the given button
|
||||
* @param {HTMLElement} button
|
||||
*/
|
||||
static toggleValue(button) {
|
||||
button.classList.toggle('checked');
|
||||
}
|
||||
ClustersService.updateCluster(endpoint, { cluster: { enabled: value } })
|
||||
.then(() => {
|
||||
toggleLoadingButton(toggleButton);
|
||||
})
|
||||
.catch(() => {
|
||||
toggleLoadingButton(toggleButton);
|
||||
toggleValue(toggleButton);
|
||||
Flash(s__('ClusterIntegration|Something went wrong on our end.'));
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
|
|
@ -556,7 +556,7 @@ import ProjectVariables from './project_variables';
|
|||
break;
|
||||
case 'projects:clusters:index':
|
||||
import(/* webpackChunkName: "clusters_index" */ './clusters/clusters_index')
|
||||
.then(clusterIndex => new clusterIndex.default()) // eslint-disable-line new-cap
|
||||
.then(clusterIndex => clusterIndex.default())
|
||||
.catch((err) => {
|
||||
Flash(s__('ClusterIntegration|Problem setting up the clusters list'));
|
||||
throw err;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import projectFeatureToggle from './project_feature_toggle.vue';
|
||||
import projectFeatureToggle from '../../../vue_shared/components/toggle_button.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
|
|
|
@ -1,51 +0,0 @@
|
|||
<script>
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
disabledInput: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
model: {
|
||||
prop: 'value',
|
||||
event: 'change',
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleFeature() {
|
||||
if (!this.disabledInput) this.$emit('change', !this.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="toggle-wrapper">
|
||||
<input
|
||||
v-if="name"
|
||||
type="hidden"
|
||||
:name="name"
|
||||
:value="value"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle"
|
||||
class="project-feature-toggle"
|
||||
data-enabled-text="Enabled"
|
||||
data-disabled-text="Disabled"
|
||||
:class="{ checked: value, disabled: disabledInput }"
|
||||
@click="toggleFeature"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import projectFeatureSetting from './project_feature_setting.vue';
|
||||
import projectFeatureToggle from './project_feature_toggle.vue';
|
||||
import projectFeatureToggle from '../../../vue_shared/components/toggle_button.vue';
|
||||
import projectSettingRow from './project_setting_row.vue';
|
||||
import { visibilityOptions, visibilityLevelDescriptions } from '../constants';
|
||||
import { toggleHiddenClassBySelector } from '../external';
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
<script>
|
||||
import loadingIcon from './loading_icon.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
value: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
disabledInput: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false,
|
||||
},
|
||||
enabledText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'Enabled',
|
||||
},
|
||||
disabledText: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'Disabled',
|
||||
},
|
||||
},
|
||||
|
||||
components: {
|
||||
loadingIcon,
|
||||
},
|
||||
|
||||
model: {
|
||||
prop: 'value',
|
||||
event: 'change',
|
||||
},
|
||||
|
||||
methods: {
|
||||
toggleFeature() {
|
||||
if (!this.disabledInput) this.$emit('change', !this.value);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="toggle-wrapper">
|
||||
<input
|
||||
type="hidden"
|
||||
:name="name"
|
||||
:value="value"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Toggle"
|
||||
class="project-feature-toggle"
|
||||
:data-enabled-text="enabledText"
|
||||
:data-disabled-text="disabledText"
|
||||
:class="{
|
||||
'is-checked': value,
|
||||
'is-disabled': disabledInput,
|
||||
'is-loading': isLoading
|
||||
}"
|
||||
@click="toggleFeature"
|
||||
>
|
||||
<loadingIcon class="loading-icon" />
|
||||
</button>
|
||||
</label>
|
||||
</template>
|
|
@ -2,22 +2,22 @@
|
|||
* Toggle button
|
||||
*
|
||||
* @usage
|
||||
* ### Active text
|
||||
* <button type="button" class="project-feature-toggle checked" data-enabled-text="Enabled" data-disabled-text="Disabled">
|
||||
* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
|
||||
* </button>
|
||||
|
||||
* ### Disabled text
|
||||
* ### Active and Inactive text should be provided as data attributes:
|
||||
* <button type="button" class="project-feature-toggle" data-enabled-text="Enabled" data-disabled-text="Disabled">
|
||||
* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
|
||||
* </button>
|
||||
|
||||
* ### Disabled button
|
||||
* <button type="button" class="project-feature-toggle disabled" data-enabled-text="Enabled" data-disabled-text="Disabled" disabled="true">
|
||||
* ### Checked should have `is-checked` class
|
||||
* <button type="button" class="project-feature-toggle is-checked" data-enabled-text="Enabled" data-disabled-text="Disabled">
|
||||
* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
|
||||
* </button>
|
||||
|
||||
* ### Loading
|
||||
* ### Disabled should have `is-disabled` class
|
||||
* <button type="button" class="project-feature-toggle is-disabled" data-enabled-text="Enabled" data-disabled-text="Disabled" disabled="true">
|
||||
* <i class="fa fa-spinner fa-spin loading-icon hidden"></i>
|
||||
* </button>
|
||||
|
||||
* ### Loading should have `is-loading` and an icon with `loading-icon` class
|
||||
* <button type="button" class="project-feature-toggle is-loading" data-enabled-text="Enabled" data-disabled-text="Disabled">
|
||||
* <i class="fa fa-spinner fa-spin loading-icon"></i>
|
||||
* </button>
|
||||
|
@ -69,38 +69,37 @@
|
|||
transition: all .2s ease;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
display: none;
|
||||
font-size: 12px;
|
||||
color: $white-light;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
&::before {
|
||||
left: 38px;
|
||||
right: 5px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
position: absolute;
|
||||
left: 28px;
|
||||
font-size: $tooltip-font-size;
|
||||
color: $white-light;
|
||||
top: 6px;
|
||||
display: block;
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.checked {
|
||||
&.is-checked {
|
||||
background: $feature-toggle-color-enabled;
|
||||
|
||||
&.is-loading {
|
||||
&::before {
|
||||
left: 10px;
|
||||
right: 42px;
|
||||
animation: animate-enabled .2s ease-in;
|
||||
content: attr(data-enabled-text);
|
||||
}
|
||||
|
||||
.loading-icon {
|
||||
left: 60px;
|
||||
top: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 5px;
|
||||
right: 25px;
|
||||
|
@ -113,7 +112,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
&.is-disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
@ -122,7 +121,7 @@
|
|||
width: 50px;
|
||||
|
||||
&::before,
|
||||
&.checked::before {
|
||||
&.is-checked::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,4 +13,8 @@
|
|||
.nav-bar-right {
|
||||
padding: $gl-padding-top $gl-padding;
|
||||
}
|
||||
|
||||
.empty-state .svg-content img {
|
||||
width: 145px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -102,7 +102,8 @@ class Projects::ClustersController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def clusters
|
||||
scope = params[:scope]&.to_sym || :all
|
||||
@scope = params[:scope] || 'all'
|
||||
scope = @scope&.to_sym
|
||||
@clusters = ClustersFinder.new(project, current_user, scope).execute
|
||||
end
|
||||
|
||||
|
|
|
@ -1,22 +1,22 @@
|
|||
.gl-responsive-table-row
|
||||
.table-section.section-30
|
||||
.table-mobile-header{ role: 'rowheader' }= s_('ClusterIntegration|Cluster')
|
||||
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Cluster")
|
||||
.table-mobile-content
|
||||
= link_to cluster.name, namespace_project_cluster_path(@project.namespace, @project, cluster)
|
||||
.table-section.section-30
|
||||
.table-mobile-header{ role: 'rowheader' }= s_('ClusterIntegration|Environment pattern')
|
||||
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Environment pattern")
|
||||
.table-mobile-content= cluster.environment_scope
|
||||
.table-section.section-30
|
||||
.table-mobile-header{ role: 'rowheader' }= s_('ClusterIntegration|Project namespace')
|
||||
.table-mobile-header{ role: "rowheader" }= s_("ClusterIntegration|Project namespace")
|
||||
.table-mobile-content= cluster.platform_kubernetes&.namespace
|
||||
.table-section.section-10
|
||||
.table-mobile-header{ role: 'rowheader' }
|
||||
.table-mobile-header{ role: "rowheader" }
|
||||
.table-mobile-content
|
||||
%button{ type: 'button',
|
||||
class: "js-toggle-cluster-list project-feature-toggle #{'checked' unless !cluster.enabled?} #{'disabled' unless can?(current_user, :update_cluster, cluster)}",
|
||||
'aria-label': s_('ClusterIntegration|Toggle Cluster'),
|
||||
%button{ type: "button",
|
||||
class: "js-toggle-cluster-list project-feature-toggle #{'is-checked' unless !cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, cluster) && cluster.provider_gcp&.created?}",
|
||||
"aria-label": s_("ClusterIntegration|Toggle Cluster"),
|
||||
disabled: !can?(current_user, :update_cluster, cluster),
|
||||
data: { 'enabled-text': 'Enabled',
|
||||
'disabled-text': 'Disabled',
|
||||
data: { "enabled-text": s_("ClusterIntegration|Active"),
|
||||
"disabled-text": s_("ClusterIntegration|Inactive"),
|
||||
endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } }
|
||||
= icon('spinner spin', class: 'hidden loading-icon')
|
||||
= icon("spinner spin", class: "loading-icon")
|
||||
|
|
|
@ -8,5 +8,5 @@
|
|||
%p= s_('ClusterIntegration|Clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way. %{link_to_help_page}').html_safe % { link_to_help_page: link_to_help_page}
|
||||
|
||||
%p
|
||||
= link_to s_('ClusterIntegration|Add cluster'), new_project_cluster_path(@project), class: 'btn btn-success', title: 'Add cluster'
|
||||
= link_to s_('ClusterIntegration|Add cluster'), new_project_cluster_path(@project), class: 'btn btn-success'
|
||||
|
||||
|
|
|
@ -2,8 +2,12 @@
|
|||
- page_title "Clusters"
|
||||
|
||||
.clusters-container
|
||||
- if @clusters.empty?
|
||||
- if @clusters.empty? && @scope == 'all'
|
||||
= render "empty_state"
|
||||
- elsif @clusters.empty? && @scope != 'all'
|
||||
= render "tabs"
|
||||
.prepend-top-20.text-center
|
||||
= s_("ClusterIntegration|There are no clusters to show")
|
||||
- else
|
||||
= render "tabs"
|
||||
.ci-table.js-clusters-list
|
||||
|
@ -17,4 +21,4 @@
|
|||
.table-section.section-10{ role: "rowheader" }
|
||||
- @clusters.each do |cluster|
|
||||
= render "cluster", cluster: cluster
|
||||
= paginate @clusters, theme: 'gitlab'
|
||||
= paginate @clusters, theme: "gitlab"
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import ClusterTable from '~/clusters/clusters_index';
|
||||
import setClusterTableToggles from '~/clusters/clusters_index';
|
||||
import { setTimeout } from 'core-js/library/web/timers';
|
||||
|
||||
describe('Clusters table', () => {
|
||||
|
@ -10,21 +10,17 @@ describe('Clusters table', () => {
|
|||
beforeEach(() => {
|
||||
loadFixtures('clusters/index_cluster.html.raw');
|
||||
mock = new MockAdapter(axios);
|
||||
return new ClusterTable();
|
||||
setClusterTableToggles();
|
||||
});
|
||||
|
||||
describe('update cluster', () => {
|
||||
it('renders a toggle button', () => {
|
||||
expect(document.querySelector('.js-toggle-cluster-list')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders loading state while request is made', () => {
|
||||
const button = document.querySelector('.js-toggle-cluster-list');
|
||||
|
||||
button.click();
|
||||
|
||||
expect(button.classList).toContain('is-loading');
|
||||
expect(button.classList).toContain('disabled');
|
||||
expect(button.classList).toContain('is-disabled');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -40,7 +36,7 @@ describe('Clusters table', () => {
|
|||
|
||||
setTimeout(() => {
|
||||
expect(button.classList).not.toContain('is-loading');
|
||||
expect(button.classList).not.toContain('checked');
|
||||
expect(button.classList).not.toContain('is-checked');
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
|
@ -54,7 +50,7 @@ describe('Clusters table', () => {
|
|||
|
||||
setTimeout(() => {
|
||||
expect(button.classList).not.toContain('is-loading');
|
||||
expect(button.classList).toContain('checked');
|
||||
expect(button.classList).toContain('is-checked');
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
|
|
|
@ -0,0 +1,91 @@
|
|||
import Vue from 'vue';
|
||||
import toggleButton from '~/vue_shared/components/toggle_button.vue';
|
||||
import mountComponent from '../../helpers/vue_mount_component_helper';
|
||||
|
||||
describe('Toggle Button', () => {
|
||||
let vm;
|
||||
let Component;
|
||||
|
||||
beforeEach(() => {
|
||||
Component = Vue.extend(toggleButton);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.$destroy();
|
||||
});
|
||||
|
||||
describe('render output', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(Component, {
|
||||
value: true,
|
||||
name: 'foo',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders input with provided name', () => {
|
||||
expect(vm.$el.querySelector('input').getAttribute('name')).toEqual('foo');
|
||||
});
|
||||
|
||||
it('renders input with provided value', () => {
|
||||
expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true');
|
||||
});
|
||||
|
||||
it('renders Enabled and Disabled text data attributes', () => {
|
||||
expect(vm.$el.querySelector('button').getAttribute('data-enabled-text')).toEqual('Enabled');
|
||||
expect(vm.$el.querySelector('button').getAttribute('data-disabled-text')).toEqual('Disabled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('is-checked', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(Component, {
|
||||
value: true,
|
||||
});
|
||||
|
||||
spyOn(vm, '$emit');
|
||||
});
|
||||
|
||||
it('renders is checked class', () => {
|
||||
expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true);
|
||||
});
|
||||
|
||||
it('emits change event when clicked', () => {
|
||||
vm.$el.querySelector('button').click();
|
||||
|
||||
expect(vm.$emit).toHaveBeenCalledWith('change', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('is-disabled', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(Component, {
|
||||
value: true,
|
||||
disabledInput: true,
|
||||
});
|
||||
spyOn(vm, '$emit');
|
||||
});
|
||||
|
||||
it('renders disabled button', () => {
|
||||
expect(vm.$el.querySelector('button').classList.contains('is-disabled')).toEqual(true);
|
||||
});
|
||||
|
||||
it('does not emit change event when clicked', () => {
|
||||
vm.$el.querySelector('button').click();
|
||||
|
||||
expect(vm.$emit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('is-loading', () => {
|
||||
beforeEach(() => {
|
||||
vm = mountComponent(Component, {
|
||||
value: true,
|
||||
isLoading: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders loading class', () => {
|
||||
expect(vm.$el.querySelector('button').classList.contains('is-loading')).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue