diff options
42 files changed, 1858 insertions, 474 deletions
diff --git a/app/assets/javascripts/pages/projects/clusters/gcp/new/index.js b/app/assets/javascripts/pages/projects/clusters/gcp/new/index.js new file mode 100644 index 00000000000..d4f34e32a48 --- /dev/null +++ b/app/assets/javascripts/pages/projects/clusters/gcp/new/index.js @@ -0,0 +1,5 @@ +import initGkeDropdowns from '~/projects/gke_cluster_dropdowns'; + +document.addEventListener('DOMContentLoaded', () => { + initGkeDropdowns(); +}); diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js new file mode 100644 index 00000000000..c15d8ba49e1 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_dropdown_mixin.js @@ -0,0 +1,71 @@ +import _ from 'underscore'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import DropdownSearchInput from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; +import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; +import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; + +import store from '../store'; + +export default { + store, + components: { + LoadingIcon, + DropdownButton, + DropdownSearchInput, + DropdownHiddenInput, + }, + props: { + fieldId: { + type: String, + required: true, + }, + fieldName: { + type: String, + required: true, + }, + defaultValue: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + isLoading: false, + hasErrors: false, + searchQuery: '', + gapiError: '', + }; + }, + computed: { + results() { + if (!this.items) { + return []; + } + + return this.items.filter(item => item.name.toLowerCase().indexOf(this.searchQuery) > -1); + }, + }, + methods: { + fetchSuccessHandler() { + if (this.defaultValue) { + const itemToSelect = _.find(this.items, item => item.name === this.defaultValue); + + if (itemToSelect) { + this.setItem(itemToSelect.name); + } + } + + this.isLoading = false; + this.hasErrors = false; + }, + fetchFailureHandler(resp) { + this.isLoading = false; + this.hasErrors = true; + + if (resp.result && resp.result.error) { + this.gapiError = resp.result.error.message; + } + }, + }, +}; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue new file mode 100644 index 00000000000..6be39702546 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue @@ -0,0 +1,142 @@ +<script> +import { sprintf, s__ } from '~/locale'; +import { mapState, mapGetters, mapActions } from 'vuex'; + +import gkeDropdownMixin from './gke_dropdown_mixin'; + +export default { + name: 'GkeMachineTypeDropdown', + mixins: [gkeDropdownMixin], + computed: { + ...mapState([ + 'isValidatingProjectBilling', + 'projectHasBillingEnabled', + 'selectedZone', + 'selectedMachineType', + ]), + ...mapState({ items: 'machineTypes' }), + ...mapGetters(['hasZone', 'hasMachineType']), + allDropdownsSelected() { + return this.projectHasBillingEnabled && this.hasZone && this.hasMachineType; + }, + isDisabled() { + return ( + this.isLoading || + this.isValidatingProjectBilling || + !this.projectHasBillingEnabled || + !this.hasZone + ); + }, + toggleText() { + if (this.isLoading) { + return s__('ClusterIntegration|Fetching machine types'); + } + + if (this.selectedMachineType) { + return this.selectedMachineType; + } + + if (!this.projectHasBillingEnabled && !this.hasZone) { + return s__('ClusterIntegration|Select project and zone to choose machine type'); + } + + return !this.hasZone + ? s__('ClusterIntegration|Select zone to choose machine type') + : s__('ClusterIntegration|Select machine type'); + }, + errorMessage() { + return sprintf( + s__( + 'ClusterIntegration|An error occured while trying to fetch zone machine types: %{error}', + ), + { error: this.gapiError }, + ); + }, + }, + watch: { + selectedZone() { + this.hasErrors = false; + + if (this.hasZone) { + this.isLoading = true; + + this.fetchMachineTypes() + .then(this.fetchSuccessHandler) + .catch(this.fetchFailureHandler); + } + }, + selectedMachineType() { + this.enableSubmit(); + }, + }, + methods: { + ...mapActions(['fetchMachineTypes']), + ...mapActions({ setItem: 'setMachineType' }), + enableSubmit() { + if (this.allDropdownsSelected) { + const submitButtonEl = document.querySelector('.js-gke-cluster-creation-submit'); + + if (submitButtonEl) { + submitButtonEl.removeAttribute('disabled'); + } + } + }, + }, +}; +</script> + +<template> + <div> + <div + class="js-gcp-machine-type-dropdown dropdown" + :class="{ 'gl-show-field-errors': hasErrors }" + > + <dropdown-hidden-input + :name="fieldName" + :value="selectedMachineType" + /> + <dropdown-button + :class="{ 'gl-field-error-outline': hasErrors }" + :is-disabled="isDisabled" + :is-loading="isLoading" + :toggle-text="toggleText" + /> + <div class="dropdown-menu dropdown-select"> + <dropdown-search-input + v-model="searchQuery" + :placeholder-text="s__('ClusterIntegration|Search machine types')" + /> + <div class="dropdown-content"> + <ul> + <li v-show="!results.length"> + <span class="menu-item"> + {{ s__('ClusterIntegration|No machine types matched your search') }} + </span> + </li> + <li + v-for="result in results" + :key="result.id" + > + <button + type="button" + @click.prevent="setItem(result.name)" + > + {{ result.name }} + </button> + </li> + </ul> + </div> + <div class="dropdown-loading"> + <loading-icon /> + </div> + </div> + </div> + <span + class="help-block" + :class="{ 'gl-field-error': hasErrors }" + v-if="hasErrors" + > + {{ errorMessage }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue new file mode 100644 index 00000000000..f813a4625d6 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue @@ -0,0 +1,201 @@ +<script> +import _ from 'underscore'; +import { s__, sprintf } from '~/locale'; +import { mapState, mapGetters, mapActions } from 'vuex'; + +import gkeDropdownMixin from './gke_dropdown_mixin'; + +export default { + name: 'GkeProjectIdDropdown', + mixins: [gkeDropdownMixin], + props: { + docsUrl: { + type: String, + required: true, + }, + }, + computed: { + ...mapState(['selectedProject', 'isValidatingProjectBilling', 'projectHasBillingEnabled']), + ...mapState({ items: 'projects' }), + ...mapGetters(['hasProject']), + hasOneProject() { + return this.items && this.items.length === 1; + }, + isDisabled() { + return ( + this.isLoading || this.isValidatingProjectBilling || (this.items && this.items.length < 2) + ); + }, + toggleText() { + if (this.isValidatingProjectBilling) { + return s__('ClusterIntegration|Validating project billing status'); + } + + if (this.isLoading) { + return s__('ClusterIntegration|Fetching projects'); + } + + if (this.hasProject) { + return this.selectedProject.name; + } + + if (!this.items) { + return s__('ClusterIntegration|No projects found'); + } + + return s__('ClusterIntegration|Select project'); + }, + helpText() { + let message; + if (this.hasErrors) { + return this.errorMessage; + } + + if (!this.items) { + message = + 'ClusterIntegration|We were unable to fetch any projects. Ensure that you have a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.'; + } + + message = + this.items && this.items.length + ? 'ClusterIntegration|To use a new project, first create one on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.' + : 'ClusterIntegration|To create a cluster, first create a project on %{docsLinkStart}Google Cloud Platform%{docsLinkEnd}.'; + + return sprintf( + s__(message), + { + docsLinkEnd: ' <i class="fa fa-external-link" aria-hidden="true"></i></a>', + docsLinkStart: `<a href="${_.escape( + this.docsUrl, + )}" target="_blank" rel="noopener noreferrer">`, + }, + false, + ); + }, + errorMessage() { + if (!this.projectHasBillingEnabled) { + if (this.gapiError) { + return s__( + 'ClusterIntegration|We could not verify that one of your projects on GCP has billing enabled. Please try again.', + ); + } + + return sprintf( + s__( + 'This project does not have billing enabled. To create a cluster, <a href=%{linkToBilling} target="_blank" rel="noopener noreferrer">enable billing <i class="fa fa-external-link" aria-hidden="true"></i></a> and try again.', + ), + { + linkToBilling: + 'https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral', + }, + false, + ); + } + + return sprintf( + s__('ClusterIntegration|An error occured while trying to fetch your projects: %{error}'), + { error: this.gapiError }, + ); + }, + }, + watch: { + selectedProject() { + this.setIsValidatingProjectBilling(true); + + this.validateProjectBilling() + .then(this.validateProjectBillingSuccessHandler) + .catch(this.validateProjectBillingFailureHandler); + }, + }, + created() { + this.isLoading = true; + + this.fetchProjects() + .then(this.fetchSuccessHandler) + .catch(this.fetchFailureHandler); + }, + methods: { + ...mapActions(['fetchProjects', 'setIsValidatingProjectBilling', 'validateProjectBilling']), + ...mapActions({ setItem: 'setProject' }), + fetchSuccessHandler() { + if (this.defaultValue) { + const projectToSelect = _.find(this.items, item => item.projectId === this.defaultValue); + + if (projectToSelect) { + this.setItem(projectToSelect); + } + } else if (this.items.length === 1) { + this.setItem(this.items[0]); + } + + this.isLoading = false; + this.hasErrors = false; + }, + validateProjectBillingSuccessHandler() { + this.hasErrors = !this.projectHasBillingEnabled; + }, + validateProjectBillingFailureHandler(resp) { + this.hasErrors = true; + + this.gapiError = resp.result ? resp.result.error.message : resp; + }, + }, +}; +</script> + +<template> + <div> + <div + class="js-gcp-project-id-dropdown dropdown" + :class="{ 'gl-show-field-errors': hasErrors }" + > + <dropdown-hidden-input + :name="fieldName" + :value="selectedProject.projectId" + /> + <dropdown-button + :class="{ + 'gl-field-error-outline': hasErrors, + 'read-only': hasOneProject + }" + :is-disabled="isDisabled" + :is-loading="isLoading" + :toggle-text="toggleText" + /> + <div class="dropdown-menu dropdown-select"> + <dropdown-search-input + v-model="searchQuery" + :placeholder-text="s__('ClusterIntegration|Search projects')" + /> + <div class="dropdown-content"> + <ul> + <li v-show="!results.length"> + <span class="menu-item"> + {{ s__('ClusterIntegration|No projects matched your search') }} + </span> + </li> + <li + v-for="result in results" + :key="result.project_number" + > + <button + type="button" + @click.prevent="setItem(result)" + > + {{ result.name }} + </button> + </li> + </ul> + </div> + <div class="dropdown-loading"> + <loading-icon /> + </div> + </div> + </div> + <span + class="help-block" + :class="{ 'gl-field-error': hasErrors }" + v-html="helpText" + ></span> + </div> +</template> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue new file mode 100644 index 00000000000..0c63ff5dc63 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue @@ -0,0 +1,116 @@ +<script> +import { sprintf, s__ } from '~/locale'; +import { mapState, mapActions } from 'vuex'; + +import gkeDropdownMixin from './gke_dropdown_mixin'; + +export default { + name: 'GkeZoneDropdown', + mixins: [gkeDropdownMixin], + computed: { + ...mapState([ + 'selectedProject', + 'selectedZone', + 'projects', + 'isValidatingProjectBilling', + 'projectHasBillingEnabled', + ]), + ...mapState({ items: 'zones' }), + isDisabled() { + return this.isLoading || this.isValidatingProjectBilling || !this.projectHasBillingEnabled; + }, + toggleText() { + if (this.isLoading) { + return s__('ClusterIntegration|Fetching zones'); + } + + if (this.selectedZone) { + return this.selectedZone; + } + + return !this.projectHasBillingEnabled + ? s__('ClusterIntegration|Select project to choose zone') + : s__('ClusterIntegration|Select zone'); + }, + errorMessage() { + return sprintf( + s__('ClusterIntegration|An error occured while trying to fetch project zones: %{error}'), + { error: this.gapiError }, + ); + }, + }, + watch: { + isValidatingProjectBilling(isValidating) { + this.hasErrors = false; + + if (!isValidating && this.projectHasBillingEnabled) { + this.isLoading = true; + + this.fetchZones() + .then(this.fetchSuccessHandler) + .catch(this.fetchFailureHandler); + } + }, + }, + methods: { + ...mapActions(['fetchZones']), + ...mapActions({ setItem: 'setZone' }), + }, +}; +</script> + +<template> + <div> + <div + class="js-gcp-zone-dropdown dropdown" + :class="{ 'gl-show-field-errors': hasErrors }" + > + <dropdown-hidden-input + :name="fieldName" + :value="selectedZone" + /> + <dropdown-button + :class="{ 'gl-field-error-outline': hasErrors }" + :is-disabled="isDisabled" + :is-loading="isLoading" + :toggle-text="toggleText" + /> + <div class="dropdown-menu dropdown-select"> + <dropdown-search-input + v-model="searchQuery" + :placeholder-text="s__('ClusterIntegration|Search zones')" + /> + <div class="dropdown-content"> + <ul> + <li v-show="!results.length"> + <span class="menu-item"> + {{ s__('ClusterIntegration|No zones matched your search') }} + </span> + </li> + <li + v-for="result in results" + :key="result.id" + > + <button + type="button" + @click.prevent="setItem(result.name)" + > + {{ result.name }} + </button> + </li> + </ul> + </div> + <div class="dropdown-loading"> + <loading-icon /> + </div> + </div> + </div> + <span + class="help-block" + :class="{ 'gl-field-error': hasErrors }" + v-if="hasErrors" + > + {{ errorMessage }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js new file mode 100644 index 00000000000..2a1c0819916 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/constants.js @@ -0,0 +1,11 @@ +import { s__ } from '~/locale'; + +export const GCP_API_ERROR = s__( + 'ClusterIntegration|An error occurred when trying to contact the Google Cloud API. Please try again later.', +); +export const GCP_API_CLOUD_BILLING_ENDPOINT = + 'https://www.googleapis.com/discovery/v1/apis/cloudbilling/v1/rest'; +export const GCP_API_CLOUD_RESOURCE_MANAGER_ENDPOINT = + 'https://www.googleapis.com/discovery/v1/apis/cloudresourcemanager/v1/rest'; +export const GCP_API_COMPUTE_ENDPOINT = + 'https://www.googleapis.com/discovery/v1/apis/compute/v1/rest'; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js new file mode 100644 index 00000000000..729b9404b64 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/index.js @@ -0,0 +1,88 @@ +/* global gapi */ +import Vue from 'vue'; +import Flash from '~/flash'; +import GkeProjectIdDropdown from './components/gke_project_id_dropdown.vue'; +import GkeZoneDropdown from './components/gke_zone_dropdown.vue'; +import GkeMachineTypeDropdown from './components/gke_machine_type_dropdown.vue'; +import * as CONSTANTS from './constants'; + +const mountComponent = (entryPoint, component, componentName, extraProps = {}) => { + const el = document.querySelector(entryPoint); + if (!el) return false; + + const hiddenInput = el.querySelector('input'); + + return new Vue({ + el, + components: { + [componentName]: component, + }, + render: createElement => + createElement(componentName, { + props: { + fieldName: hiddenInput.getAttribute('name'), + fieldId: hiddenInput.getAttribute('id'), + defaultValue: hiddenInput.value, + ...extraProps, + }, + }), + }); +}; + +const mountGkeProjectIdDropdown = () => { + const entryPoint = '.js-gcp-project-id-dropdown-entry-point'; + const el = document.querySelector(entryPoint); + + mountComponent(entryPoint, GkeProjectIdDropdown, 'gke-project-id-dropdown', { + docsUrl: el.dataset.docsurl, + }); +}; + +const mountGkeZoneDropdown = () => { + mountComponent('.js-gcp-zone-dropdown-entry-point', GkeZoneDropdown, 'gke-zone-dropdown'); +}; + +const mountGkeMachineTypeDropdown = () => { + mountComponent( + '.js-gcp-machine-type-dropdown-entry-point', + GkeMachineTypeDropdown, + 'gke-machine-type-dropdown', + ); +}; + +const gkeDropdownErrorHandler = () => { + Flash(CONSTANTS.GCP_API_ERROR); +}; + +const initializeGapiClient = () => { + const el = document.querySelector('.js-gke-cluster-creation'); + if (!el) return false; + + return gapi.client + .init({ + discoveryDocs: [ + CONSTANTS.GCP_API_CLOUD_BILLING_ENDPOINT, + CONSTANTS.GCP_API_CLOUD_RESOURCE_MANAGER_ENDPOINT, + CONSTANTS.GCP_API_COMPUTE_ENDPOINT, + ], + }) + .then(() => { + gapi.client.setToken({ access_token: el.dataset.token }); + + mountGkeProjectIdDropdown(); + mountGkeZoneDropdown(); + mountGkeMachineTypeDropdown(); + }) + .catch(gkeDropdownErrorHandler); +}; + +const initGkeDropdowns = () => { + if (!gapi) { + gkeDropdownErrorHandler(); + return false; + } + + return gapi.load('client', initializeGapiClient); +}; + +export default initGkeDropdowns; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js new file mode 100644 index 00000000000..4834a856271 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/actions.js @@ -0,0 +1,95 @@ +/* global gapi */ +import * as types from './mutation_types'; + +const gapiResourceListRequest = ({ resource, params, commit, mutation, payloadKey }) => + new Promise((resolve, reject) => { + const request = resource.list(params); + + return request.then( + resp => { + const { result } = resp; + + commit(mutation, result[payloadKey]); + + resolve(); + }, + resp => { + reject(resp); + }, + ); + }); + +export const setProject = ({ commit }, selectedProject) => { + commit(types.SET_PROJECT, selectedProject); +}; + +export const setZone = ({ commit }, selectedZone) => { + commit(types.SET_ZONE, selectedZone); +}; + +export const setMachineType = ({ commit }, selectedMachineType) => { + commit(types.SET_MACHINE_TYPE, selectedMachineType); +}; + +export const setIsValidatingProjectBilling = ({ commit }, isValidatingProjectBilling) => { + commit(types.SET_IS_VALIDATING_PROJECT_BILLING, isValidatingProjectBilling); +}; + +export const fetchProjects = ({ commit }) => + gapiResourceListRequest({ + resource: gapi.client.cloudresourcemanager.projects, + params: {}, + commit, + mutation: types.SET_PROJECTS, + payloadKey: 'projects', + }); + +export const validateProjectBilling = ({ dispatch, commit, state }) => + new Promise((resolve, reject) => { + const request = gapi.client.cloudbilling.projects.getBillingInfo({ + name: `projects/${state.selectedProject.projectId}`, + }); + + commit(types.SET_ZONE, ''); + commit(types.SET_MACHINE_TYPE, ''); + + return request.then( + resp => { + const { billingEnabled } = resp.result; + + commit(types.SET_PROJECT_BILLING_STATUS, !!billingEnabled); + dispatch('setIsValidatingProjectBilling', false); + resolve(); + }, + resp => { + dispatch('setIsValidatingProjectBilling', false); + reject(resp); + }, + ); + }); + +export const fetchZones = ({ commit, state }) => + gapiResourceListRequest({ + resource: gapi.client.compute.zones, + params: { + project: state.selectedProject.projectId, + }, + commit, + mutation: types.SET_ZONES, + payloadKey: 'items', + }); + +export const fetchMachineTypes = ({ commit, state }) => + gapiResourceListRequest({ + resource: gapi.client.compute.machineTypes, + params: { + project: state.selectedProject.projectId, + zone: state.selectedZone, + }, + commit, + mutation: types.SET_MACHINE_TYPES, + payloadKey: 'items', + }); + +// prevent babel-plugin-rewire from generating an invalid default during karma tests +export default () => {}; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js new file mode 100644 index 00000000000..e39f02d0894 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/getters.js @@ -0,0 +1,3 @@ +export const hasProject = state => !!state.selectedProject.projectId; +export const hasZone = state => !!state.selectedZone; +export const hasMachineType = state => !!state.selectedMachineType; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js new file mode 100644 index 00000000000..5f72060633e --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/index.js @@ -0,0 +1,18 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import createState from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + actions, + getters, + mutations, + state: createState(), + }); + +export default createStore(); diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js new file mode 100644 index 00000000000..45a91efc2d9 --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutation_types.js @@ -0,0 +1,8 @@ +export const SET_PROJECT = 'SET_PROJECT'; +export const SET_PROJECT_BILLING_STATUS = 'SET_PROJECT_BILLING_STATUS'; +export const SET_IS_VALIDATING_PROJECT_BILLING = 'SET_IS_VALIDATING_PROJECT_BILLING'; +export const SET_ZONE = 'SET_ZONE'; +export const SET_MACHINE_TYPE = 'SET_MACHINE_TYPE'; +export const SET_PROJECTS = 'SET_PROJECTS'; +export const SET_ZONES = 'SET_ZONES'; +export const SET_MACHINE_TYPES = 'SET_MACHINE_TYPES'; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js new file mode 100644 index 00000000000..88a2c1b630d --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/mutations.js @@ -0,0 +1,28 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_PROJECT](state, selectedProject) { + Object.assign(state, { selectedProject }); + }, + [types.SET_IS_VALIDATING_PROJECT_BILLING](state, isValidatingProjectBilling) { + Object.assign(state, { isValidatingProjectBilling }); + }, + [types.SET_PROJECT_BILLING_STATUS](state, projectHasBillingEnabled) { + Object.assign(state, { projectHasBillingEnabled }); + }, + [types.SET_ZONE](state, selectedZone) { + Object.assign(state, { selectedZone }); + }, + [types.SET_MACHINE_TYPE](state, selectedMachineType) { + Object.assign(state, { selectedMachineType }); + }, + [types.SET_PROJECTS](state, projects) { + Object.assign(state, { projects }); + }, + [types.SET_ZONES](state, zones) { + Object.assign(state, { zones }); + }, + [types.SET_MACHINE_TYPES](state, machineTypes) { + Object.assign(state, { machineTypes }); + }, +}; diff --git a/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js new file mode 100644 index 00000000000..9f3c473d4bc --- /dev/null +++ b/app/assets/javascripts/projects/gke_cluster_dropdowns/store/state.js @@ -0,0 +1,13 @@ +export default () => ({ + selectedProject: { + projectId: '', + name: '', + }, + selectedZone: '', + selectedMachineType: '', + isValidatingProjectBilling: null, + projectHasBillingEnabled: null, + projects: [], + zones: [], + machineTypes: [], +}); diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue new file mode 100644 index 00000000000..c159333d89a --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_button.vue @@ -0,0 +1,55 @@ +<script> +import { __ } from '~/locale'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; + +export default { + components: { + LoadingIcon, + }, + props: { + isDisabled: { + type: Boolean, + required: false, + default: false, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + toggleText: { + type: String, + required: false, + default: __('Select'), + }, + }, +}; +</script> + +<template> + <button + class="dropdown-menu-toggle dropdown-menu-full-width" + type="button" + data-toggle="dropdown" + aria-expanded="false" + :disabled="isDisabled || isLoading" + > + <loading-icon + v-show="isLoading" + :inline="true" + /> + <span class="dropdown-toggle-text"> + {{ toggleText }} + </span> + <span + class="dropdown-toggle-icon" + v-show="!isLoading" + > + <i + class="fa fa-chevron-down" + aria-hidden="true" + data-hidden="true" + ></i> + </span> + </button> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue index 1832c3c1757..1fe27eb97ab 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_hidden_input.vue @@ -5,8 +5,8 @@ export default { type: String, required: true, }, - label: { - type: Object, + value: { + type: [Number, String], required: true, }, }, @@ -17,6 +17,6 @@ export default { <input type="hidden" :name="name" - :value="label.id" + :value="value" /> </template> diff --git a/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue new file mode 100644 index 00000000000..c2145a26e64 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/dropdown/dropdown_search_input.vue @@ -0,0 +1,46 @@ +<script> +import { __ } from '~/locale'; + +export default { + props: { + placeholderText: { + type: String, + required: true, + default: __('Search'), + }, + }, + data() { + return { searchQuery: this.value }; + }, + watch: { + searchQuery(query) { + this.$emit('input', query); + }, + }, +}; +</script> + +<template> + <div class="dropdown-input"> + <input + class="dropdown-input-field" + type="search" + v-model="searchQuery" + :placeholder="placeholderText" + autocomplete="off" + /> + <i + class="fa fa-search dropdown-input-search" + aria-hidden="true" + data-hidden="true" + > + </i> + <i + class="fa fa-times dropdown-input-clear js-dropdown-input-clear" + aria-hidden="true" + data-hidden="true" + role="button" + > + </i> + </div> +</template> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue index 70b46a9c2bb..f155ac2be02 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -2,13 +2,13 @@ import $ from 'jquery'; import { __ } from '~/locale'; import LabelsSelect from '~/labels_select'; +import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import LoadingIcon from '../../loading_icon.vue'; import DropdownTitle from './dropdown_title.vue'; import DropdownValue from './dropdown_value.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import DropdownButton from './dropdown_button.vue'; -import DropdownHiddenInput from './dropdown_hidden_input.vue'; import DropdownHeader from './dropdown_header.vue'; import DropdownSearchInput from './dropdown_search_input.vue'; import DropdownFooter from './dropdown_footer.vue'; @@ -140,7 +140,7 @@ export default { v-for="label in context.labels" :key="label.id" :name="hiddenInputName" - :label="label" + :value="label.id" /> <div class="dropdown" diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 1570b1f2eaa..b91d579cae6 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -63,6 +63,10 @@ border-radius: $border-radius-base; white-space: nowrap; + &:disabled.read-only { + color: $gl-text-color !important; + } + &.no-outline { outline: 0; } diff --git a/app/controllers/projects/clusters/gcp_controller.rb b/app/controllers/projects/clusters/gcp_controller.rb index 6b0b22f8e73..c2c5ad61e01 100644 --- a/app/controllers/projects/clusters/gcp_controller.rb +++ b/app/controllers/projects/clusters/gcp_controller.rb @@ -1,9 +1,8 @@ class Projects::Clusters::GcpController < Projects::ApplicationController before_action :authorize_read_cluster! - before_action :authorize_google_api, except: [:login] - before_action :authorize_google_project_billing, only: [:new, :create] before_action :authorize_create_cluster!, only: [:new, :create] - before_action :verify_billing, only: [:create] + before_action :authorize_google_api, except: :login + helper_method :token_in_session def login begin @@ -37,21 +36,6 @@ class Projects::Clusters::GcpController < Projects::ApplicationController private - def verify_billing - case google_project_billing_status - when nil - flash.now[:alert] = _('We could not verify that one of your projects on GCP has billing enabled. Please try again.') - when false - flash.now[:alert] = _('Please <a href=%{link_to_billing} target="_blank" rel="noopener noreferrer">enable billing for one of your projects to be able to create a Kubernetes cluster</a>, then try again.').html_safe % { link_to_billing: "https://console.cloud.google.com/freetrial?utm_campaign=2018_cpanel&utm_source=gitlab&utm_medium=referral" } - when true - return - end - - @cluster = ::Clusters::Cluster.new(create_params) - - render :new - end - def create_params params.require(:cluster).permit( :enabled, @@ -75,18 +59,8 @@ class Projects::Clusters::GcpController < Projects::ApplicationController end end - def authorize_google_project_billing - redis_token_key = CheckGcpProjectBillingWorker.store_session_token(token_in_session) - CheckGcpProjectBillingWorker.perform_async(redis_token_key) - end - - def google_project_billing_status - CheckGcpProjectBillingWorker.get_billing_state(token_in_session) - end - def token_in_session - @token_in_session ||= - session[GoogleApi::CloudPlatform::Client.session_key_for_token] + session[GoogleApi::CloudPlatform::Client.session_key_for_token] end def expires_at_in_session diff --git a/app/services/check_gcp_project_billing_service.rb b/app/services/check_gcp_project_billing_service.rb deleted file mode 100644 index ea82b61b279..00000000000 --- a/app/services/check_gcp_project_billing_service.rb +++ /dev/null @@ -1,11 +0,0 @@ -class CheckGcpProjectBillingService - def execute(token) - client = GoogleApi::CloudPlatform::Client.new(token, nil) - client.projects_list.select do |project| - begin - client.projects_get_billing_info(project.project_id).billing_enabled - rescue - end - end - end -end diff --git a/app/views/projects/clusters/gcp/_form.html.haml b/app/views/projects/clusters/gcp/_form.html.haml index 5739a57dcfe..be58a844f70 100644 --- a/app/views/projects/clusters/gcp/_form.html.haml +++ b/app/views/projects/clusters/gcp/_form.html.haml @@ -1,8 +1,10 @@ += javascript_include_tag 'https://apis.google.com/js/api.js' + %p - link_to_help_page = link_to(s_('ClusterIntegration|help page'), help_page_path('user/project/clusters/index'), target: '_blank', rel: 'noopener noreferrer') = s_('ClusterIntegration|Read our %{link_to_help_page} on Kubernetes cluster integration.').html_safe % { link_to_help_page: link_to_help_page} -= form_for @cluster, html: { class: 'prepend-top-20' }, url: gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| += form_for @cluster, html: { class: 'js-gke-cluster-creation prepend-top-20', data: { token: token_in_session } }, url: gcp_namespace_project_clusters_path(@project.namespace, @project), as: :cluster do |field| = form_errors(@cluster) .form-group = field.label :name, s_('ClusterIntegration|Kubernetes cluster name') @@ -14,13 +16,25 @@ = field.fields_for :provider_gcp, @cluster.provider_gcp do |provider_gcp_field| .form-group = provider_gcp_field.label :gcp_project_id, s_('ClusterIntegration|Google Cloud Platform project ID') - = link_to(s_('ClusterIntegration|See your projects'), 'https://console.cloud.google.com/home/dashboard', target: '_blank', rel: 'noopener noreferrer') - = provider_gcp_field.text_field :gcp_project_id, class: 'form-control', placeholder: s_('ClusterIntegration|Project ID') + .js-gcp-project-id-dropdown-entry-point{ data: { docsUrl: 'https://console.cloud.google.com/home/dashboard' } } + = provider_gcp_field.hidden_field :gcp_project_id + .dropdown + %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true } + %span.dropdown-toggle-text + = _('Select project') + = icon('chevron-down') + %span.help-block .form-group = provider_gcp_field.label :zone, s_('ClusterIntegration|Zone') = link_to(s_('ClusterIntegration|See zones'), 'https://cloud.google.com/compute/docs/regions-zones/regions-zones', target: '_blank', rel: 'noopener noreferrer') - = provider_gcp_field.text_field :zone, class: 'form-control', placeholder: 'us-central1-a' + .js-gcp-zone-dropdown-entry-point + = provider_gcp_field.hidden_field :zone + .dropdown + %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true } + %span.dropdown-toggle-text + = _('Select project to choose zone') + = icon('chevron-down') .form-group = provider_gcp_field.label :num_nodes, s_('ClusterIntegration|Number of nodes') @@ -28,8 +42,13 @@ .form-group = provider_gcp_field.label :machine_type, s_('ClusterIntegration|Machine type') - = link_to(s_('ClusterIntegration|See machine types'), 'https://cloud.google.com/compute/docs/machine-types', target: '_blank', rel: 'noopener noreferrer') - = provider_gcp_field.text_field :machine_type, class: 'form-control', placeholder: 'n1-standard-4' + .js-gcp-machine-type-dropdown-entry-point + = provider_gcp_field.hidden_field :machine_type + .dropdown + %button.dropdown-menu-toggle.dropdown-menu-full-width{ type: 'button', disabled: true } + %span.dropdown-toggle-text + = _('Select project and zone to choose machine type') + = icon('chevron-down') .form-group - = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'btn btn-success' + = field.submit s_('ClusterIntegration|Create Kubernetes cluster'), class: 'js-gke-cluster-creation-submit btn btn-success', disabled: true diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index d07865a4043..93e57512edb 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -24,7 +24,6 @@ - gcp_cluster:cluster_provision - gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:wait_for_cluster_creation -- gcp_cluster:check_gcp_project_billing - gcp_cluster:cluster_wait_for_ingress_ip_address - github_import_advance_stage diff --git a/app/workers/check_gcp_project_billing_worker.rb b/app/workers/check_gcp_project_billing_worker.rb deleted file mode 100644 index 363f81590ab..00000000000 --- a/app/workers/check_gcp_project_billing_worker.rb +++ /dev/null @@ -1,92 +0,0 @@ -require 'securerandom' - -class CheckGcpProjectBillingWorker - include ApplicationWorker - include ClusterQueue - - LEASE_TIMEOUT = 3.seconds.to_i - SESSION_KEY_TIMEOUT = 5.minutes - BILLING_TIMEOUT = 1.hour - BILLING_CHANGED_LABELS = { state_transition: nil }.freeze - - def self.get_session_token(token_key) - Gitlab::Redis::SharedState.with do |redis| - redis.get(get_redis_session_key(token_key)) - end - end - - def self.store_session_token(token) - generate_token_key.tap do |token_key| - Gitlab::Redis::SharedState.with do |redis| - redis.set(get_redis_session_key(token_key), token, ex: SESSION_KEY_TIMEOUT) - end - end - end - - def self.get_billing_state(token) - Gitlab::Redis::SharedState.with do |redis| - value = redis.get(redis_shared_state_key_for(token)) - ActiveRecord::Type::Boolean.new.type_cast_from_user(value) - end - end - - def perform(token_key) - return unless token_key - - token = self.class.get_session_token(token_key) - return unless token - return unless try_obtain_lease_for(token) - - billing_enabled_state = !CheckGcpProjectBillingService.new.execute(token).empty? - update_billing_change_counter(self.class.get_billing_state(token), billing_enabled_state) - self.class.set_billing_state(token, billing_enabled_state) - end - - private - - def self.generate_token_key - SecureRandom.uuid - end - - def self.get_redis_session_key(token_key) - "gitlab:gcp:session:#{token_key}" - end - - def self.redis_shared_state_key_for(token) - "gitlab:gcp:#{Digest::SHA1.hexdigest(token)}:billing_enabled" - end - - def self.set_billing_state(token, value) - Gitlab::Redis::SharedState.with do |redis| - redis.set(redis_shared_state_key_for(token), value, ex: BILLING_TIMEOUT) - end - end - - def try_obtain_lease_for(token) - Gitlab::ExclusiveLease - .new("check_gcp_project_billing_worker:#{token.hash}", timeout: LEASE_TIMEOUT) - .try_obtain - end - - def billing_changed_counter - @billing_changed_counter ||= Gitlab::Metrics.counter( - :gcp_billing_change_count, - "Counts the number of times a GCP project changed billing_enabled state from false to true", - BILLING_CHANGED_LABELS - ) - end - - def state_transition(previous_state, current_state) - if previous_state.nil? && !current_state - 'no_billing' - elsif previous_state.nil? && current_state - 'with_billing' - elsif !previous_state && current_state - 'billing_configured' - end - end - - def update_billing_change_counter(previous_state, current_state) - billing_changed_counter.increment(state_transition: state_transition(previous_state, current_state)) - end -end diff --git a/changelogs/unreleased/38759-fetch-available-parameters-directly-from-gke-when-creating-a-cluster.yml b/changelogs/unreleased/38759-fetch-available-parameters-directly-from-gke-when-creating-a-cluster.yml new file mode 100644 index 00000000000..e7d0d37becd --- /dev/null +++ b/changelogs/unreleased/38759-fetch-available-parameters-directly-from-gke-when-creating-a-cluster.yml @@ -0,0 +1,5 @@ +--- +title: Dynamically fetch GCP cluster creation parameters. +merge_request: 17806 +author: +type: changed diff --git a/lib/google_api/cloud_platform/client.rb b/lib/google_api/cloud_platform/client.rb index f30dd995695..36859b4d025 100644 --- a/lib/google_api/cloud_platform/client.rb +++ b/lib/google_api/cloud_platform/client.rb @@ -1,3 +1,4 @@ +require 'google/apis/compute_v1' require 'google/apis/container_v1' require 'google/apis/cloudbilling_v1' require 'google/apis/cloudresourcemanager_v1' @@ -42,22 +43,6 @@ module GoogleApi true end - def projects_list - service = Google::Apis::CloudresourcemanagerV1::CloudResourceManagerService.new - service.authorization = access_token - - service.fetch_all(items: :projects) do |token| - service.list_projects(page_token: token, options: user_agent_header) - end - end - - def projects_get_billing_info(project_id) - service = Google::Apis::CloudbillingV1::CloudbillingService.new - service.authorization = access_token - - service.get_project_billing_info("projects/#{project_id}", options: user_agent_header) - end - def projects_zones_clusters_get(project_id, zone, cluster_id) service = Google::Apis::ContainerV1::ContainerService.new service.authorization = access_token diff --git a/spec/controllers/projects/clusters/gcp_controller_spec.rb b/spec/controllers/projects/clusters/gcp_controller_spec.rb index 715bb9f5e52..271ba37aed4 100644 --- a/spec/controllers/projects/clusters/gcp_controller_spec.rb +++ b/spec/controllers/projects/clusters/gcp_controller_spec.rb @@ -77,8 +77,6 @@ describe Projects::Clusters::GcpController do end it 'has new object' do - expect(controller).to receive(:authorize_google_project_billing) - go expect(assigns(:cluster)).to be_an_instance_of(Clusters::Cluster) @@ -137,33 +135,15 @@ describe Projects::Clusters::GcpController do context 'when access token is valid' do before do stub_google_api_validate_token - allow_any_instance_of(described_class).to receive(:authorize_google_project_billing) - end - - context 'when google project billing is enabled' do - before do - redis_double = double.as_null_object - allow(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis_double) - allow(redis_double).to receive(:get).with(CheckGcpProjectBillingWorker.redis_shared_state_key_for('token')).and_return('true') - end - - it 'creates a new cluster' do - expect(ClusterProvisionWorker).to receive(:perform_async) - expect { go }.to change { Clusters::Cluster.count } - .and change { Clusters::Providers::Gcp.count } - expect(response).to redirect_to(project_cluster_path(project, project.clusters.first)) - expect(project.clusters.first).to be_gcp - expect(project.clusters.first).to be_kubernetes - end end - context 'when google project billing is not enabled' do - it 'renders the cluster form with an error' do - go - - expect(response).to set_flash.now[:alert] - expect(response).to render_template('new') - end + it 'creates a new cluster' do + expect(ClusterProvisionWorker).to receive(:perform_async) + expect { go }.to change { Clusters::Cluster.count } + .and change { Clusters::Providers::Gcp.count } + expect(response).to redirect_to(project_cluster_path(project, project.clusters.first)) + expect(project.clusters.first).to be_gcp + expect(project.clusters.first).to be_kubernetes end end diff --git a/spec/features/projects/clusters/gcp_spec.rb b/spec/features/projects/clusters/gcp_spec.rb index a8a627d8806..c85b82b2090 100644 --- a/spec/features/projects/clusters/gcp_spec.rb +++ b/spec/features/projects/clusters/gcp_spec.rb @@ -22,152 +22,123 @@ feature 'Gcp Cluster', :js do .to receive(:expires_at_in_session).and_return(1.hour.since.to_i.to_s) end - context 'when user has a GCP project with billing enabled' do + context 'when user does not have a cluster and visits cluster index page' do before do - allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing) - allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(true) - end - - context 'when user does not have a cluster and visits cluster index page' do - before do - visit project_clusters_path(project) - - click_link 'Add Kubernetes cluster' - click_link 'Create on Google Kubernetes Engine' - end - - context 'when user filled form with valid parameters' do - before do - allow_any_instance_of(GoogleApi::CloudPlatform::Client) - .to receive(:projects_zones_clusters_create) do - OpenStruct.new( - self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123', - status: 'RUNNING' - ) - end - - allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil) - - fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123' - fill_in 'cluster_name', with: 'dev-cluster' - click_button 'Create Kubernetes cluster' - end + visit project_clusters_path(project) - it 'user sees a cluster details page and creation status' do - expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...') + click_link 'Add Kubernetes cluster' + click_link 'Create on Google Kubernetes Engine' + end - Clusters::Cluster.last.provider.make_created! + context 'when user filled form with valid parameters' do + subject { click_button 'Create Kubernetes cluster' } - expect(page).to have_content('Kubernetes cluster was successfully created on Google Kubernetes Engine') + before do + allow_any_instance_of(GoogleApi::CloudPlatform::Client) + .to receive(:projects_zones_clusters_create) do + OpenStruct.new( + self_link: 'projects/gcp-project-12345/zones/us-central1-a/operations/ope-123', + status: 'RUNNING' + ) end - it 'user sees a error if something worng during creation' do - expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...') + allow(WaitForClusterCreationWorker).to receive(:perform_in).and_return(nil) - Clusters::Cluster.last.provider.make_errored!('Something wrong!') + execute_script('document.querySelector(".js-gke-cluster-creation-submit").removeAttribute("disabled")') + sleep 2 # wait for ajax + execute_script('document.querySelector(".js-gcp-project-id-dropdown input").setAttribute("type", "text")') + execute_script('document.querySelector(".js-gcp-zone-dropdown input").setAttribute("type", "text")') + execute_script('document.querySelector(".js-gcp-machine-type-dropdown input").setAttribute("type", "text")') - expect(page).to have_content('Something wrong!') - end + fill_in 'cluster[name]', with: 'dev-cluster' + fill_in 'cluster[provider_gcp_attributes][gcp_project_id]', with: 'gcp-project-123' + fill_in 'cluster[provider_gcp_attributes][zone]', with: 'us-central1-a' + fill_in 'cluster[provider_gcp_attributes][machine_type]', with: 'n1-standard-2' end - context 'when user filled form with invalid parameters' do - before do - click_button 'Create Kubernetes cluster' - end - - it 'user sees a validation error' do - expect(page).to have_css('#error_explanation') - end + it 'users sees a form with the GCP token' do + expect(page).to have_selector(:css, 'form[data-token="token"]') end - end - context 'when user does have a cluster and visits cluster page' do - let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } + it 'user sees a cluster details page and creation status' do + subject - before do - visit project_cluster_path(project, cluster) - end + expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...') + + Clusters::Cluster.last.provider.make_created! - it 'user sees a cluster details page' do - expect(page).to have_button('Save changes') - expect(page.find(:css, '.cluster-name').value).to eq(cluster.name) + expect(page).to have_content('Kubernetes cluster was successfully created on Google Kubernetes Engine') end - context 'when user disables the cluster' do - before do - page.find(:css, '.js-cluster-enable-toggle-area .js-project-feature-toggle').click - page.within('#cluster-integration') { click_button 'Save changes' } - end + it 'user sees a error if something wrong during creation' do + subject - it 'user sees the successful message' do - expect(page).to have_content('Kubernetes cluster was successfully updated.') - end - end + expect(page).to have_content('Kubernetes cluster is being created on Google Kubernetes Engine...') - context 'when user changes cluster parameters' do - before do - fill_in 'cluster_platform_kubernetes_attributes_namespace', with: 'my-namespace' - page.within('#js-cluster-details') { click_button 'Save changes' } - end + Clusters::Cluster.last.provider.make_errored!('Something wrong!') - it 'user sees the successful message' do - expect(page).to have_content('Kubernetes cluster was successfully updated.') - expect(cluster.reload.platform_kubernetes.namespace).to eq('my-namespace') - end + expect(page).to have_content('Something wrong!') end + end - context 'when user destroy the cluster' do - before do - page.accept_confirm do - click_link 'Remove integration' - end - end + context 'when user filled form with invalid parameters' do + before do + execute_script('document.querySelector(".js-gke-cluster-creation-submit").removeAttribute("disabled")') + click_button 'Create Kubernetes cluster' + end - it 'user sees creation form with the successful message' do - expect(page).to have_content('Kubernetes cluster integration was successfully removed.') - expect(page).to have_link('Add Kubernetes cluster') - end + it 'user sees a validation error' do + expect(page).to have_css('#error_explanation') end end end - context 'when user does not have a GCP project with billing enabled' do - before do - allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing) - allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(false) - - visit project_clusters_path(project) - - click_link 'Add Kubernetes cluster' - click_link 'Create on Google Kubernetes Engine' + context 'when user does have a cluster and visits cluster page' do + let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } - fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123' - fill_in 'cluster_name', with: 'dev-cluster' - click_button 'Create Kubernetes cluster' + before do + visit project_cluster_path(project, cluster) end - it 'user sees form with error' do - expect(page).to have_content('Please enable billing for one of your projects to be able to create a Kubernetes cluster, then try again.') + it 'user sees a cluster details page' do + expect(page).to have_button('Save changes') + expect(page.find(:css, '.cluster-name').value).to eq(cluster.name) end - end - context 'when gcp billing status is not in redis' do - before do - allow_any_instance_of(Projects::Clusters::GcpController).to receive(:authorize_google_project_billing) - allow_any_instance_of(Projects::Clusters::GcpController).to receive(:google_project_billing_status).and_return(nil) + context 'when user disables the cluster' do + before do + page.find(:css, '.js-cluster-enable-toggle-area .js-project-feature-toggle').click + page.within('#cluster-integration') { click_button 'Save changes' } + end - visit project_clusters_path(project) + it 'user sees the successful message' do + expect(page).to have_content('Kubernetes cluster was successfully updated.') + end + end - click_link 'Add Kubernetes cluster' - click_link 'Create on Google Kubernetes Engine' + context 'when user changes cluster parameters' do + before do + fill_in 'cluster_platform_kubernetes_attributes_namespace', with: 'my-namespace' + page.within('#js-cluster-details') { click_button 'Save changes' } + end - fill_in 'cluster_provider_gcp_attributes_gcp_project_id', with: 'gcp-project-123' - fill_in 'cluster_name', with: 'dev-cluster' - click_button 'Create Kubernetes cluster' + it 'user sees the successful message' do + expect(page).to have_content('Kubernetes cluster was successfully updated.') + expect(cluster.reload.platform_kubernetes.namespace).to eq('my-namespace') + end end - it 'user sees form with error' do - expect(page).to have_content('We could not verify that one of your projects on GCP has billing enabled. Please try again.') + context 'when user destroy the cluster' do + before do + page.accept_confirm do + click_link 'Remove integration' + end + end + + it 'user sees creation form with the successful message' do + expect(page).to have_content('Kubernetes cluster integration was successfully removed.') + expect(page).to have_link('Add Kubernetes cluster') + end end end end diff --git a/spec/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown_spec.js b/spec/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown_spec.js new file mode 100644 index 00000000000..21805ef0b28 --- /dev/null +++ b/spec/javascripts/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown_spec.js @@ -0,0 +1,103 @@ +import Vue from 'vue'; +import GkeMachineTypeDropdown from '~/projects/gke_cluster_dropdowns/components/gke_machine_type_dropdown.vue'; +import { createStore } from '~/projects/gke_cluster_dropdowns/store'; +import { + SET_PROJECT, + SET_PROJECT_BILLING_STATUS, + SET_ZONE, + SET_MACHINE_TYPES, +} from '~/projects/gke_cluster_dropdowns/store/mutation_types'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { + selectedZoneMock, + selectedProjectMock, + selectedMachineTypeMock, + gapiMachineTypesResponseMock, +} from '../mock_data'; + +const componentConfig = { + fieldId: 'cluster_provider_gcp_attributes_gcp_machine_type', + fieldName: 'cluster[provider_gcp_attributes][gcp_machine_type]', +}; + +const LABELS = { + LOADING: 'Fetching machine types', + DISABLED_NO_PROJECT: 'Select project and zone to choose machine type', + DISABLED_NO_ZONE: 'Select zone to choose machine type', + DEFAULT: 'Select machine type', +}; + +const createComponent = (store, props = componentConfig) => { + const Component = Vue.extend(GkeMachineTypeDropdown); + + return mountComponentWithStore(Component, { + el: null, + props, + store, + }); +}; + +describe('GkeMachineTypeDropdown', () => { + let vm; + let store; + + beforeEach(() => { + store = createStore(); + vm = createComponent(store); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('shows various toggle text depending on state', () => { + it('returns disabled state toggle text when no project and zone are selected', () => { + expect(vm.toggleText).toBe(LABELS.DISABLED_NO_PROJECT); + }); + + it('returns disabled state toggle text when no zone is selected', () => { + vm.$store.commit(SET_PROJECT, selectedProjectMock); + vm.$store.commit(SET_PROJECT_BILLING_STATUS, true); + + expect(vm.toggleText).toBe(LABELS.DISABLED_NO_ZONE); + }); + + it('returns loading toggle text', () => { + vm.isLoading = true; + + expect(vm.toggleText).toBe(LABELS.LOADING); + }); + + it('returns default toggle text', () => { + expect(vm.toggleText).toBe(LABELS.DISABLED_NO_PROJECT); + + vm.$store.commit(SET_PROJECT, selectedProjectMock); + vm.$store.commit(SET_PROJECT_BILLING_STATUS, true); + vm.$store.commit(SET_ZONE, selectedZoneMock); + + expect(vm.toggleText).toBe(LABELS.DEFAULT); + }); + + it('returns machine type name if machine type selected', () => { + vm.setItem(selectedMachineTypeMock); + + expect(vm.toggleText).toBe(selectedMachineTypeMock); + }); + }); + + describe('form input', () => { + it('reflects new value when dropdown item is clicked', done => { + expect(vm.$el.querySelector('input').value).toBe(''); + vm.$store.commit(SET_MACHINE_TYPES, gapiMachineTypesResponseMock.items); + + return vm.$nextTick().then(() => { + vm.$el.querySelector('.dropdown-content button').click(); + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('input').value).toBe(selectedMachineTypeMock); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown_spec.js b/spec/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown_spec.js new file mode 100644 index 00000000000..d4fcb2dc8ff --- /dev/null +++ b/spec/javascripts/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown_spec.js @@ -0,0 +1,92 @@ +import Vue from 'vue'; +import GkeProjectIdDropdown from '~/projects/gke_cluster_dropdowns/components/gke_project_id_dropdown.vue'; +import { createStore } from '~/projects/gke_cluster_dropdowns/store'; +import { SET_PROJECTS } from '~/projects/gke_cluster_dropdowns/store/mutation_types'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { emptyProjectMock, selectedProjectMock } from '../mock_data'; + +const componentConfig = { + docsUrl: 'https://console.cloud.google.com/home/dashboard', + fieldId: 'cluster_provider_gcp_attributes_gcp_project_id', + fieldName: 'cluster[provider_gcp_attributes][gcp_project_id]', +}; + +const LABELS = { + LOADING: 'Fetching projects', + VALIDATING_PROJECT_BILLING: 'Validating project billing status', + DEFAULT: 'Select project', + EMPTY: 'No projects found', +}; + +const createComponent = (store, props = componentConfig) => { + const Component = Vue.extend(GkeProjectIdDropdown); + + return mountComponentWithStore(Component, { + el: null, + props, + store, + }); +}; + +describe('GkeProjectIdDropdown', () => { + let vm; + let store; + + beforeEach(() => { + store = createStore(); + vm = createComponent(store); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('toggleText', () => { + it('returns loading toggle text', () => { + expect(vm.toggleText).toBe(LABELS.LOADING); + }); + + it('returns project billing validation text', () => { + vm.setIsValidatingProjectBilling(true); + expect(vm.toggleText).toBe(LABELS.VALIDATING_PROJECT_BILLING); + }); + + it('returns default toggle text', done => + vm.$nextTick().then(() => { + vm.setItem(emptyProjectMock); + + expect(vm.toggleText).toBe(LABELS.DEFAULT); + done(); + })); + + it('returns project name if project selected', done => + vm.$nextTick().then(() => { + expect(vm.toggleText).toBe(selectedProjectMock.name); + done(); + })); + + it('returns empty toggle text', done => + vm.$nextTick().then(() => { + vm.$store.commit(SET_PROJECTS, null); + vm.setItem(emptyProjectMock); + + expect(vm.toggleText).toBe(LABELS.EMPTY); + done(); + })); + }); + + describe('selectItem', () => { + it('reflects new value when dropdown item is clicked', done => { + expect(vm.$el.querySelector('input').value).toBe(''); + + return vm.$nextTick().then(() => { + vm.$el.querySelector('.dropdown-content button').click(); + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('input').value).toBe(selectedProjectMock.projectId); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown_spec.js b/spec/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown_spec.js new file mode 100644 index 00000000000..89a4a7ea2ce --- /dev/null +++ b/spec/javascripts/projects/gke_cluster_dropdowns/components/gke_zone_dropdown_spec.js @@ -0,0 +1,88 @@ +import Vue from 'vue'; +import GkeZoneDropdown from '~/projects/gke_cluster_dropdowns/components/gke_zone_dropdown.vue'; +import { createStore } from '~/projects/gke_cluster_dropdowns/store'; +import { + SET_PROJECT, + SET_ZONES, + SET_PROJECT_BILLING_STATUS, +} from '~/projects/gke_cluster_dropdowns/store/mutation_types'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { selectedZoneMock, selectedProjectMock, gapiZonesResponseMock } from '../mock_data'; + +const componentConfig = { + fieldId: 'cluster_provider_gcp_attributes_gcp_zone', + fieldName: 'cluster[provider_gcp_attributes][gcp_zone]', +}; + +const LABELS = { + LOADING: 'Fetching zones', + DISABLED: 'Select project to choose zone', + DEFAULT: 'Select zone', +}; + +const createComponent = (store, props = componentConfig) => { + const Component = Vue.extend(GkeZoneDropdown); + + return mountComponentWithStore(Component, { + el: null, + props, + store, + }); +}; + +describe('GkeZoneDropdown', () => { + let vm; + let store; + + beforeEach(() => { + store = createStore(); + vm = createComponent(store); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('toggleText', () => { + it('returns disabled state toggle text', () => { + expect(vm.toggleText).toBe(LABELS.DISABLED); + }); + + it('returns loading toggle text', () => { + vm.isLoading = true; + + expect(vm.toggleText).toBe(LABELS.LOADING); + }); + + it('returns default toggle text', () => { + expect(vm.toggleText).toBe(LABELS.DISABLED); + + vm.$store.commit(SET_PROJECT, selectedProjectMock); + vm.$store.commit(SET_PROJECT_BILLING_STATUS, true); + + expect(vm.toggleText).toBe(LABELS.DEFAULT); + }); + + it('returns project name if project selected', () => { + vm.setItem(selectedZoneMock); + + expect(vm.toggleText).toBe(selectedZoneMock); + }); + }); + + describe('selectItem', () => { + it('reflects new value when dropdown item is clicked', done => { + expect(vm.$el.querySelector('input').value).toBe(''); + vm.$store.commit(SET_ZONES, gapiZonesResponseMock.items); + + return vm.$nextTick().then(() => { + vm.$el.querySelector('.dropdown-content button').click(); + + return vm.$nextTick().then(() => { + expect(vm.$el.querySelector('input').value).toBe(selectedZoneMock); + done(); + }); + }); + }); + }); +}); diff --git a/spec/javascripts/projects/gke_cluster_dropdowns/helpers.js b/spec/javascripts/projects/gke_cluster_dropdowns/helpers.js new file mode 100644 index 00000000000..6df511e9157 --- /dev/null +++ b/spec/javascripts/projects/gke_cluster_dropdowns/helpers.js @@ -0,0 +1,49 @@ +import { + gapiProjectsResponseMock, + gapiZonesResponseMock, + gapiMachineTypesResponseMock, +} from './mock_data'; + +// eslint-disable-next-line import/prefer-default-export +export const gapi = () => ({ + client: { + cloudbilling: { + projects: { + getBillingInfo: () => + new Promise(resolve => { + resolve({ + result: { billingEnabled: true }, + }); + }), + }, + }, + cloudresourcemanager: { + projects: { + list: () => + new Promise(resolve => { + resolve({ + result: { ...gapiProjectsResponseMock }, + }); + }), + }, + }, + compute: { + zones: { + list: () => + new Promise(resolve => { + resolve({ + result: { ...gapiZonesResponseMock }, + }); + }), + }, + machineTypes: { + list: () => + new Promise(resolve => { + resolve({ + result: { ...gapiMachineTypesResponseMock }, + }); + }), + }, + }, + }, +}); diff --git a/spec/javascripts/projects/gke_cluster_dropdowns/mock_data.js b/spec/javascripts/projects/gke_cluster_dropdowns/mock_data.js new file mode 100644 index 00000000000..d9f5dbc636f --- /dev/null +++ b/spec/javascripts/projects/gke_cluster_dropdowns/mock_data.js @@ -0,0 +1,75 @@ +export const emptyProjectMock = { + projectId: '', + name: '', +}; + +export const selectedProjectMock = { + projectId: 'gcp-project-123', + name: 'gcp-project', +}; + +export const selectedZoneMock = 'us-central1-a'; + +export const selectedMachineTypeMock = 'n1-standard-2'; + +export const gapiProjectsResponseMock = { + projects: [ + { + projectNumber: '1234', + projectId: 'gcp-project-123', + lifecycleState: 'ACTIVE', + name: 'gcp-project', + createTime: '2017-12-16T01:48:29.129Z', + parent: { + type: 'organization', + id: '12345', + }, + }, + ], +}; + +export const gapiZonesResponseMock = { + kind: 'compute#zoneList', + id: 'projects/gitlab-internal-153318/zones', + items: [ + { + kind: 'compute#zone', + id: '2000', + creationTimestamp: '1969-12-31T16:00:00.000-08:00', + name: 'us-central1-a', + description: 'us-central1-a', + status: 'UP', + region: + 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/regions/us-central1', + selfLink: + 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a', + availableCpuPlatforms: ['Intel Skylake', 'Intel Broadwell', 'Intel Sandy Bridge'], + }, + ], + selfLink: 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones', +}; + +export const gapiMachineTypesResponseMock = { + kind: 'compute#machineTypeList', + id: 'projects/gitlab-internal-153318/zones/us-central1-a/machineTypes', + items: [ + { + kind: 'compute#machineType', + id: '3002', + creationTimestamp: '1969-12-31T16:00:00.000-08:00', + name: 'n1-standard-2', + description: '2 vCPUs, 7.5 GB RAM', + guestCpus: 2, + memoryMb: 7680, + imageSpaceGb: 10, + maximumPersistentDisks: 64, + maximumPersistentDisksSizeGb: '65536', + zone: 'us-central1-a', + selfLink: + 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes/n1-standard-2', + isSharedCpu: false, + }, + ], + selfLink: + 'https://www.googleapis.com/compute/v1/projects/gitlab-internal-153318/zones/us-central1-a/machineTypes', +}; diff --git a/spec/javascripts/projects/gke_cluster_dropdowns/stores/actions_spec.js b/spec/javascripts/projects/gke_cluster_dropdowns/stores/actions_spec.js new file mode 100644 index 00000000000..9d892b8185b --- /dev/null +++ b/spec/javascripts/projects/gke_cluster_dropdowns/stores/actions_spec.js @@ -0,0 +1,131 @@ +import testAction from 'spec/helpers/vuex_action_helper'; +import * as actions from '~/projects/gke_cluster_dropdowns/store/actions'; +import { createStore } from '~/projects/gke_cluster_dropdowns/store'; +import { gapi } from '../helpers'; +import { selectedProjectMock, selectedZoneMock, selectedMachineTypeMock } from '../mock_data'; + +describe('GCP Cluster Dropdown Store Actions', () => { + let store; + + beforeEach(() => { + store = createStore(); + }); + + describe('setProject', () => { + it('should set project', done => { + testAction( + actions.setProject, + selectedProjectMock, + { selectedProject: {} }, + [{ type: 'SET_PROJECT', payload: selectedProjectMock }], + [], + done, + ); + }); + }); + + describe('setZone', () => { + it('should set zone', done => { + testAction( + actions.setZone, + selectedZoneMock, + { selectedZone: '' }, + [{ type: 'SET_ZONE', payload: selectedZoneMock }], + [], + done, + ); + }); + }); + + describe('setMachineType', () => { + it('should set machine type', done => { + testAction( + actions.setMachineType, + selectedMachineTypeMock, + { selectedMachineType: '' }, + [{ type: 'SET_MACHINE_TYPE', payload: selectedMachineTypeMock }], + [], + done, + ); + }); + }); + + describe('setIsValidatingProjectBilling', () => { + it('should set machine type', done => { + testAction( + actions.setIsValidatingProjectBilling, + true, + { isValidatingProjectBilling: null }, + [{ type: 'SET_IS_VALIDATING_PROJECT_BILLING', payload: true }], + [], + done, + ); + }); + }); + + describe('async fetch methods', () => { + window.gapi = gapi(); + + describe('fetchProjects', () => { + it('fetches projects from Google API', done => { + store + .dispatch('fetchProjects') + .then(() => { + expect(store.state.projects[0].projectId).toEqual(selectedProjectMock.projectId); + expect(store.state.projects[0].name).toEqual(selectedProjectMock.name); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('validateProjectBilling', () => { + it('checks project billing status from Google API', done => { + testAction( + actions.validateProjectBilling, + true, + { + selectedProject: selectedProjectMock, + selectedZone: '', + selectedMachineType: '', + projectHasBillingEnabled: null, + }, + [ + { type: 'SET_ZONE', payload: '' }, + { type: 'SET_MACHINE_TYPE', payload: '' }, + { type: 'SET_PROJECT_BILLING_STATUS', payload: true }, + ], + [{ type: 'setIsValidatingProjectBilling', payload: false }], + done, + ); + }); + }); + + describe('fetchZones', () => { + it('fetches zones from Google API', done => { + store + .dispatch('fetchZones') + .then(() => { + expect(store.state.zones[0].name).toEqual(selectedZoneMock); + + done(); + }) + .catch(done.fail); + }); + }); + + describe('fetchMachineTypes', () => { + it('fetches machine types from Google API', done => { + store + .dispatch('fetchMachineTypes') + .then(() => { + expect(store.state.machineTypes[0].name).toEqual(selectedMachineTypeMock); + + done(); + }) + .catch(done.fail); + }); + }); + }); +}); diff --git a/spec/javascripts/projects/gke_cluster_dropdowns/stores/getters_spec.js b/spec/javascripts/projects/gke_cluster_dropdowns/stores/getters_spec.js new file mode 100644 index 00000000000..6f89158f807 --- /dev/null +++ b/spec/javascripts/projects/gke_cluster_dropdowns/stores/getters_spec.js @@ -0,0 +1,65 @@ +import * as getters from '~/projects/gke_cluster_dropdowns/store/getters'; +import { selectedProjectMock, selectedZoneMock, selectedMachineTypeMock } from '../mock_data'; + +describe('GCP Cluster Dropdown Store Getters', () => { + let state; + + describe('valid states', () => { + beforeEach(() => { + state = { + selectedProject: selectedProjectMock, + selectedZone: selectedZoneMock, + selectedMachineType: selectedMachineTypeMock, + }; + }); + + describe('hasProject', () => { + it('should return true when project is selected', () => { + expect(getters.hasProject(state)).toEqual(true); + }); + }); + + describe('hasZone', () => { + it('should return true when zone is selected', () => { + expect(getters.hasZone(state)).toEqual(true); + }); + }); + + describe('hasMachineType', () => { + it('should return true when machine type is selected', () => { + expect(getters.hasMachineType(state)).toEqual(true); + }); + }); + }); + + describe('invalid states', () => { + beforeEach(() => { + state = { + selectedProject: { + projectId: '', + name: '', + }, + selectedZone: '', + selectedMachineType: '', + }; + }); + + describe('hasProject', () => { + it('should return false when project is not selected', () => { + expect(getters.hasProject(state)).toEqual(false); + }); + }); + + describe('hasZone', () => { + it('should return false when zone is not selected', () => { + expect(getters.hasZone(state)).toEqual(false); + }); + }); + + describe('hasMachineType', () => { + it('should return false when machine type is not selected', () => { + expect(getters.hasMachineType(state)).toEqual(false); + }); + }); + }); +}); diff --git a/spec/javascripts/projects/gke_cluster_dropdowns/stores/mutations_spec.js b/spec/javascripts/projects/gke_cluster_dropdowns/stores/mutations_spec.js new file mode 100644 index 00000000000..7f8c4f314e4 --- /dev/null +++ b/spec/javascripts/projects/gke_cluster_dropdowns/stores/mutations_spec.js @@ -0,0 +1,87 @@ +import { createStore } from '~/projects/gke_cluster_dropdowns/store'; +import * as types from '~/projects/gke_cluster_dropdowns/store/mutation_types'; +import { + selectedProjectMock, + selectedZoneMock, + selectedMachineTypeMock, + gapiProjectsResponseMock, + gapiZonesResponseMock, + gapiMachineTypesResponseMock, +} from '../mock_data'; + +describe('GCP Cluster Dropdown Store Mutations', () => { + let store; + + beforeEach(() => { + store = createStore(); + }); + + describe('SET_PROJECT', () => { + it('should set GCP project as selectedProject', () => { + const projectToSelect = gapiProjectsResponseMock.projects[0]; + + store.commit(types.SET_PROJECT, projectToSelect); + + expect(store.state.selectedProject.projectId).toEqual(selectedProjectMock.projectId); + expect(store.state.selectedProject.name).toEqual(selectedProjectMock.name); + }); + }); + + describe('SET_PROJECT_BILLING_STATUS', () => { + it('should set project billing status', () => { + store.commit(types.SET_PROJECT_BILLING_STATUS, true); + + expect(store.state.projectHasBillingEnabled).toBeTruthy(); + }); + }); + + describe('SET_ZONE', () => { + it('should set GCP zone as selectedZone', () => { + const zoneToSelect = gapiZonesResponseMock.items[0].name; + + store.commit(types.SET_ZONE, zoneToSelect); + + expect(store.state.selectedZone).toEqual(selectedZoneMock); + }); + }); + + describe('SET_MACHINE_TYPE', () => { + it('should set GCP machine type as selectedMachineType', () => { + const machineTypeToSelect = gapiMachineTypesResponseMock.items[0].name; + + store.commit(types.SET_MACHINE_TYPE, machineTypeToSelect); + + expect(store.state.selectedMachineType).toEqual(selectedMachineTypeMock); + }); + }); + + describe('SET_PROJECTS', () => { + it('should set Google API Projects response as projects', () => { + expect(store.state.projects.length).toEqual(0); + + store.commit(types.SET_PROJECTS, gapiProjectsResponseMock.projects); + + expect(store.state.projects.length).toEqual(gapiProjectsResponseMock.projects.length); + }); + }); + + describe('SET_ZONES', () => { + it('should set Google API Zones response as zones', () => { + expect(store.state.zones.length).toEqual(0); + + store.commit(types.SET_ZONES, gapiZonesResponseMock.items); + + expect(store.state.zones.length).toEqual(gapiZonesResponseMock.items.length); + }); + }); + + describe('SET_MACHINE_TYPES', () => { + it('should set Google API Machine Types response as machineTypes', () => { + expect(store.state.machineTypes.length).toEqual(0); + + store.commit(types.SET_MACHINE_TYPES, gapiMachineTypesResponseMock.items); + + expect(store.state.machineTypes.length).toEqual(gapiMachineTypesResponseMock.items.length); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js new file mode 100644 index 00000000000..ba897f4660d --- /dev/null +++ b/spec/javascripts/vue_shared/components/dropdown/dropdown_button_spec.js @@ -0,0 +1,69 @@ +import Vue from 'vue'; + +import dropdownButtonComponent from '~/vue_shared/components/dropdown/dropdown_button.vue'; + +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +const defaultLabel = 'Select'; +const customLabel = 'Select project'; + +const createComponent = config => { + const Component = Vue.extend(dropdownButtonComponent); + + return mountComponent(Component, config); +}; + +describe('DropdownButtonComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('dropdownToggleText', () => { + it('returns default toggle text', () => { + expect(vm.toggleText).toBe(defaultLabel); + }); + + it('returns custom toggle text when provided via props', () => { + const vmEmptyLabels = createComponent({ toggleText: customLabel }); + + expect(vmEmptyLabels.toggleText).toBe(customLabel); + vmEmptyLabels.$destroy(); + }); + }); + }); + + describe('template', () => { + it('renders component container element of type `button`', () => { + expect(vm.$el.nodeName).toBe('BUTTON'); + }); + + it('renders component container element with required data attributes', () => { + expect(vm.$el.dataset.abilityName).toBe(vm.abilityName); + expect(vm.$el.dataset.fieldName).toBe(vm.fieldName); + expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath); + expect(vm.$el.dataset.labels).toBe(vm.labelsPath); + expect(vm.$el.dataset.namespacePath).toBe(vm.namespace); + expect(vm.$el.dataset.showAny).not.toBeDefined(); + }); + + it('renders dropdown toggle text element', () => { + const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text'); + expect(dropdownToggleTextEl).not.toBeNull(); + expect(dropdownToggleTextEl.innerText.trim()).toBe(defaultLabel); + }); + + it('renders dropdown button icon', () => { + const dropdownIconEl = vm.$el.querySelector('.dropdown-toggle-icon i.fa'); + + expect(dropdownIconEl).not.toBeNull(); + expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js b/spec/javascripts/vue_shared/components/dropdown/dropdown_hidden_input_spec.js index 88733922a59..445ab0cb40e 100644 --- a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js +++ b/spec/javascripts/vue_shared/components/dropdown/dropdown_hidden_input_spec.js @@ -1,17 +1,17 @@ import Vue from 'vue'; -import dropdownHiddenInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue'; +import dropdownHiddenInputComponent from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; import { mockLabels } from './mock_data'; -const createComponent = (name = 'label_id[]', label = mockLabels[0]) => { +const createComponent = (name = 'label_id[]', value = mockLabels[0].id) => { const Component = Vue.extend(dropdownHiddenInputComponent); return mountComponent(Component, { name, - label, + value, }); }; @@ -31,7 +31,7 @@ describe('DropdownHiddenInputComponent', () => { expect(vm.$el.nodeName).toBe('INPUT'); expect(vm.$el.getAttribute('type')).toBe('hidden'); expect(vm.$el.getAttribute('name')).toBe(vm.name); - expect(vm.$el.getAttribute('value')).toBe(`${vm.label.id}`); + expect(vm.$el.getAttribute('value')).toBe(`${vm.value}`); }); }); }); diff --git a/spec/javascripts/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/javascripts/vue_shared/components/dropdown/dropdown_search_input_spec.js new file mode 100644 index 00000000000..551520721e5 --- /dev/null +++ b/spec/javascripts/vue_shared/components/dropdown/dropdown_search_input_spec.js @@ -0,0 +1,52 @@ +import Vue from 'vue'; + +import dropdownSearchInputComponent from '~/vue_shared/components/dropdown/dropdown_search_input.vue'; + +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +const componentConfig = { + placeholderText: 'Search something', +}; + +const createComponent = (config = componentConfig) => { + const Component = Vue.extend(dropdownSearchInputComponent); + + return mountComponent(Component, config); +}; + +describe('DropdownSearchInputComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('renders input element with type `search`', () => { + const inputEl = vm.$el.querySelector('input.dropdown-input-field'); + + expect(inputEl).not.toBeNull(); + expect(inputEl.getAttribute('type')).toBe('search'); + }); + + it('renders search icon element', () => { + expect(vm.$el.querySelector('.fa-search.dropdown-input-search')).not.toBeNull(); + }); + + it('renders clear search icon element', () => { + expect( + vm.$el.querySelector('.fa-times.dropdown-input-clear.js-dropdown-input-clear'), + ).not.toBeNull(); + }); + + it('displays custom placeholder text', () => { + const inputEl = vm.$el.querySelector('input.dropdown-input-field'); + + expect(inputEl.getAttribute('placeholder')).toBe(componentConfig.placeholderText); + }); + }); +}); diff --git a/spec/javascripts/vue_shared/components/dropdown/mock_data.js b/spec/javascripts/vue_shared/components/dropdown/mock_data.js new file mode 100644 index 00000000000..b09d42da401 --- /dev/null +++ b/spec/javascripts/vue_shared/components/dropdown/mock_data.js @@ -0,0 +1,11 @@ +export const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, +]; + +export default mockLabels; diff --git a/spec/lib/google_api/cloud_platform/client_spec.rb b/spec/lib/google_api/cloud_platform/client_spec.rb index db9d9158b29..27cb3198e5b 100644 --- a/spec/lib/google_api/cloud_platform/client_spec.rb +++ b/spec/lib/google_api/cloud_platform/client_spec.rb @@ -50,30 +50,6 @@ describe GoogleApi::CloudPlatform::Client do end end - describe '#projects_list' do - subject { client.projects_list } - let(:projects) { double } - - before do - allow_any_instance_of(Google::Apis::CloudresourcemanagerV1::CloudResourceManagerService) - .to receive(:fetch_all).and_return(projects) - end - - it { is_expected.to eq(projects) } - end - - describe '#projects_get_billing_info' do - subject { client.projects_get_billing_info('project') } - let(:billing_info) { double } - - before do - allow_any_instance_of(Google::Apis::CloudbillingV1::CloudbillingService) - .to receive(:get_project_billing_info).and_return(billing_info) - end - - it { is_expected.to eq(billing_info) } - end - describe '#projects_zones_clusters_get' do subject { client.projects_zones_clusters_get(spy, spy, spy) } let(:gke_cluster) { double } diff --git a/spec/services/check_gcp_project_billing_service_spec.rb b/spec/services/check_gcp_project_billing_service_spec.rb deleted file mode 100644 index 3e68d906e71..00000000000 --- a/spec/services/check_gcp_project_billing_service_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require 'spec_helper' - -describe CheckGcpProjectBillingService do - include GoogleApi::CloudPlatformHelpers - - let(:service) { described_class.new } - let(:project_id) { 'test-project-1234' } - - describe '#execute' do - before do - stub_cloud_platform_projects_list(project_id: project_id) - end - - subject { service.execute('bogustoken') } - - context 'google account has a billing enabled gcp project' do - before do - stub_cloud_platform_projects_get_billing_info(project_id, true) - end - - it { is_expected.to all(satisfy { |project| project.project_id == project_id }) } - end - - context 'google account does not have a billing enabled gcp project' do - before do - stub_cloud_platform_projects_get_billing_info(project_id, false) - end - - it { is_expected.to eq([]) } - end - end -end diff --git a/spec/workers/check_gcp_project_billing_worker_spec.rb b/spec/workers/check_gcp_project_billing_worker_spec.rb deleted file mode 100644 index 526ecf75921..00000000000 --- a/spec/workers/check_gcp_project_billing_worker_spec.rb +++ /dev/null @@ -1,116 +0,0 @@ -require 'spec_helper' - -describe CheckGcpProjectBillingWorker do - describe '.perform' do - let(:token) { 'bogustoken' } - - subject { described_class.new.perform('token_key') } - - before do - allow(described_class).to receive(:get_billing_state) - allow_any_instance_of(described_class).to receive(:update_billing_change_counter) - end - - context 'when there is a token in redis' do - before do - allow(described_class).to receive(:get_session_token).and_return(token) - end - - context 'when there is no lease' do - before do - allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return('randomuuid') - end - - it 'calls the service' do - expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double]) - - subject - end - - it 'stores billing status in redis' do - expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double]) - expect(described_class).to receive(:set_billing_state).with(token, true) - - subject - end - end - - context 'when there is a lease' do - before do - allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return(false) - end - - it 'does not call the service' do - expect(CheckGcpProjectBillingService).not_to receive(:new) - - subject - end - end - end - - context 'when there is no token in redis' do - before do - allow(described_class).to receive(:get_session_token).and_return(nil) - end - - it 'does not call the service' do - expect(CheckGcpProjectBillingService).not_to receive(:new) - - subject - end - end - end - - describe 'billing change counter' do - subject { described_class.new.perform('token_key') } - - before do - allow(described_class).to receive(:get_session_token).and_return('bogustoken') - allow_any_instance_of(described_class).to receive(:try_obtain_lease_for).and_return('randomuuid') - allow(described_class).to receive(:set_billing_state) - end - - context 'when previous state was false' do - before do - expect(described_class).to receive(:get_billing_state).and_return(false) - end - - context 'when the current state is false' do - before do - expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([]) - end - - it 'increments the billing change counter' do - expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment) - - subject - end - end - - context 'when the current state is true' do - before do - expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double]) - end - - it 'increments the billing change counter' do - expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment) - - subject - end - end - end - - context 'when previous state was true' do - before do - expect(described_class).to receive(:get_billing_state).and_return(true) - expect(CheckGcpProjectBillingService).to receive_message_chain(:new, :execute).and_return([double]) - end - - it 'increment the billing change counter' do - expect_any_instance_of(described_class).to receive_message_chain(:billing_changed_counter, :increment) - - subject - end - end - end -end |