Merge branch 'generalize-js-toggle-buttons' into 'master'
Generalize toggle_buttons for JavaScript usage See merge request gitlab-org/gitlab-ce!16689
This commit is contained in:
commit
dc325c672e
12 changed files with 225 additions and 155 deletions
|
@ -14,6 +14,7 @@ import {
|
|||
import ClustersService from './services/clusters_service';
|
||||
import ClustersStore from './stores/clusters_store';
|
||||
import applications from './components/applications.vue';
|
||||
import setupToggleButtons from '../toggle_buttons';
|
||||
|
||||
/**
|
||||
* Cluster page has 2 separate parts:
|
||||
|
@ -48,12 +49,9 @@ export default class Clusters {
|
|||
installPrometheusEndpoint: installPrometheusPath,
|
||||
});
|
||||
|
||||
this.toggle = this.toggle.bind(this);
|
||||
this.installApplication = this.installApplication.bind(this);
|
||||
this.showToken = this.showToken.bind(this);
|
||||
|
||||
this.toggleButton = document.querySelector('.js-toggle-cluster');
|
||||
this.toggleInput = document.querySelector('.js-toggle-input');
|
||||
this.errorContainer = document.querySelector('.js-cluster-error');
|
||||
this.successContainer = document.querySelector('.js-cluster-success');
|
||||
this.creatingContainer = document.querySelector('.js-cluster-creating');
|
||||
|
@ -63,6 +61,7 @@ export default class Clusters {
|
|||
this.tokenField = document.querySelector('.js-cluster-token');
|
||||
|
||||
initSettingsPanels();
|
||||
setupToggleButtons(document.querySelector('.js-cluster-enable-toggle-area'));
|
||||
this.initApplications();
|
||||
|
||||
if (this.store.state.status !== 'created') {
|
||||
|
@ -101,13 +100,11 @@ export default class Clusters {
|
|||
}
|
||||
|
||||
addListeners() {
|
||||
this.toggleButton.addEventListener('click', this.toggle);
|
||||
if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken);
|
||||
eventHub.$on('installApplication', this.installApplication);
|
||||
}
|
||||
|
||||
removeListeners() {
|
||||
this.toggleButton.removeEventListener('click', this.toggle);
|
||||
if (this.showTokenButton) this.showTokenButton.removeEventListener('click', this.showToken);
|
||||
eventHub.$off('installApplication', this.installApplication);
|
||||
}
|
||||
|
@ -151,11 +148,6 @@ export default class Clusters {
|
|||
this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.toggleButton.classList.toggle('is-checked');
|
||||
this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('is-checked').toString());
|
||||
}
|
||||
|
||||
showToken() {
|
||||
const type = this.tokenField.getAttribute('type');
|
||||
|
||||
|
|
|
@ -1,58 +1,20 @@
|
|||
import Flash from '../flash';
|
||||
import { s__ } from '../locale';
|
||||
import setupToggleButtons from '../toggle_buttons';
|
||||
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);
|
||||
|
||||
export default () => {
|
||||
const clusterList = document.querySelector('.js-clusters-list');
|
||||
// The empty state won't have a clusterList
|
||||
if (clusterList) {
|
||||
setupToggleButtons(
|
||||
document.querySelector('.js-clusters-list'),
|
||||
(value, toggle) =>
|
||||
ClustersService.updateCluster(toggle.dataset.endpoint, { cluster: { enabled: value } })
|
||||
.catch((err) => {
|
||||
Flash(s__('ClusterIntegration|Something went wrong on our end.'));
|
||||
throw err;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* When the user clicks the toggle button for each cluster, it:
|
||||
* - toggles the button
|
||||
* - shows a loading and disables button
|
||||
* - Makes a put request to the given endpoint
|
||||
* Once we receive the response, either:
|
||||
* 1) Show updated status in case of successfull response
|
||||
* 2) Show initial status in case of failed response
|
||||
*/
|
||||
export default function setClusterTableToggles() {
|
||||
document.querySelectorAll('.js-toggle-cluster-list')
|
||||
.forEach(button => button.addEventListener('click', (e) => {
|
||||
const toggleButton = e.currentTarget;
|
||||
const endpoint = toggleButton.getAttribute('data-endpoint');
|
||||
|
||||
toggleValue(toggleButton);
|
||||
toggleLoadingButton(toggleButton);
|
||||
|
||||
const value = toggleButton.classList.contains('is-checked');
|
||||
|
||||
ClustersService.updateCluster(endpoint, { cluster: { enabled: value } })
|
||||
.then(() => {
|
||||
toggleLoadingButton(toggleButton);
|
||||
})
|
||||
.catch(() => {
|
||||
toggleLoadingButton(toggleButton);
|
||||
toggleValue(toggleButton);
|
||||
Flash(s__('ClusterIntegration|Something went wrong on our end.'));
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
|
61
app/assets/javascripts/toggle_buttons.js
Normal file
61
app/assets/javascripts/toggle_buttons.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
import $ from 'jquery';
|
||||
import Flash from './flash';
|
||||
import { __ } from './locale';
|
||||
import { convertPermissionToBoolean } from './lib/utils/common_utils';
|
||||
|
||||
/*
|
||||
example HAML:
|
||||
```
|
||||
%button.js-project-feature-toggle.project-feature-toggle{ type: "button",
|
||||
class: "#{'is-checked' if enabled?}",
|
||||
'aria-label': _('Toggle Cluster') }
|
||||
%input{ type: "hidden", class: 'js-project-feature-toggle-input', value: enabled? }
|
||||
```
|
||||
*/
|
||||
|
||||
function updatetoggle(toggle, isOn) {
|
||||
toggle.classList.toggle('is-checked', isOn);
|
||||
}
|
||||
|
||||
function onToggleClicked(toggle, input, clickCallback) {
|
||||
const previousIsOn = convertPermissionToBoolean(input.value);
|
||||
|
||||
// Visually change the toggle and start loading
|
||||
updatetoggle(toggle, !previousIsOn);
|
||||
toggle.setAttribute('disabled', true);
|
||||
toggle.classList.toggle('is-loading', true);
|
||||
|
||||
Promise.resolve(clickCallback(!previousIsOn, toggle))
|
||||
.then(() => {
|
||||
// Actually change the input value
|
||||
input.setAttribute('value', !previousIsOn);
|
||||
})
|
||||
.catch(() => {
|
||||
// Revert the visuals if something goes wrong
|
||||
updatetoggle(toggle, previousIsOn);
|
||||
})
|
||||
.then(() => {
|
||||
// Remove the loading indicator in any case
|
||||
toggle.removeAttribute('disabled');
|
||||
toggle.classList.toggle('is-loading', false);
|
||||
|
||||
$(input).trigger('trigger-change');
|
||||
})
|
||||
.catch(() => {
|
||||
Flash(__('Something went wrong when toggling the button'));
|
||||
});
|
||||
}
|
||||
|
||||
export default function setupToggleButtons(container, clickCallback = () => {}) {
|
||||
const toggles = container.querySelectorAll('.js-project-feature-toggle');
|
||||
|
||||
toggles.forEach((toggle) => {
|
||||
const input = toggle.querySelector('.js-project-feature-toggle-input');
|
||||
const isOn = convertPermissionToBoolean(input.value);
|
||||
|
||||
// Get the visible toggle in sync with the hidden input
|
||||
updatetoggle(toggle, isOn);
|
||||
|
||||
toggle.addEventListener('click', onToggleClicked.bind(null, toggle, input, clickCallback));
|
||||
});
|
||||
}
|
|
@ -12,11 +12,12 @@
|
|||
.table-section.section-10
|
||||
.table-mobile-header{ role: "rowheader" }
|
||||
.table-mobile-content
|
||||
%button{ type: "button",
|
||||
class: "js-toggle-cluster-list project-feature-toggle #{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}",
|
||||
%button.js-project-feature-toggle.project-feature-toggle{ type: "button",
|
||||
class: "#{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}",
|
||||
"aria-label": s_("ClusterIntegration|Toggle Cluster"),
|
||||
disabled: !cluster.can_toggle_cluster?,
|
||||
data: { endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } }
|
||||
%input.js-project-feature-toggle-input{ type: "hidden", value: cluster.enabled? }
|
||||
= icon("spinner spin", class: "loading-icon")
|
||||
%span.toggle-icon
|
||||
= sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
|
||||
|
|
|
@ -10,13 +10,12 @@
|
|||
= s_('ClusterIntegration|Cluster integration is enabled for this project.')
|
||||
- else
|
||||
= s_('ClusterIntegration|Cluster integration is disabled for this project.')
|
||||
%label.append-bottom-10
|
||||
= field.hidden_field :enabled, { class: 'js-toggle-input'}
|
||||
|
||||
%label.append-bottom-10.js-cluster-enable-toggle-area
|
||||
%button{ type: 'button',
|
||||
class: "js-toggle-cluster project-feature-toggle #{'is-checked' unless !@cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
|
||||
class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if @cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
|
||||
"aria-label": s_("ClusterIntegration|Toggle Cluster"),
|
||||
disabled: !can?(current_user, :update_cluster, @cluster) }
|
||||
= field.hidden_field :enabled, { class: 'js-project-feature-toggle-input'}
|
||||
%span.toggle-icon
|
||||
= sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
|
||||
= sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
|
||||
|
|
|
@ -95,7 +95,7 @@ feature 'Gcp Cluster', :js do
|
|||
|
||||
context 'when user disables the cluster' do
|
||||
before do
|
||||
page.find(:css, '.js-toggle-cluster').click
|
||||
page.find(:css, '.js-cluster-enable-toggle-area .js-project-feature-toggle').click
|
||||
page.within('#cluster-integration') { click_button 'Save changes' }
|
||||
end
|
||||
|
||||
|
|
|
@ -62,7 +62,7 @@ feature 'User Cluster', :js do
|
|||
|
||||
context 'when user disables the cluster' do
|
||||
before do
|
||||
page.find(:css, '.js-toggle-cluster').click
|
||||
page.find(:css, '.js-cluster-enable-toggle-area .js-project-feature-toggle').click
|
||||
fill_in 'cluster_name', with: 'dev-cluster'
|
||||
page.within('#cluster-integration') { click_button 'Save changes' }
|
||||
end
|
||||
|
|
|
@ -37,13 +37,13 @@ feature 'Clusters', :js do
|
|||
|
||||
context 'inline update of cluster' do
|
||||
it 'user can update cluster' do
|
||||
expect(page).to have_selector('.js-toggle-cluster-list')
|
||||
expect(page).to have_selector('.js-project-feature-toggle')
|
||||
end
|
||||
|
||||
context 'with sucessfull request' do
|
||||
it 'user sees updated cluster' do
|
||||
expect do
|
||||
page.find('.js-toggle-cluster-list').click
|
||||
page.find('.js-project-feature-toggle').click
|
||||
wait_for_requests
|
||||
end.to change { cluster.reload.enabled }
|
||||
|
||||
|
@ -57,7 +57,7 @@ feature 'Clusters', :js do
|
|||
expect_any_instance_of(Clusters::UpdateService).to receive(:execute).and_call_original
|
||||
allow_any_instance_of(Clusters::Cluster).to receive(:valid?) { false }
|
||||
|
||||
page.find('.js-toggle-cluster-list').click
|
||||
page.find('.js-project-feature-toggle').click
|
||||
|
||||
expect(page).to have_content('Something went wrong on our end.')
|
||||
expect(page).to have_selector('.is-checked')
|
||||
|
|
|
@ -23,16 +23,24 @@ describe('Clusters', () => {
|
|||
});
|
||||
|
||||
describe('toggle', () => {
|
||||
it('should update the button and the input field on click', () => {
|
||||
cluster.toggleButton.click();
|
||||
it('should update the button and the input field on click', (done) => {
|
||||
const toggleButton = document.querySelector('.js-cluster-enable-toggle-area .js-project-feature-toggle');
|
||||
const toggleInput = document.querySelector('.js-cluster-enable-toggle-area .js-project-feature-toggle-input');
|
||||
|
||||
expect(
|
||||
cluster.toggleButton.classList,
|
||||
).not.toContain('is-checked');
|
||||
toggleButton.click();
|
||||
|
||||
expect(
|
||||
cluster.toggleInput.getAttribute('value'),
|
||||
).toEqual('false');
|
||||
getSetTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(
|
||||
toggleButton.classList,
|
||||
).not.toContain('is-checked');
|
||||
|
||||
expect(
|
||||
toggleInput.getAttribute('value'),
|
||||
).toEqual('false');
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -1,58 +0,0 @@
|
|||
import MockAdapter from 'axios-mock-adapter';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import setClusterTableToggles from '~/clusters/clusters_index';
|
||||
import { setTimeout } from 'core-js/library/web/timers';
|
||||
|
||||
describe('Clusters table', () => {
|
||||
preloadFixtures('clusters/index_cluster.html.raw');
|
||||
let mock;
|
||||
|
||||
beforeEach(() => {
|
||||
loadFixtures('clusters/index_cluster.html.raw');
|
||||
mock = new MockAdapter(axios);
|
||||
setClusterTableToggles();
|
||||
});
|
||||
|
||||
describe('update cluster', () => {
|
||||
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.getAttribute('disabled')).toEqual('true');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
it('shows updated state after sucessfull request', (done) => {
|
||||
mock.onPut().reply(200, {}, {});
|
||||
const button = document.querySelector('.js-toggle-cluster-list');
|
||||
button.click();
|
||||
|
||||
expect(button.classList).toContain('is-loading');
|
||||
|
||||
setTimeout(() => {
|
||||
expect(button.classList).not.toContain('is-loading');
|
||||
expect(button.classList).not.toContain('is-checked');
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
|
||||
it('shows inital state after failed request', (done) => {
|
||||
mock.onPut().reply(500, {}, {});
|
||||
const button = document.querySelector('.js-toggle-cluster-list');
|
||||
|
||||
button.click();
|
||||
expect(button.classList).toContain('is-loading');
|
||||
|
||||
setTimeout(() => {
|
||||
expect(button.classList).not.toContain('is-loading');
|
||||
expect(button.classList).toContain('is-checked');
|
||||
done();
|
||||
}, 0);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -31,19 +31,4 @@ describe Projects::ClustersController, '(JavaScript fixtures)', type: :controlle
|
|||
expect(response).to be_success
|
||||
store_frontend_fixture(response, example.description)
|
||||
end
|
||||
|
||||
context 'rendering non-empty state' do
|
||||
before do
|
||||
cluster
|
||||
end
|
||||
|
||||
it 'clusters/index_cluster.html.raw' do |example|
|
||||
get :index,
|
||||
namespace_id: namespace,
|
||||
project_id: project
|
||||
|
||||
expect(response).to be_success
|
||||
store_frontend_fixture(response, example.description)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
120
spec/javascripts/toggle_buttons_spec.js
Normal file
120
spec/javascripts/toggle_buttons_spec.js
Normal file
|
@ -0,0 +1,120 @@
|
|||
import setupToggleButtons from '~/toggle_buttons';
|
||||
import getSetTimeoutPromise from './helpers/set_timeout_promise_helper';
|
||||
|
||||
function generateMarkup(isChecked = true) {
|
||||
return `
|
||||
<button type="button" class="${isChecked ? 'is-checked' : ''} js-project-feature-toggle">
|
||||
<input type="hidden" class="js-project-feature-toggle-input" value="${isChecked}" />
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
function setupFixture(isChecked, clickCallback) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = generateMarkup(isChecked);
|
||||
|
||||
setupToggleButtons(wrapper, clickCallback);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe('ToggleButtons', () => {
|
||||
describe('when input value is true', () => {
|
||||
it('should initialize as checked', () => {
|
||||
const wrapper = setupFixture(true);
|
||||
|
||||
expect(wrapper.querySelector('.js-project-feature-toggle').classList.contains('is-checked')).toEqual(true);
|
||||
expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('true');
|
||||
});
|
||||
|
||||
it('should toggle to unchecked when clicked', (done) => {
|
||||
const wrapper = setupFixture(true);
|
||||
const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
|
||||
|
||||
toggleButton.click();
|
||||
|
||||
getSetTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(toggleButton.classList.contains('is-checked')).toEqual(false);
|
||||
expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('false');
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when input value is false', () => {
|
||||
it('should initialize as unchecked', () => {
|
||||
const wrapper = setupFixture(false);
|
||||
|
||||
expect(wrapper.querySelector('.js-project-feature-toggle').classList.contains('is-checked')).toEqual(false);
|
||||
expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('false');
|
||||
});
|
||||
|
||||
it('should toggle to checked when clicked', (done) => {
|
||||
const wrapper = setupFixture(false);
|
||||
const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
|
||||
|
||||
toggleButton.click();
|
||||
|
||||
getSetTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(toggleButton.classList.contains('is-checked')).toEqual(true);
|
||||
expect(wrapper.querySelector('.js-project-feature-toggle-input').value).toEqual('true');
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
});
|
||||
|
||||
it('should emit `trigger-change` event', (done) => {
|
||||
const changeSpy = jasmine.createSpy('changeEventHandler');
|
||||
const wrapper = setupFixture(false);
|
||||
const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
|
||||
const input = wrapper.querySelector('.js-project-feature-toggle-input');
|
||||
|
||||
$(input).on('trigger-change', changeSpy);
|
||||
|
||||
toggleButton.click();
|
||||
|
||||
getSetTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(changeSpy).toHaveBeenCalled();
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
});
|
||||
|
||||
describe('clickCallback', () => {
|
||||
it('should show loading indicator while waiting', (done) => {
|
||||
const isChecked = true;
|
||||
const clickCallback = (newValue, toggleButton) => {
|
||||
const input = toggleButton.querySelector('.js-project-feature-toggle-input');
|
||||
|
||||
expect(newValue).toEqual(false);
|
||||
|
||||
// Check for the loading state
|
||||
expect(toggleButton.classList.contains('is-checked')).toEqual(false);
|
||||
expect(toggleButton.classList.contains('is-loading')).toEqual(true);
|
||||
expect(toggleButton.disabled).toEqual(true);
|
||||
expect(input.value).toEqual('true');
|
||||
|
||||
// After the callback finishes, check that the loading state is gone
|
||||
getSetTimeoutPromise()
|
||||
.then(() => {
|
||||
expect(toggleButton.classList.contains('is-checked')).toEqual(false);
|
||||
expect(toggleButton.classList.contains('is-loading')).toEqual(false);
|
||||
expect(toggleButton.disabled).toEqual(false);
|
||||
expect(input.value).toEqual('false');
|
||||
})
|
||||
.then(done)
|
||||
.catch(done.fail);
|
||||
};
|
||||
|
||||
const wrapper = setupFixture(isChecked, clickCallback);
|
||||
const toggleButton = wrapper.querySelector('.js-project-feature-toggle');
|
||||
|
||||
toggleButton.click();
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue