diff options
author | Eric Eastwood <contact@ericeastwood.com> | 2017-10-24 07:56:39 +0300 |
---|---|---|
committer | Eric Eastwood <contact@ericeastwood.com> | 2017-11-03 11:28:57 -0500 |
commit | 797e758beb882d67f32b87db6453cad41c0b931f (patch) | |
tree | 7e3a35aa71b0d484f727a29f88f16f3b3d0ffa29 | |
parent | e6616e0468deaf1e37ddddc9332cc3e677567410 (diff) | |
download | gitlab-ce-797e758beb882d67f32b87db6453cad41c0b931f.tar.gz |
Add applications section to GKE clusters page
22 files changed, 1268 insertions, 210 deletions
diff --git a/app/assets/javascripts/clusters.js b/app/assets/javascripts/clusters.js deleted file mode 100644 index c9fef94efea..00000000000 --- a/app/assets/javascripts/clusters.js +++ /dev/null @@ -1,123 +0,0 @@ -/* globals Flash */ -import Visibility from 'visibilityjs'; -import axios from 'axios'; -import setAxiosCsrfToken from './lib/utils/axios_utils'; -import Poll from './lib/utils/poll'; -import { s__ } from './locale'; -import initSettingsPanels from './settings_panels'; -import Flash from './flash'; - -/** - * Cluster page has 2 separate parts: - * Toggle button - * - * - Polling status while creating or scheduled - * -- Update status area with the response result - */ - -class ClusterService { - constructor(options = {}) { - this.options = options; - setAxiosCsrfToken(); - } - fetchData() { - return axios.get(this.options.endpoint); - } -} - -export default class Clusters { - constructor() { - initSettingsPanels(); - - const dataset = document.querySelector('.js-edit-cluster-form').dataset; - - this.state = { - statusPath: dataset.statusPath, - clusterStatus: dataset.clusterStatus, - clusterStatusReason: dataset.clusterStatusReason, - toggleStatus: dataset.toggleStatus, - }; - - this.service = new ClusterService({ endpoint: this.state.statusPath }); - 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'); - this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); - - this.toggleButton.addEventListener('click', this.toggle.bind(this)); - - if (this.state.clusterStatus !== 'created') { - this.updateContainer(this.state.clusterStatus, this.state.clusterStatusReason); - } - - if (this.state.statusPath) { - this.initPolling(); - } - } - - toggle() { - this.toggleButton.classList.toggle('checked'); - this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString()); - } - - initPolling() { - this.poll = new Poll({ - resource: this.service, - method: 'fetchData', - successCallback: data => this.handleSuccess(data), - errorCallback: () => Clusters.handleError(), - }); - - if (!Visibility.hidden()) { - this.poll.makeRequest(); - } else { - this.service.fetchData() - .then(data => this.handleSuccess(data)) - .catch(() => Clusters.handleError()); - } - - Visibility.change(() => { - if (!Visibility.hidden()) { - this.poll.restart(); - } else { - this.poll.stop(); - } - }); - } - - static handleError() { - Flash(s__('ClusterIntegration|Something went wrong on our end.')); - } - - handleSuccess(data) { - const { status, status_reason } = data.data; - this.updateContainer(status, status_reason); - } - - hideAll() { - this.errorContainer.classList.add('hidden'); - this.successContainer.classList.add('hidden'); - this.creatingContainer.classList.add('hidden'); - } - - updateContainer(status, error) { - this.hideAll(); - switch (status) { - case 'created': - this.successContainer.classList.remove('hidden'); - break; - case 'errored': - this.errorContainer.classList.remove('hidden'); - this.errorReasonContainer.textContent = error; - break; - case 'scheduled': - case 'creating': - this.creatingContainer.classList.remove('hidden'); - break; - default: - this.hideAll(); - } - } -} diff --git a/app/assets/javascripts/clusters/clusters_bundle.js b/app/assets/javascripts/clusters/clusters_bundle.js new file mode 100644 index 00000000000..053f11cc3c4 --- /dev/null +++ b/app/assets/javascripts/clusters/clusters_bundle.js @@ -0,0 +1,216 @@ +import Visibility from 'visibilityjs'; +import Vue from 'vue'; +import { s__, sprintf } from '../locale'; +import Flash from '../flash'; +import Poll from '../lib/utils/poll'; +import initSettingsPanels from '../settings_panels'; +import eventHub from './event_hub'; +import { + APPLICATION_INSTALLED, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from './constants'; +import ClustersService from './services/clusters_service'; +import ClustersStore from './stores/clusters_store'; +import applications from './components/applications.vue'; + +/** + * Cluster page has 2 separate parts: + * Toggle button and applications section + * + * - Polling status while creating or scheduled + * - Update status area with the response result + */ + +export default class Clusters { + constructor() { + const { + statusPath, + installHelmPath, + installIngressPath, + installRunnerPath, + clusterStatus, + clusterStatusReason, + helpPath, + } = document.querySelector('.js-edit-cluster-form').dataset; + + this.store = new ClustersStore(); + this.store.setHelpPath(helpPath); + this.store.updateStatus(clusterStatus); + this.store.updateStatusReason(clusterStatusReason); + this.service = new ClustersService({ + endpoint: statusPath, + installHelmEndpoint: installHelmPath, + installIngresEndpoint: installIngressPath, + installRunnerEndpoint: installRunnerPath, + }); + + this.toggle = this.toggle.bind(this); + this.installApplication = this.installApplication.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'); + this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason'); + this.successApplicationContainer = document.querySelector('.js-cluster-application-notice'); + + initSettingsPanels(); + this.initApplications(); + + if (this.store.state.status !== 'created') { + this.updateContainer(this.store.state.status, this.store.state.statusReason); + } + + this.addListeners(); + if (statusPath) { + this.initPolling(); + } + } + + initApplications() { + const store = this.store; + const el = document.querySelector('#js-cluster-applications'); + + this.applications = new Vue({ + el, + components: { + applications, + }, + data() { + return { + state: store.state, + }; + }, + render(createElement) { + return createElement('applications', { + props: { + applications: this.state.applications, + helpPath: this.state.helpPath, + }, + }); + }, + }); + } + + addListeners() { + this.toggleButton.addEventListener('click', this.toggle); + eventHub.$on('installApplication', this.installApplication); + } + + removeListeners() { + this.toggleButton.removeEventListener('click', this.toggle); + eventHub.$off('installApplication', this.installApplication); + } + + initPolling() { + this.poll = new Poll({ + resource: this.service, + method: 'fetchData', + successCallback: data => this.handleSuccess(data), + errorCallback: () => Clusters.handleError(), + }); + + if (!Visibility.hidden()) { + this.poll.makeRequest(); + } else { + this.service.fetchData() + .then(data => this.handleSuccess(data)) + .catch(() => Clusters.handleError()); + } + + Visibility.change(() => { + if (!Visibility.hidden() && !this.destroyed) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + static handleError() { + Flash(s__('ClusterIntegration|Something went wrong on our end.')); + } + + handleSuccess(data) { + const prevApplicationMap = Object.assign({}, this.store.state.applications); + this.store.updateStateFromServer(data.data); + this.checkForNewInstalls(prevApplicationMap, this.store.state.applications); + this.updateContainer(this.store.state.status, this.store.state.statusReason); + } + + toggle() { + this.toggleButton.classList.toggle('checked'); + this.toggleInput.setAttribute('value', this.toggleButton.classList.contains('checked').toString()); + } + + hideAll() { + this.errorContainer.classList.add('hidden'); + this.successContainer.classList.add('hidden'); + this.creatingContainer.classList.add('hidden'); + } + + checkForNewInstalls(prevApplicationMap, newApplicationMap) { + const appTitles = Object.keys(newApplicationMap) + .filter(appId => newApplicationMap[appId].status === APPLICATION_INSTALLED && + prevApplicationMap[appId].status !== APPLICATION_INSTALLED && + prevApplicationMap[appId].status !== null) + .map(appId => newApplicationMap[appId].title); + + if (appTitles.length > 0) { + this.successApplicationContainer.textContent = sprintf(s__('ClusterIntegration|%{appList} was successfully installed on your cluster'), { + appList: appTitles.join(', '), + }); + this.successApplicationContainer.classList.remove('hidden'); + } else { + this.successApplicationContainer.classList.add('hidden'); + } + } + + updateContainer(status, error) { + this.hideAll(); + switch (status) { + case 'created': + this.successContainer.classList.remove('hidden'); + break; + case 'errored': + this.errorContainer.classList.remove('hidden'); + this.errorReasonContainer.textContent = error; + break; + case 'scheduled': + case 'creating': + this.creatingContainer.classList.remove('hidden'); + break; + default: + this.hideAll(); + } + } + + installApplication(appId) { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); + this.store.updateAppProperty(appId, 'requestReason', null); + + this.service.installApplication(appId) + .then(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); + }) + .catch(() => { + this.store.updateAppProperty(appId, 'requestStatus', REQUEST_FAILURE); + this.store.updateAppProperty(appId, 'requestReason', s__('ClusterIntegration|Request to begin installing failed')); + }); + } + + destroy() { + this.destroyed = true; + + this.removeListeners(); + + if (this.poll) { + this.poll.stop(); + } + + this.applications.$destroy(); + } +} diff --git a/app/assets/javascripts/clusters/components/application_row.vue b/app/assets/javascripts/clusters/components/application_row.vue new file mode 100644 index 00000000000..f8d53fcc4b7 --- /dev/null +++ b/app/assets/javascripts/clusters/components/application_row.vue @@ -0,0 +1,168 @@ +<script> +import { s__ } from '../../locale'; +import eventHub from '../event_hub'; +import loadingButton from '../../vue_shared/components/loading_button.vue'; +import { + APPLICATION_INSTALLABLE, + APPLICATION_INSTALLING, + APPLICATION_INSTALLED, + APPLICATION_ERROR, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from '../constants'; + +export default { + props: { + id: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + titleLink: { + type: String, + required: false, + }, + description: { + type: String, + required: true, + }, + status: { + type: String, + required: false, + }, + statusReason: { + type: String, + required: false, + }, + requestStatus: { + type: String, + required: false, + }, + requestReason: { + type: String, + required: false, + }, + }, + components: { + loadingButton, + }, + computed: { + rowJsClass() { + return `js-cluster-application-row-${this.id}`; + }, + titleElementType() { + return this.titleLink ? 'a' : 'span'; + }, + installButtonLoading() { + return !this.status || + this.status === APPLICATION_INSTALLING || + this.requestStatus === REQUEST_LOADING; + }, + installButtonDisabled() { + // Avoid the potential for the real-time data to say APPLICATION_INSTALLABLE but + // we already made a request to install and are just waiting for the real-time + // to sync up. + return this.status !== APPLICATION_INSTALLABLE || + this.requestStatus === REQUEST_LOADING || + this.requestStatus === REQUEST_SUCCESS; + }, + installButtonLabel() { + let label; + if (this.status === APPLICATION_INSTALLABLE || this.status === APPLICATION_ERROR) { + label = s__('ClusterIntegration|Install'); + } else if (this.status === APPLICATION_INSTALLING) { + label = s__('ClusterIntegration|Installing'); + } else if (this.status === APPLICATION_INSTALLED) { + label = s__('ClusterIntegration|Installed'); + } + + return label; + }, + hasError() { + return this.status === APPLICATION_ERROR || this.requestStatus === REQUEST_FAILURE; + }, + }, + methods: { + installClicked() { + eventHub.$emit('installApplication', this.id); + }, + }, +}; +</script> + +<template> + <div + class="gl-responsive-table-row gl-responsive-table-row-col-span" + :class="rowJsClass" + > + <div + class="gl-responsive-table-row-layout" + role="row" + > + <component + :is="titleElementType" + :href="titleLink" + target="blank" + rel="noopener noreferrer" + role="gridcell" + class="table-section section-15 section-align-top js-cluster-application-title" + > + {{ title }} + </component> + <div + class="table-section section-wrap" + role="gridcell" + > + <div v-html="description"></div> + </div> + <div + class="table-section table-button-footer section-15 section-align-top" + role="gridcell" + > + <div class="btn-group table-action-buttons"> + <loading-button + class="js-cluster-application-install-button" + :loading="installButtonLoading" + :disabled="installButtonDisabled" + :label="installButtonLabel" + @click="installClicked" + /> + </div> + </div> + </div> + <div + v-if="hasError" + class="gl-responsive-table-row-layout" + role="row" + > + <div + class="alert alert-danger alert-block append-bottom-0 table-section section-100" + role="gridcell" + > + <div> + <p class="js-cluster-application-general-error-message"> + Something went wrong while installing {{ title }} + </p> + <ul v-if="statusReason || requestReason"> + <li + v-if="statusReason" + class="js-cluster-application-status-error-message" + > + {{ statusReason }} + </li> + <li + v-if="requestReason" + class="js-cluster-application-request-error-message" + > + {{ requestReason }} + </li> + </ul> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue new file mode 100644 index 00000000000..865fa4f702e --- /dev/null +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -0,0 +1,105 @@ +<script> +import _ from 'underscore'; +import { s__, sprintf } from '../../locale'; +import applicationRow from './application_row.vue'; + +export default { + props: { + applications: { + type: Object, + required: false, + default: () => ({}), + }, + helpPath: { + type: String, + required: false, + }, + }, + components: { + applicationRow, + }, + computed: { + generalApplicationDescription() { + return sprintf( + _.escape(s__('ClusterIntegration|Install applications on your cluster. Read more about %{helpLink}')), { + helpLink: `<a href="${this.helpPath}"> + ${_.escape(s__('ClusterIntegration|installing applications'))} + </a>`, + }, + false, + ); + }, + helmTillerDescription() { + return _.escape(s__( + `ClusterIntegration|Helm streamlines installing and managing Kubernets applications. + Tiller runs inside of your Kubernetes Cluster, and manages + releases of your charts.`, + )); + }, + ingressDescription() { + const descriptionParagraph = _.escape(s__( + `ClusterIntegration|Ingress gives you a way to route requests to services based on the + request host or path, centralizing a number of services into a single entrypoint.`, + )); + + const extraCostParagraph = sprintf( + _.escape(s__('ClusterIntegration|%{boldNotice} This will add some extra resources like a load balancer, which incur additional costs. See %{pricingLink}')), { + boldNotice: `<strong>${_.escape(s__('ClusterIntegration|Note:'))}</strong>`, + pricingLink: `<a href="https://cloud.google.com/compute/pricing#lb" target="blank" rel="noopener noreferrer"> + ${_.escape(s__('ClusterIntegration|GKE pricing'))} + </a>`, + }, + false, + ); + + return ` + <p> + ${descriptionParagraph} + </p> + <p class="append-bottom-0"> + ${extraCostParagraph} + </p> + `; + }, + gitlabRunnerDescription() { + return _.escape(s__( + `ClusterIntegration|GitLab Runner is the open source project that is used to run your jobs + and send the results back to GitLab.`, + )); + }, + }, +}; +</script> + +<template> + <section class="settings no-animate expanded"> + <div class="settings-header"> + <h4> + {{ s__('ClusterIntegration|Applications') }} + </h4> + <p + class="append-bottom-0" + v-html="generalApplicationDescription" + > + </p> + </div> + + <div class="settings-content"> + <div class="form_group append-bottom-20"> + <application-row + id="helm" + :title="applications.helm.title" + title-link="https://docs.helm.sh/" + :description="helmTillerDescription" + :status="applications.helm.status" + :status-reason="applications.helm.statusReason" + :request-status="applications.helm.requestStatus" + :request-reason="applications.helm.requestReason" + /> + <!-- NOTE: Don't forget to update `clusters.scss` min-height for this block and uncomment `application_spec` tests --> + <!-- Add Ingress row, all other plumbing is complete --> + <!-- Add GitLab Runner row, all other plumbing is complete --> + </div> + </div> + </section> +</template> diff --git a/app/assets/javascripts/clusters/constants.js b/app/assets/javascripts/clusters/constants.js new file mode 100644 index 00000000000..3f202435716 --- /dev/null +++ b/app/assets/javascripts/clusters/constants.js @@ -0,0 +1,10 @@ +// These need to match what is returned from the server +export const APPLICATION_INSTALLABLE = 'installable'; +export const APPLICATION_INSTALLING = 'installing'; +export const APPLICATION_INSTALLED = 'installed'; +export const APPLICATION_ERROR = 'error'; + +// These are only used client-side +export const REQUEST_LOADING = 'request-loading'; +export const REQUEST_SUCCESS = 'request-success'; +export const REQUEST_FAILURE = 'request-failure'; diff --git a/app/assets/javascripts/clusters/event_hub.js b/app/assets/javascripts/clusters/event_hub.js new file mode 100644 index 00000000000..0948c2e5352 --- /dev/null +++ b/app/assets/javascripts/clusters/event_hub.js @@ -0,0 +1,3 @@ +import Vue from 'vue'; + +export default new Vue(); diff --git a/app/assets/javascripts/clusters/services/clusters_service.js b/app/assets/javascripts/clusters/services/clusters_service.js new file mode 100644 index 00000000000..0ac8e68187d --- /dev/null +++ b/app/assets/javascripts/clusters/services/clusters_service.js @@ -0,0 +1,24 @@ +import axios from 'axios'; +import setAxiosCsrfToken from '../../lib/utils/axios_utils'; + +export default class ClusterService { + constructor(options = {}) { + setAxiosCsrfToken(); + + this.options = options; + this.appInstallEndpointMap = { + helm: this.options.installHelmEndpoint, + ingress: this.options.installIngressEndpoint, + runner: this.options.installRunnerEndpoint, + }; + } + + fetchData() { + return axios.get(this.options.endpoint); + } + + installApplication(appId) { + const endpoint = this.appInstallEndpointMap[appId]; + return axios.post(endpoint); + } +} diff --git a/app/assets/javascripts/clusters/stores/clusters_store.js b/app/assets/javascripts/clusters/stores/clusters_store.js new file mode 100644 index 00000000000..e731cdc3042 --- /dev/null +++ b/app/assets/javascripts/clusters/stores/clusters_store.js @@ -0,0 +1,68 @@ +import { s__ } from '../../locale'; + +export default class ClusterStore { + constructor() { + this.state = { + helpPath: null, + status: null, + statusReason: null, + applications: { + helm: { + title: s__('ClusterIntegration|Helm Tiller'), + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, + }, + ingress: { + title: s__('ClusterIntegration|Ingress'), + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, + }, + runner: { + title: s__('ClusterIntegration|GitLab Runner'), + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, + }, + }, + }; + } + + setHelpPath(helpPath) { + this.state.helpPath = helpPath; + } + + updateStatus(status) { + this.state.status = status; + } + + updateStatusReason(reason) { + this.state.statusReason = reason; + } + + updateAppProperty(appId, prop, value) { + this.state.applications[appId][prop] = value; + } + + updateStateFromServer(serverState = {}) { + this.state.status = serverState.status; + this.state.statusReason = serverState.status_reason; + serverState.applications.forEach((serverAppEntry) => { + const { + name: appId, + status, + status_reason: statusReason, + } = serverAppEntry; + + this.state.applications[appId] = { + ...(this.state.applications[appId] || {}), + status, + statusReason, + }; + }); + } +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 760fb0cdf67..44606989395 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ +import { s__ } from './locale'; /* global ProjectSelect */ import IssuableIndex from './issuable_index'; /* global Milestone */ @@ -32,6 +33,7 @@ import Labels from './labels'; import LabelManager from './label_manager'; /* global Sidebar */ +import Flash from './flash'; import CommitsList from './commits'; import Issue from './issue'; import BindInOut from './behaviors/bind_in_out'; @@ -543,9 +545,12 @@ import Diff from './diff'; new DueDateSelectors(); break; case 'projects:clusters:show': - import(/* webpackChunkName: "clusters" */ './clusters') + import(/* webpackChunkName: "clusters" */ './clusters/clusters_bundle') .then(cluster => new cluster.default()) // eslint-disable-line new-cap - .catch(() => {}); + .catch((err) => { + Flash(s__('ClusterIntegration|Problem setting up the cluster JavaScript')); + throw err; + }); break; } switch (path[0]) { diff --git a/app/assets/javascripts/vue_shared/components/loading_button.vue b/app/assets/javascripts/vue_shared/components/loading_button.vue index 6670b554faf..0cc2653761c 100644 --- a/app/assets/javascripts/vue_shared/components/loading_button.vue +++ b/app/assets/javascripts/vue_shared/components/loading_button.vue @@ -26,6 +26,11 @@ export default { required: false, default: false, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, label: { type: String, required: false, @@ -47,7 +52,7 @@ export default { class="btn btn-align-content" @click="onClick" type="button" - :disabled="loading" + :disabled="loading || disabled" > <transition name="fade"> <loading-icon diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 00a0e9cef67..16373b14ff7 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -294,6 +294,7 @@ .btn-align-content { display: flex; + justify-content: center; align-items: center; } diff --git a/app/assets/stylesheets/pages/clusters.scss b/app/assets/stylesheets/pages/clusters.scss index 5c91579c69c..b9bfae9356c 100644 --- a/app/assets/stylesheets/pages/clusters.scss +++ b/app/assets/stylesheets/pages/clusters.scss @@ -3,3 +3,8 @@ background-color: $white-light; } } + +.cluster-applications-table { + // Wait for the Vue to kick-in and render the applications block + min-height: 179px; +} diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index bd55136562a..de1f9fc5d32 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -4,15 +4,22 @@ - expanded = Rails.env.test? -- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) && @cluster.on_creation? +- status_path = status_namespace_project_cluster_path(@cluster.project.namespace, @cluster.project, @cluster.id, format: :json) if can?(current_user, :admin_cluster, @cluster) .edit-cluster-form.js-edit-cluster-form{ data: { status_path: status_path, + install_helm_path: 'TODO', + install_ingress_path: 'TODO', + install_runner_path: 'TODO', toggle_status: @cluster.enabled? ? 'true': 'false', cluster_status: @cluster.status_name, - cluster_status_reason: @cluster.status_reason } } + cluster_status_reason: @cluster.status_reason, + help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications') } } - %section.settings + + .hidden.js-cluster-application-notice.alert.alert-info.alert-block.append-bottom-10{ role: 'alert' } + + %section.settings.no-animate.expanded %h4= s_('ClusterIntegration|Enable cluster integration') - .settings-content.expanded + .settings-content .hidden.js-cluster-error.alert.alert-danger.alert-block.append-bottom-10{ role: 'alert' } = s_('ClusterIntegration|Something went wrong while creating your cluster on Google Container Engine') @@ -49,6 +56,8 @@ .form-group = field.submit _('Save'), class: 'btn btn-success' + .cluster-applications-table#js-cluster-applications + %section.settings#js-cluster-details .settings-header %h4= s_('ClusterIntegration|Cluster details') @@ -59,7 +68,7 @@ .settings-content.no-animate{ class: ('expanded' if expanded) } .form_group.append-bottom-20 - %label.append-bottom-10{ for: 'cluter-name' } + %label.append-bottom-10{ for: 'cluster-name' } = s_('ClusterIntegration|Cluster name') .input-group %input.form-control.cluster-name{ value: @cluster.name, disabled: true } diff --git a/changelogs/unreleased/36629-35958-add-cluster-application-section.yml b/changelogs/unreleased/36629-35958-add-cluster-application-section.yml new file mode 100644 index 00000000000..0afa53e8642 --- /dev/null +++ b/changelogs/unreleased/36629-35958-add-cluster-application-section.yml @@ -0,0 +1,6 @@ +--- +title: Add applications section to GKE clusters page to easily install Helm Tiller, + Ingress +merge_request: +author: +type: added diff --git a/doc/user/project/clusters/img/cluster-applications.png b/doc/user/project/clusters/img/cluster-applications.png Binary files differnew file mode 100644 index 00000000000..794f3331296 --- /dev/null +++ b/doc/user/project/clusters/img/cluster-applications.png diff --git a/doc/user/project/clusters/index.md b/doc/user/project/clusters/index.md index 7d9e771f570..27b4b49c207 100644 --- a/doc/user/project/clusters/index.md +++ b/doc/user/project/clusters/index.md @@ -88,3 +88,12 @@ To remove the Cluster integration from your project, simply click on the and [add a cluster](#adding-a-cluster) again. [permissions]: ../../permissions.md + +## Installing applications + +GitLab provides a one-click install for +[Helm Tiller](https://docs.helm.sh/) and +[Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) +which will be added directly to your configured cluster. + +![Cluster application settings](img/cluster-applications.png) diff --git a/spec/javascripts/clusters/clusters_bundle_spec.js b/spec/javascripts/clusters/clusters_bundle_spec.js new file mode 100644 index 00000000000..26d230ce414 --- /dev/null +++ b/spec/javascripts/clusters/clusters_bundle_spec.js @@ -0,0 +1,235 @@ +import Clusters from '~/clusters/clusters_bundle'; +import { + APPLICATION_INSTALLABLE, + APPLICATION_INSTALLING, + APPLICATION_INSTALLED, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from '~/clusters/constants'; +import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper'; + +describe('Clusters', () => { + let cluster; + preloadFixtures('clusters/show_cluster.html.raw'); + + beforeEach(() => { + loadFixtures('clusters/show_cluster.html.raw'); + cluster = new Clusters(); + }); + + afterEach(() => { + cluster.destroy(); + }); + + describe('toggle', () => { + it('should update the button and the input field on click', () => { + cluster.toggleButton.click(); + + expect( + cluster.toggleButton.classList, + ).not.toContain('checked'); + + expect( + cluster.toggleInput.getAttribute('value'), + ).toEqual('false'); + }); + }); + + describe('checkForNewInstalls', () => { + const INITIAL_APP_MAP = { + helm: { status: null, title: 'Helm Tiller' }, + ingress: { status: null, title: 'Ingress' }, + runner: { status: null, title: 'GitLab Runner' }, + }; + + it('does not show alert when things transition from initial null state to something', () => { + cluster.checkForNewInstalls(INITIAL_APP_MAP, { + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLABLE, title: 'Helm Tiller' }, + }); + + expect(document.querySelector('.js-cluster-application-notice.hidden')).toBeDefined(); + }); + + it('shows an alert when something gets newly installed', () => { + cluster.checkForNewInstalls({ + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' }, + }, { + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' }, + }); + + expect(document.querySelector('.js-cluster-application-notice:not(.hidden)')).toBeDefined(); + expect(document.querySelector('.js-cluster-application-notice').textContent.trim()).toEqual('Helm Tiller was successfully installed on your cluster'); + }); + + it('shows an alert when multiple things gets newly installed', () => { + cluster.checkForNewInstalls({ + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' }, + ingress: { status: APPLICATION_INSTALLABLE, title: 'Ingress' }, + }, { + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' }, + ingress: { status: APPLICATION_INSTALLED, title: 'Ingress' }, + }); + + expect(document.querySelector('.js-cluster-application-notice:not(.hidden)')).toBeDefined(); + expect(document.querySelector('.js-cluster-application-notice').textContent.trim()).toEqual('Helm Tiller, Ingress was successfully installed on your cluster'); + }); + + it('hides existing alert when we call again and nothing is newly installed', () => { + const installedState = { + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLED, title: 'Helm Tiller' }, + }; + + // Show the banner + cluster.checkForNewInstalls({ + ...INITIAL_APP_MAP, + helm: { status: APPLICATION_INSTALLING, title: 'Helm Tiller' }, + }, installedState); + + expect(document.querySelector('.js-cluster-application-notice:not(.hidden)')).toBeDefined(); + + // Banner should go back hidden + cluster.checkForNewInstalls(installedState, installedState); + + expect(document.querySelector('.js-cluster-application-notice.hidden')).toBeDefined(); + }); + }); + + describe('updateContainer', () => { + describe('when creating cluster', () => { + it('should show the creating container', () => { + cluster.updateContainer('creating'); + + expect( + cluster.creatingContainer.classList.contains('hidden'), + ).toBeFalsy(); + expect( + cluster.successContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.errorContainer.classList.contains('hidden'), + ).toBeTruthy(); + }); + }); + + describe('when cluster is created', () => { + it('should show the success container', () => { + cluster.updateContainer('created'); + + expect( + cluster.creatingContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.successContainer.classList.contains('hidden'), + ).toBeFalsy(); + expect( + cluster.errorContainer.classList.contains('hidden'), + ).toBeTruthy(); + }); + }); + + describe('when cluster has error', () => { + it('should show the error container', () => { + cluster.updateContainer('errored', 'this is an error'); + + expect( + cluster.creatingContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.successContainer.classList.contains('hidden'), + ).toBeTruthy(); + expect( + cluster.errorContainer.classList.contains('hidden'), + ).toBeFalsy(); + + expect( + cluster.errorReasonContainer.textContent, + ).toContain('this is an error'); + }); + }); + }); + + describe('installApplication', () => { + it('tries to install helm', (done) => { + spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); + expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); + + cluster.installApplication('helm'); + + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.helm.requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalledWith('helm'); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.helm.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); + }); + + it('tries to install ingress', (done) => { + spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); + expect(cluster.store.state.applications.ingress.requestStatus).toEqual(null); + + cluster.installApplication('ingress'); + + expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalledWith('ingress'); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.ingress.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.ingress.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); + }); + + it('tries to install runner', (done) => { + spyOn(cluster.service, 'installApplication').and.returnValue(Promise.resolve()); + expect(cluster.store.state.applications.runner.requestStatus).toEqual(null); + + cluster.installApplication('runner'); + + expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.runner.requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalledWith('runner'); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.runner.requestStatus).toEqual(REQUEST_SUCCESS); + expect(cluster.store.state.applications.runner.requestReason).toEqual(null); + }) + .then(done) + .catch(done.fail); + }); + + it('sets error request status when the request fails', (done) => { + spyOn(cluster.service, 'installApplication').and.returnValue(Promise.reject(new Error('STUBBED ERROR'))); + expect(cluster.store.state.applications.helm.requestStatus).toEqual(null); + + cluster.installApplication('helm'); + + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_LOADING); + expect(cluster.store.state.applications.helm.requestReason).toEqual(null); + expect(cluster.service.installApplication).toHaveBeenCalled(); + + getSetTimeoutPromise() + .then(() => { + expect(cluster.store.state.applications.helm.requestStatus).toEqual(REQUEST_FAILURE); + expect(cluster.store.state.applications.helm.requestReason).toBeDefined(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/clusters/components/application_row_spec.js b/spec/javascripts/clusters/components/application_row_spec.js new file mode 100644 index 00000000000..ba38ed6f180 --- /dev/null +++ b/spec/javascripts/clusters/components/application_row_spec.js @@ -0,0 +1,213 @@ +import Vue from 'vue'; +import eventHub from '~/clusters/event_hub'; +import { + APPLICATION_INSTALLABLE, + APPLICATION_INSTALLING, + APPLICATION_INSTALLED, + APPLICATION_ERROR, + REQUEST_LOADING, + REQUEST_SUCCESS, + REQUEST_FAILURE, +} from '~/clusters/constants'; +import applicationRow from '~/clusters/components/application_row.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { DEFAULT_APPLICATION_STATE } from '../services/mock_data'; + +describe('Application Row', () => { + let vm; + let ApplicationRow; + + beforeEach(() => { + ApplicationRow = Vue.extend(applicationRow); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('Title', () => { + it('shows title', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + titleLink: null, + }); + const title = vm.$el.querySelector('.js-cluster-application-title'); + + expect(title.tagName).toEqual('SPAN'); + expect(title.textContent.trim()).toEqual(DEFAULT_APPLICATION_STATE.title); + }); + + it('shows title link', () => { + expect(DEFAULT_APPLICATION_STATE.titleLink).toBeDefined(); + + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + }); + const title = vm.$el.querySelector('.js-cluster-application-title'); + + expect(title.tagName).toEqual('A'); + expect(title.textContent.trim()).toEqual(DEFAULT_APPLICATION_STATE.title); + }); + }); + + describe('Install button', () => { + it('has indeterminate state on page load', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: null, + }); + + expect(vm.installButtonLabel).toBeUndefined(); + }); + + it('has enabled "Install" when `status=installable`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(false); + }); + + it('has loading "Installing" when `status=installing`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLING, + }); + + expect(vm.installButtonLabel).toEqual('Installing'); + expect(vm.installButtonLoading).toEqual(true); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has disabled "Installed" when `status=installed`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLED, + }); + + expect(vm.installButtonLabel).toEqual('Installed'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has disabled "Install" when `status=error`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_ERROR, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has loading "Install" when `requestStatus=loading`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + requestStatus: REQUEST_LOADING, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(true); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has disabled "Install" when `requestStatus=success`', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + requestStatus: REQUEST_SUCCESS, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(true); + }); + + it('has enabled "Install" when `requestStatus=error` (so you can try installing again)', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + requestStatus: REQUEST_FAILURE, + }); + + expect(vm.installButtonLabel).toEqual('Install'); + expect(vm.installButtonLoading).toEqual(false); + expect(vm.installButtonDisabled).toEqual(false); + }); + + it('clicking install button emits event', () => { + spyOn(eventHub, '$emit'); + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + }); + const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); + + installButton.click(); + + expect(eventHub.$emit).toHaveBeenCalledWith('installApplication', DEFAULT_APPLICATION_STATE.id); + }); + + it('clicking disabled install button emits nothing', () => { + spyOn(eventHub, '$emit'); + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLING, + }); + const installButton = vm.$el.querySelector('.js-cluster-application-install-button'); + + expect(vm.installButtonDisabled).toEqual(true); + + installButton.click(); + + expect(eventHub.$emit).not.toHaveBeenCalled(); + }); + }); + + describe('Error block', () => { + it('does not show error block when there is no error', () => { + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: null, + requestStatus: null, + }); + const generalErrorMessage = vm.$el.querySelector('.js-cluster-application-general-error-message'); + + expect(generalErrorMessage).toBeNull(); + }); + + it('shows status reason when `status=error`', () => { + const statusReason = 'We broke it 0.0'; + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_ERROR, + statusReason, + }); + const generalErrorMessage = vm.$el.querySelector('.js-cluster-application-general-error-message'); + const statusErrorMessage = vm.$el.querySelector('.js-cluster-application-status-error-message'); + + expect(generalErrorMessage.textContent.trim()).toEqual(`Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`); + expect(statusErrorMessage.textContent.trim()).toEqual(statusReason); + }); + + it('shows request reason when `requestStatus=error`', () => { + const requestReason = 'We broke thre request 0.0'; + vm = mountComponent(ApplicationRow, { + ...DEFAULT_APPLICATION_STATE, + status: APPLICATION_INSTALLABLE, + requestStatus: REQUEST_FAILURE, + requestReason, + }); + const generalErrorMessage = vm.$el.querySelector('.js-cluster-application-general-error-message'); + const requestErrorMessage = vm.$el.querySelector('.js-cluster-application-request-error-message'); + + expect(generalErrorMessage.textContent.trim()).toEqual(`Something went wrong while installing ${DEFAULT_APPLICATION_STATE.title}`); + expect(requestErrorMessage.textContent.trim()).toEqual(requestReason); + }); + }); +}); diff --git a/spec/javascripts/clusters/components/applications_spec.js b/spec/javascripts/clusters/components/applications_spec.js new file mode 100644 index 00000000000..5f59a00dc65 --- /dev/null +++ b/spec/javascripts/clusters/components/applications_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; +import applications from '~/clusters/components/applications.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Applications', () => { + let vm; + let Applications; + + beforeEach(() => { + Applications = Vue.extend(applications); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('', () => { + beforeEach(() => { + vm = mountComponent(Applications, { + applications: { + helm: { title: 'Helm Tiller' }, + ingress: { title: 'Ingress' }, + runner: { title: 'GitLab Runner' }, + }, + }); + }); + + it('renders a row for Helm Tiller', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-helm')).toBeDefined(); + }); + + /* * / + it('renders a row for Ingress', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-ingress')).toBeDefined(); + }); + + it('renders a row for GitLab Runner', () => { + expect(vm.$el.querySelector('.js-cluster-application-row-runner')).toBeDefined(); + }); + /* */ + }); +}); diff --git a/spec/javascripts/clusters/services/mock_data.js b/spec/javascripts/clusters/services/mock_data.js new file mode 100644 index 00000000000..af6b6a73819 --- /dev/null +++ b/spec/javascripts/clusters/services/mock_data.js @@ -0,0 +1,50 @@ +import { + APPLICATION_INSTALLABLE, + APPLICATION_INSTALLING, + APPLICATION_ERROR, +} from '~/clusters/constants'; + +const CLUSTERS_MOCK_DATA = { + GET: { + '/gitlab-org/gitlab-shell/clusters/1/status.json': { + data: { + status: 'errored', + status_reason: 'Failed to request to CloudPlatform.', + applications: [{ + name: 'helm', + status: APPLICATION_INSTALLABLE, + status_reason: null, + }, { + name: 'ingress', + status: APPLICATION_ERROR, + status_reason: 'Cannot connect', + }, { + name: 'runner', + status: APPLICATION_INSTALLING, + status_reason: null, + }], + }, + }, + }, + POST: { + '/gitlab-org/gitlab-shell/clusters/1/applications/helm': { }, + '/gitlab-org/gitlab-shell/clusters/1/applications/ingress': { }, + '/gitlab-org/gitlab-shell/clusters/1/applications/runner': { }, + }, +}; + +const DEFAULT_APPLICATION_STATE = { + id: 'some-app', + title: 'My App', + titleLink: 'https://about.gitlab.com/', + description: 'Some description about this interesting application!', + status: null, + statusReason: null, + requestStatus: null, + requestReason: null, +}; + +export { + CLUSTERS_MOCK_DATA, + DEFAULT_APPLICATION_STATE, +}; diff --git a/spec/javascripts/clusters/stores/clusters_store_spec.js b/spec/javascripts/clusters/stores/clusters_store_spec.js new file mode 100644 index 00000000000..9f9d63434f7 --- /dev/null +++ b/spec/javascripts/clusters/stores/clusters_store_spec.js @@ -0,0 +1,86 @@ +import ClustersStore from '~/clusters/stores/clusters_store'; +import { APPLICATION_INSTALLING } from '~/clusters/constants'; +import { CLUSTERS_MOCK_DATA } from '../services/mock_data'; + +describe('Clusters Store', () => { + let store; + + beforeEach(() => { + store = new ClustersStore(); + }); + + describe('updateStatus', () => { + it('should store new status', () => { + expect(store.state.status).toEqual(null); + + const newStatus = 'errored'; + store.updateStatus(newStatus); + + expect(store.state.status).toEqual(newStatus); + }); + }); + + describe('updateStatusReason', () => { + it('should store new reason', () => { + expect(store.state.statusReason).toEqual(null); + + const newReason = 'Something went wrong!'; + store.updateStatusReason(newReason); + + expect(store.state.statusReason).toEqual(newReason); + }); + }); + + describe('updateAppProperty', () => { + it('should store new request status', () => { + expect(store.state.applications.helm.requestStatus).toEqual(null); + + const newStatus = APPLICATION_INSTALLING; + store.updateAppProperty('helm', 'requestStatus', newStatus); + + expect(store.state.applications.helm.requestStatus).toEqual(newStatus); + }); + + it('should store new request reason', () => { + expect(store.state.applications.helm.requestReason).toEqual(null); + + const newReason = 'We broke it.'; + store.updateAppProperty('helm', 'requestReason', newReason); + + expect(store.state.applications.helm.requestReason).toEqual(newReason); + }); + }); + + describe('updateStateFromServer', () => { + it('should store new polling data from server', () => { + const mockResponseData = CLUSTERS_MOCK_DATA.GET['/gitlab-org/gitlab-shell/clusters/1/status.json'].data; + store.updateStateFromServer(mockResponseData); + + expect(store.state).toEqual({ + helpPath: null, + status: mockResponseData.status, + statusReason: mockResponseData.status_reason, + applications: { + helm: { + status: mockResponseData.applications[0].status, + statusReason: mockResponseData.applications[0].status_reason, + requestStatus: null, + requestReason: null, + }, + ingress: { + status: mockResponseData.applications[1].status, + statusReason: mockResponseData.applications[1].status_reason, + requestStatus: null, + requestReason: null, + }, + runner: { + status: mockResponseData.applications[2].status, + statusReason: mockResponseData.applications[2].status_reason, + requestStatus: null, + requestReason: null, + }, + }, + }); + }); + }); +}); diff --git a/spec/javascripts/clusters_spec.js b/spec/javascripts/clusters_spec.js deleted file mode 100644 index eb1cd6eb804..00000000000 --- a/spec/javascripts/clusters_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import Clusters from '~/clusters'; - -describe('Clusters', () => { - let cluster; - preloadFixtures('clusters/show_cluster.html.raw'); - - beforeEach(() => { - loadFixtures('clusters/show_cluster.html.raw'); - cluster = new Clusters(); - }); - - describe('toggle', () => { - it('should update the button and the input field on click', () => { - cluster.toggleButton.click(); - - expect( - cluster.toggleButton.classList, - ).not.toContain('checked'); - - expect( - cluster.toggleInput.getAttribute('value'), - ).toEqual('false'); - }); - }); - - describe('updateContainer', () => { - describe('when creating cluster', () => { - it('should show the creating container', () => { - cluster.updateContainer('creating'); - - expect( - cluster.creatingContainer.classList.contains('hidden'), - ).toBeFalsy(); - expect( - cluster.successContainer.classList.contains('hidden'), - ).toBeTruthy(); - expect( - cluster.errorContainer.classList.contains('hidden'), - ).toBeTruthy(); - }); - }); - - describe('when cluster is created', () => { - it('should show the success container', () => { - cluster.updateContainer('created'); - - expect( - cluster.creatingContainer.classList.contains('hidden'), - ).toBeTruthy(); - expect( - cluster.successContainer.classList.contains('hidden'), - ).toBeFalsy(); - expect( - cluster.errorContainer.classList.contains('hidden'), - ).toBeTruthy(); - }); - }); - - describe('when cluster has error', () => { - it('should show the error container', () => { - cluster.updateContainer('errored', 'this is an error'); - - expect( - cluster.creatingContainer.classList.contains('hidden'), - ).toBeTruthy(); - expect( - cluster.successContainer.classList.contains('hidden'), - ).toBeTruthy(); - expect( - cluster.errorContainer.classList.contains('hidden'), - ).toBeFalsy(); - - expect( - cluster.errorReasonContainer.textContent, - ).toContain('this is an error'); - }); - }); - }); -}); |