Commit 18967d68 authored by Filipa Lacerda's avatar Filipa Lacerda

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
parent 8796e727
......@@ -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');
ClusterTable.toggleValue(toggleButton);
ClusterTable.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.'));
});
}
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');
/**
* Toggles loading and disabled classes.
* @param {HTMLElement} button
*/
static toggleLoadingButton(button) {
if (button.getAttribute('disabled')) {
button.removeAttribute('disabled');
} else {
button.setAttribute('disabled', true);
}
toggleValue(toggleButton);
toggleLoadingButton(toggleButton);
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;
......
<script>
import projectFeatureToggle from './project_feature_toggle.vue';
import projectFeatureToggle from '../../../vue_shared/components/toggle_button.vue';
export default {
props: {
......
<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>
<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';
......
<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">
* ### 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 text
* <button type="button" class="project-feature-toggle" data-enabled-text="Enabled" data-disabled-text="Disabled">
* ### 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>
* ### Disabled button
* <button type="button" class="project-feature-toggle disabled" data-enabled-text="Enabled" data-disabled-text="Disabled" disabled="true">
* ### 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
* ### 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,37 +69,36 @@
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;
}
}
&.checked {
background: $feature-toggle-color-enabled;
display: block;
&.is-loading {
&::before {
left: 10px;
right: 42px;
animation: animate-enabled .2s ease-in;
content: attr(data-enabled-text);
}
.loading-icon {
left: 60px;
top: 6px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
}
&.is-checked {
background: $feature-toggle-color-enabled;
&::before {
left: 5px;
......@@ -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
......
.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"
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);
});
......
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);
});
});
});
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