Commit 698f7df2 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch...

Merge branch '209105-cluster-page-disable-the-save-button-if-there-are-no-edits-to-the-form-in-cluster-details' into 'master'

Disable Save button if no edits to GitLab integration form

Closes #209105

See merge request gitlab-org/gitlab!37753
parents 66e3288b 806855d6
......@@ -8,14 +8,7 @@ import Flash from '../flash';
import Poll from '../lib/utils/poll';
import initSettingsPanels from '../settings_panels';
import eventHub from './event_hub';
import {
APPLICATION_STATUS,
INGRESS,
INGRESS_DOMAIN_SUFFIX,
CROSSPLANE,
KNATIVE,
FLUENTD,
} from './constants';
import { APPLICATION_STATUS, CROSSPLANE, KNATIVE, FLUENTD } from './constants';
import ClustersService from './services/clusters_service';
import ClustersStore from './stores/clusters_store';
import Applications from './components/applications.vue';
......@@ -120,10 +113,6 @@ export default class Clusters {
this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason');
this.successApplicationContainer = document.querySelector('.js-cluster-application-notice');
this.tokenField = document.querySelector('.js-cluster-token');
this.ingressDomainHelpText = document.querySelector('.js-ingress-domain-help-text');
this.ingressDomainSnippet =
this.ingressDomainHelpText &&
this.ingressDomainHelpText.querySelector('.js-ingress-domain-snippet');
initProjectSelectDropdown();
Clusters.initDismissableCallout();
......@@ -327,13 +316,6 @@ export default class Clusters {
this.checkForNewInstalls(prevApplicationMap, this.store.state.applications);
this.updateContainer(prevStatus, this.store.state.status, this.store.state.statusReason);
if (this.ingressDomainHelpText) {
this.toggleIngressDomainHelpText(
prevApplicationMap[INGRESS],
this.store.state.applications[INGRESS],
);
}
if (this.store.state.applications[KNATIVE]?.status === APPLICATION_STATUS.INSTALLED) {
initServerlessSurveyBanner();
}
......@@ -505,13 +487,6 @@ export default class Clusters {
});
}
toggleIngressDomainHelpText({ externalIp }, { externalIp: newExternalIp }) {
if (externalIp !== newExternalIp) {
this.ingressDomainHelpText.classList.toggle('hide', !newExternalIp);
this.ingressDomainSnippet.textContent = `${newExternalIp}${INGRESS_DOMAIN_SUFFIX}`;
}
}
saveKnativeDomain(data) {
const appId = data.id;
this.store.updateApplication(appId);
......
<script>
import { GlFormGroup, GlToggle, GlTooltipDirective } from '@gitlab/ui';
import {
GlFormGroup,
GlFormInput,
GlToggle,
GlTooltipDirective,
GlSprintf,
GlLink,
GlButton,
} from '@gitlab/ui';
import { mapState } from 'vuex';
export default {
components: {
GlFormGroup,
GlToggle,
GlFormInput,
GlSprintf,
GlLink,
GlButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
autoDevopsHelpPath: {
type: String,
},
externalEndpointHelpPath: {
type: String,
},
},
data() {
return {
toggleEnabled: true,
envScope: '*',
baseDomainField: '',
externalIp: '',
};
},
computed: {
...mapState(['enabled', 'editable']),
...mapState([
'enabled',
'editable',
'environmentScope',
'baseDomain',
'applicationIngressExternalIp',
]),
canSubmit() {
return (
this.enabled !== this.toggleEnabled ||
this.environmentScope !== this.envScope ||
this.baseDomain !== this.baseDomainField
);
},
},
mounted() {
this.toggleEnabled = this.enabled;
this.envScope = this.environmentScope;
this.baseDomainField = this.baseDomain;
this.externalIp = this.applicationIngressExternalIp;
},
};
</script>
<template>
<div class="d-flex align-items-center">
<div class="d-flex gl-flex-direction-column">
<gl-form-group>
<div class="gl-display-flex gl-align-items-center">
<h4 class="gl-pr-3 gl-m-0 ">{{ s__('ClusterIntegration|GitLab Integration') }}</h4>
<input
id="cluster_enabled"
class="js-project-feature-toggle-input"
type="hidden"
:value="toggleEnabled"
name="cluster[enabled]"
/>
<div id="tooltipcontainer" class="js-cluster-enable-toggle-area">
<h4 class="gl-pr-3 gl-m-0">{{ s__('ClusterIntegration|GitLab Integration') }}</h4>
<div class="js-cluster-enable-toggle-area">
<gl-toggle
id="toggleCluster"
v-model="toggleEnabled"
v-gl-tooltip:tooltipcontainer
name="cluster[enabled]"
class="gl-mb-0 js-project-feature-toggle"
data-qa-selector="integration_status_toggle"
:aria-describedby="__('Toggle Kubernetes cluster')"
aria-describedby="toggleCluster"
:disabled="!editable"
:is_checked="toggleEnabled"
:title="
s__(
'ClusterIntegration|Enable or disable GitLab\'s connection to your Kubernetes cluster.',
......@@ -54,5 +88,76 @@ export default {
</div>
</div>
</gl-form-group>
<gl-form-group
:label="s__('ClusterIntegration|Environment scope')"
label-size="sm"
label-for="cluster_environment_scope"
:description="
s__('ClusterIntegration|Choose which of your environments will use this cluster.')
"
>
<gl-form-input
id="cluster_environment_scope"
v-model="envScope"
name="cluster[environment_scope]"
class="col-md-6"
type="text"
/>
</gl-form-group>
<gl-form-group
:label="s__('ClusterIntegration|Base domain')"
label-size="sm"
label-for="cluster_base_domain"
>
<gl-form-input
id="cluster_base_domain"
v-model="baseDomainField"
name="cluster[base_domain]"
data-qa-selector="base_domain_field"
class="col-md-6"
type="text"
/>
<div class="form-text text-muted inline">
<gl-sprintf
:message="
s__(
'ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{linkStart}Auto DevOps.%{linkEnd} The domain should have a wildcard DNS configured matching the domain. ',
)
"
>
<template #link="{ content }">
<gl-link :href="autoDevopsHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
<div v-if="applicationIngressExternalIp" class="js-ingress-domain-help-text inline">
{{ s__('ClusterIntegration|Alternatively, ') }}
<gl-sprintf :message="s__('ClusterIntegration|%{externalIp}.nip.io')">
<template #externalIp>{{ externalIp }}</template>
</gl-sprintf>
{{ s__('ClusterIntegration|can be used instead of a custom domain. ') }}
</div>
<gl-sprintf
class="inline"
:message="s__('ClusterIntegration|%{linkStart}More information%{linkEnd}')"
>
<template #link="{ content }">
<gl-link :href="externalEndpointHelpPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</div>
</gl-form-group>
<div v-if="editable" class="form group gl-display-flex gl-justify-content-end">
<gl-button
category="primary"
variant="success"
type="submit"
:disabled="!canSubmit"
:aria-disabled="!canSubmit"
data-qa-selector="save_changes_button"
>{{ s__('ClusterIntegration|Save changes') }}</gl-button
>
</div>
</div>
</template>
......@@ -9,13 +9,19 @@ export default () => {
return;
}
const { autoDevopsHelpPath, externalEndpointHelpPath } = entryPoint.dataset;
// eslint-disable-next-line no-new
new Vue({
el: entryPoint,
store: createStore(entryPoint.dataset),
provide: {
autoDevopsHelpPath,
externalEndpointHelpPath,
},
render(createElement) {
return createElement(IntegrationForm);
return createElement(IntegrationForm, {});
},
});
};
......@@ -4,5 +4,10 @@ export default (initialState = {}) => {
return {
enabled: parseBoolean(initialState.enabled),
editable: parseBoolean(initialState.editable),
environmentScope: initialState.environmentScope,
baseDomain: initialState.baseDomain,
applicationIngressExternalIp: initialState.applicationIngressExternalIp,
autoDevopsHelpPath: initialState.autoDevopsHelpPath,
externalEndpointHelpPath: initialState.externalEndpointHelpPath,
};
};
# frozen_string_literal: true
module ClustersHelper
def has_multiple_clusters?
true
end
def create_new_cluster_label(provider: nil)
case provider
when 'aws'
......@@ -31,7 +27,12 @@ module ClustersHelper
def js_cluster_form_data(cluster, can_edit)
{
enabled: cluster.enabled?.to_s,
editable: can_edit.to_s
editable: can_edit.to_s,
environment_scope: cluster.environment_scope,
base_domain: cluster.base_domain,
application_ingress_external_ip: cluster.application_ingress_external_ip,
auto_devops_help_path: help_page_path('topics/autodevops/index'),
external_endpoint_help_path: help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint')
}
end
......
= form_for @cluster, url: clusterable.cluster_path(@cluster), as: :cluster, html: { class: 'js-cluster-integration-form' } do |field|
= form_errors(@cluster)
#js-cluster-integration-form{ data: js_cluster_form_data(@cluster, can?(current_user, :update_cluster, @cluster)) }
.form-group
%h5= s_('ClusterIntegration|Environment scope')
= field.text_field :environment_scope, class: 'col-md-6 form-control js-select-on-focus', placeholder: s_('ClusterIntegration|Environment scope')
- environment_scope_url = help_page_path('user/project/clusters/index', anchor: 'base-domain')
- environment_scope_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: environment_scope_url }
.form-text.text-muted= s_("ClusterIntegration|Choose which of your environments will use this cluster. %{environment_scope_start}More information%{environment_scope_end}").html_safe % { environment_scope_start: environment_scope_start, environment_scope_end: '</a>'.html_safe }
.form-group
%h5= s_('ClusterIntegration|Base domain')
= field.text_field :base_domain, class: 'col-md-6 form-control js-select-on-focus', data: { qa_selector: 'base_domain_field' }
.form-text.text-muted
- auto_devops_url = help_page_path('topics/autodevops/index')
- auto_devops_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: auto_devops_url }
= s_('ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain.').html_safe % { auto_devops_start: auto_devops_start, auto_devops_end: '</a>'.html_safe }
%span{ :class => ["js-ingress-domain-help-text", ("hide" unless @cluster.application_ingress_external_ip.present?)] }
= s_('ClusterIntegration|Alternatively')
%code{ :class => "js-ingress-domain-snippet" }
= s_('ClusterIntegration|%{external_ip}.nip.io').html_safe % { external_ip: @cluster.application_ingress_external_ip }
= s_('ClusterIntegration| can be used instead of a custom domain.')
- custom_domain_url = help_page_path('user/clusters/applications.md', anchor: 'pointing-your-dns-at-the-external-endpoint')
- custom_domain_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: custom_domain_url }
= s_('ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}.').html_safe % { custom_domain_start: custom_domain_start, custom_domain_end: '</a>'.html_safe }
- if can?(current_user, :update_cluster, @cluster)
.form-group.gl-display-flex.gl-justify-content-end
= field.submit _('Save changes'), class: 'btn btn-success', data: { qa_selector: 'save_changes_button'}
......@@ -5105,19 +5105,16 @@ msgstr ""
msgid "Cluster type must be specificed for Stages::ClusterEndpointInserter"
msgstr ""
msgid "ClusterIntegration| %{custom_domain_start}More information%{custom_domain_end}."
msgstr ""
msgid "ClusterIntegration| This will permanently delete the following resources: <ul> <li>All installed applications and related resources</li> <li>The <code>gitlab-managed-apps</code> namespace</li> <li>Any project namespaces</li> <li><code>clusterroles</code></li> <li><code>clusterrolebindings</code></li> </ul>"
msgstr ""
msgid "ClusterIntegration| can be used instead of a custom domain."
msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgstr ""
msgid "ClusterIntegration|%{appList} was successfully installed on your Kubernetes cluster"
msgid "ClusterIntegration|%{externalIp}.nip.io"
msgstr ""
msgid "ClusterIntegration|%{external_ip}.nip.io"
msgid "ClusterIntegration|%{linkStart}More information%{linkEnd}"
msgstr ""
msgid "ClusterIntegration|%{title} uninstalled successfully."
......@@ -5171,7 +5168,7 @@ msgstr ""
msgid "ClusterIntegration|Allow GitLab to manage namespace and service accounts for this cluster. %{startLink}More information%{endLink}"
msgstr ""
msgid "ClusterIntegration|Alternatively"
msgid "ClusterIntegration|Alternatively, "
msgstr ""
msgid "ClusterIntegration|Amazon EKS"
......@@ -5234,9 +5231,6 @@ msgstr ""
msgid "ClusterIntegration|Choose which of your environments will use this cluster."
msgstr ""
msgid "ClusterIntegration|Choose which of your environments will use this cluster. %{environment_scope_start}More information%{environment_scope_end}"
msgstr ""
msgid "ClusterIntegration|Clear cluster cache"
msgstr ""
......@@ -5858,7 +5852,7 @@ msgstr ""
msgid "ClusterIntegration|Something went wrong while updating Knative domain name."
msgstr ""
msgid "ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{auto_devops_start}Auto DevOps%{auto_devops_end}. The domain should have a wildcard DNS configured matching the domain."
msgid "ClusterIntegration|Specifying a domain will allow you to use Auto Review Apps and Auto Deploy stages for %{linkStart}Auto DevOps.%{linkEnd} The domain should have a wildcard DNS configured matching the domain. "
msgstr ""
msgid "ClusterIntegration|Subnets"
......@@ -5981,6 +5975,9 @@ msgstr ""
msgid "ClusterIntegration|access to Google Kubernetes Engine"
msgstr ""
msgid "ClusterIntegration|can be used instead of a custom domain. "
msgstr ""
msgid "ClusterIntegration|documentation"
msgstr ""
......@@ -25704,9 +25701,6 @@ msgstr ""
msgid "Today"
msgstr ""
msgid "Toggle Kubernetes cluster"
msgstr ""
msgid "Toggle Markdown preview"
msgstr ""
......
......@@ -12,9 +12,6 @@ module QA
view 'app/assets/javascripts/clusters/forms/components/integration_form.vue' do
element :integration_status_toggle, required: true
end
view 'app/views/clusters/clusters/_gitlab_integration_form.html.haml' do
element :base_domain_field, required: true
element :save_changes_button, required: true
end
......
......@@ -20,7 +20,7 @@ RSpec.describe 'Clusterable > Show page' do
expect(page).to have_content(cluster_type_label)
end
it 'allow the user to set domain' do
it 'allow the user to set domain', :js do
visit cluster_path
within '.js-cluster-integration-form' do
......@@ -28,20 +28,19 @@ RSpec.describe 'Clusterable > Show page' do
click_on 'Save changes'
end
expect(page.status_code).to eq(200)
expect(page).to have_content('Kubernetes cluster was successfully updated.')
end
context 'when there is a cluster with ingress and external ip' do
context 'when there is a cluster with ingress and external ip', :js do
before do
cluster.create_application_ingress!(external_ip: '192.168.1.100')
visit cluster_path
end
it 'shows help text with the domain as an alternative to custom domain' do
it 'shows help text with the domain as an alternative to custom domain', :js do
within '.js-cluster-integration-form' do
expect(find(cluster_ingress_help_text_selector)).not_to match_css(hide_modifier_selector)
expect(find(cluster_ingress_help_text_selector).text).to include('192.168.1.100')
end
end
end
......@@ -51,7 +50,7 @@ RSpec.describe 'Clusterable > Show page' do
visit cluster_path
within '.js-cluster-integration-form' do
expect(find(cluster_ingress_help_text_selector)).to match_css(hide_modifier_selector)
expect(page).not_to have_selector(cluster_ingress_help_text_selector)
end
end
end
......
......@@ -2,12 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import { loadHTMLFixture } from 'helpers/fixtures';
import { setTestTimeout } from 'helpers/timeout';
import Clusters from '~/clusters/clusters_bundle';
import {
APPLICATION_STATUS,
INGRESS_DOMAIN_SUFFIX,
APPLICATIONS,
RUNNER,
} from '~/clusters/constants';
import { APPLICATION_STATUS, APPLICATIONS, RUNNER } from '~/clusters/constants';
import axios from '~/lib/utils/axios_utils';
import initProjectSelectDropdown from '~/project_select';
......@@ -308,7 +303,6 @@ describe('Clusters', () => {
return promise.then(() => {
expect(cluster.store.state.applications.helm.status).toEqual(INSTALLED);
expect(cluster.store.state.applications.helm.uninstallFailed).toBe(true);
expect(cluster.store.state.applications.helm.requestReason).toBeDefined();
});
});
......@@ -334,10 +328,8 @@ describe('Clusters', () => {
describe('handleClusterStatusSuccess', () => {
beforeEach(() => {
jest.spyOn(cluster.store, 'updateStateFromServer').mockReturnThis();
jest.spyOn(cluster, 'toggleIngressDomainHelpText').mockReturnThis();
jest.spyOn(cluster, 'checkForNewInstalls').mockReturnThis();
jest.spyOn(cluster, 'updateContainer').mockReturnThis();
cluster.handleClusterStatusSuccess({ data: {} });
});
......@@ -349,53 +341,11 @@ describe('Clusters', () => {
expect(cluster.checkForNewInstalls).toHaveBeenCalled();
});
it('toggles ingress domain help text', () => {
expect(cluster.toggleIngressDomainHelpText).toHaveBeenCalled();
});
it('updates message containers', () => {
expect(cluster.updateContainer).toHaveBeenCalled();
});
});
describe('toggleIngressDomainHelpText', () => {
let ingressPreviousState;
let ingressNewState;
beforeEach(() => {
ingressPreviousState = { externalIp: null };
ingressNewState = { externalIp: '127.0.0.1' };
});
describe(`when ingress have an external ip assigned`, () => {
beforeEach(() => {
cluster.toggleIngressDomainHelpText(ingressPreviousState, ingressNewState);
});
it('displays custom domain help text', () => {
expect(cluster.ingressDomainHelpText.classList.contains('hide')).toEqual(false);
});
it('updates ingress external ip address', () => {
expect(cluster.ingressDomainSnippet.textContent).toEqual(
`${ingressNewState.externalIp}${INGRESS_DOMAIN_SUFFIX}`,
);
});
});
describe(`when ingress does not have an external ip assigned`, () => {
it('hides custom domain help text', () => {
ingressPreviousState.externalIp = '127.0.0.1';
ingressNewState.externalIp = null;
cluster.ingressDomainHelpText.classList.remove('hide');
cluster.toggleIngressDomainHelpText(ingressPreviousState, ingressNewState);
expect(cluster.ingressDomainHelpText.classList.contains('hide')).toEqual(true);
});
});
});
describe('updateApplication', () => {
const params = { version: '1.0.0' };
let storeUpdateApplication;
......
import Vuex from 'vuex';
import IntegrationForm from '~/clusters/forms/components/integration_form.vue';
import { createStore } from '~/clusters/forms/stores/index';
import { mount } from '@vue/test-utils';
import { GlToggle } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlToggle, GlButton } from '@gitlab/ui';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ClusterIntegrationForm', () => {
let wrapper;
let store;
const glToggle = () => wrapper.find(GlToggle);
const toggleButton = () => glToggle().find('button');
const toggleInput = () => wrapper.find('input');
const defaultStoreValues = {
enabled: true,
editable: true,
environmentScope: '*',
baseDomain: 'testDomain',
applicationIngressExternalIp: null,
};
const createWrapper = () => {
store = createStore({
enabled: 'true',
editable: 'true',
const createWrapper = (storeValues = defaultStoreValues) => {
wrapper = shallowMount(IntegrationForm, {
localVue,
store: createStore(storeValues),
provide: {
autoDevopsHelpPath: 'topics/autodevops/index',
externalEndpointHelpPath: 'user/clusters/applications.md',
},
});
wrapper = mount(IntegrationForm, { store });
return wrapper.vm.$nextTick();
};
beforeEach(() => {
return createWrapper();
});
const destroyWrapper = () => {
wrapper.destroy();
wrapper = null;
};
const findSubmitButton = () => wrapper.find(GlButton);
const findGlToggle = () => wrapper.find(GlToggle);
afterEach(() => {
wrapper.destroy();
destroyWrapper();
});
it('creates the toggle and label', () => {
expect(wrapper.text()).toContain('GitLab Integration');
expect(wrapper.contains(GlToggle)).toBe(true);
});
describe('rendering', () => {
beforeEach(() => createWrapper());
it('enables toggle if editable is true', () => {
expect(findGlToggle().props('disabled')).toBe(false);
});
it('sets the envScope to default', () => {
expect(wrapper.find('[id="cluster_environment_scope"]').attributes('value')).toBe('*');
});
it('sets the baseDomain to default', () => {
expect(wrapper.find('[id="cluster_base_domain"]').attributes('value')).toBe('testDomain');
});
it('initializes toggle with store value', () => {
expect(toggleButton().classes()).toContain('is-checked');
expect(toggleInput().attributes('value')).toBe('true');
describe('when editable is false', () => {
beforeEach(() => {
createWrapper({ ...defaultStoreValues, editable: false });
});
it('disables toggle if editable is false', () => {
expect(findGlToggle().props('disabled')).toBe(true);
});
it('does not render the save button', () => {
expect(findSubmitButton().exists()).toBe(false);
});
});
it('does not render external IP block if applicationIngressExternalIp was not passed', () => {
createWrapper({ ...defaultStoreValues });
expect(wrapper.find('.js-ingress-domain-help-text').exists()).toBe(false);
});
it('renders external IP block if applicationIngressExternalIp was passed', () => {
createWrapper({ ...defaultStoreValues, applicationIngressExternalIp: '127.0.0.1' });
expect(wrapper.find('.js-ingress-domain-help-text').exists()).toBe(true);
});
});
it('switches the toggle value on click', () => {
toggleButton().trigger('click');
wrapper.vm.$nextTick(() => {
expect(toggleButton().classes()).not.toContain('is-checked');
expect(toggleInput().attributes('value')).toBe('false');
describe('reactivity', () => {
beforeEach(() => createWrapper());
it('enables the submit button on changing toggle to different value', () => {
return wrapper.vm
.$nextTick()
.then(() => {
// setData is a bad approach because it changes the internal implementation which we should not touch
// but our GlFormInput lacks the ability to set a new value.
wrapper.setData({ toggleEnabled: !defaultStoreValues.enabled });
})
.then(() => {
expect(findSubmitButton().props('disabled')).toBe(false);
});
});
it('enables the submit button on changing input values', () => {
return wrapper.vm
.$nextTick()
.then(() => {
wrapper.setData({ envScope: `${defaultStoreValues.environmentScope}1` });
})
.then(() => {
expect(findSubmitButton().props('disabled')).toBe(false);
});
});
});
});
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