From 737236551fbf5c8d4e44a70d7012ac1331322cd8 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Wed, 24 Jan 2018 15:54:33 -0600 Subject: [PATCH] Generalize toggle_buttons.js Part of https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/4110 --- .../javascripts/clusters/clusters_bundle.js | 12 +- .../javascripts/clusters/clusters_index.js | 68 +++------- app/assets/javascripts/toggle_buttons.js | 61 +++++++++ .../projects/clusters/_cluster.html.haml | 5 +- .../clusters/_integration_form.html.haml | 7 +- spec/features/projects/clusters/gcp_spec.rb | 2 +- spec/features/projects/clusters/user_spec.rb | 2 +- spec/features/projects/clusters_spec.rb | 6 +- .../clusters/clusters_bundle_spec.js | 24 ++-- .../clusters/clusters_index_spec.js | 58 --------- spec/javascripts/fixtures/clusters.rb | 15 --- spec/javascripts/toggle_buttons_spec.js | 120 ++++++++++++++++++ 12 files changed, 225 insertions(+), 155 deletions(-) create mode 100644 app/assets/javascripts/toggle_buttons.js delete mode 100644 spec/javascripts/clusters/clusters_index_spec.js create mode 100644 spec/javascripts/toggle_buttons_spec.js diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js index 637d0dbde23..4dddb6eb0d6 100644 --- a/app/assets/javascripts/clusters/clusters_bundle.js +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -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'); diff --git a/app/assets/javascripts/clusters/clusters_index.js b/app/assets/javascripts/clusters/clusters_index.js index 6844d1dbd83..2e3ad244375 100644 --- a/app/assets/javascripts/clusters/clusters_index.js +++ b/app/assets/javascripts/clusters/clusters_index.js @@ -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.')); - }); - })); -} diff --git a/app/assets/javascripts/toggle_buttons.js b/app/assets/javascripts/toggle_buttons.js new file mode 100644 index 00000000000..974dc3ee052 --- /dev/null +++ b/app/assets/javascripts/toggle_buttons.js @@ -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)); + }); +} diff --git a/app/views/projects/clusters/_cluster.html.haml b/app/views/projects/clusters/_cluster.html.haml index 3943dfc0856..20ee8086f93 100644 --- a/app/views/projects/clusters/_cluster.html.haml +++ b/app/views/projects/clusters/_cluster.html.haml @@ -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') diff --git a/app/views/projects/clusters/_integration_form.html.haml b/app/views/projects/clusters/_integration_form.html.haml index 9d593ffc021..0af6e6e0577 100644 --- a/app/views/projects/clusters/_integration_form.html.haml +++ b/app/views/projects/clusters/_integration_form.html.haml @@ -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') diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index 8953b30bebf..94bde723e2f 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -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 diff --git a/spec/features/projects/clusters/user_spec.rb b/spec/features/projects/clusters/user_spec.rb index a519b9f9c7e..b9ab434c259 100644 --- a/spec/features/projects/clusters/user_spec.rb +++ b/spec/features/projects/clusters/user_spec.rb @@ -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 diff --git a/spec/features/projects/clusters_spec.rb b/spec/features/projects/clusters_spec.rb index eae2910a8f6..497a50bebe4 100644 --- a/spec/features/projects/clusters_spec.rb +++ b/spec/features/projects/clusters_spec.rb @@ -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') diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js index f5be9ea0fb2..7b38f6b7855 100644 --- a/spec/javascripts/clusters/clusters_bundle_spec.js +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -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); }); }); diff --git a/spec/javascripts/clusters/clusters_index_spec.js b/spec/javascripts/clusters/clusters_index_spec.js deleted file mode 100644 index 0a8b63ed5b4..00000000000 --- a/spec/javascripts/clusters/clusters_index_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/spec/javascripts/fixtures/clusters.rb b/spec/javascripts/fixtures/clusters.rb index d26ea3febe8..8e74c4f859c 100644 --- a/spec/javascripts/fixtures/clusters.rb +++ b/spec/javascripts/fixtures/clusters.rb @@ -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 diff --git a/spec/javascripts/toggle_buttons_spec.js b/spec/javascripts/toggle_buttons_spec.js new file mode 100644 index 00000000000..205e396d682 --- /dev/null +++ b/spec/javascripts/toggle_buttons_spec.js @@ -0,0 +1,120 @@ +import setupToggleButtons from '~/toggle_buttons'; +import getSetTimeoutPromise from './helpers/set_timeout_promise_helper'; + +function generateMarkup(isChecked = true) { + return ` + + `; +} + +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(); + }); + }); +});