diff --git a/app/assets/javascripts/clusters_list/components/clusters.vue b/app/assets/javascripts/clusters_list/components/clusters.vue index 0d1534d20e066cc97404a359d9005a249af3c84f..cf0587341b58d3922bed6a6559bdca2454c1f0fb 100644 --- a/app/assets/javascripts/clusters_list/components/clusters.vue +++ b/app/assets/javascripts/clusters_list/components/clusters.vue @@ -14,6 +14,7 @@ import { __, sprintf } from '~/locale'; import { CLUSTER_TYPES, STATUSES } from '../constants'; import AncestorNotice from './ancestor_notice.vue'; import NodeErrorHelpText from './node_error_help_text.vue'; +import ClustersEmptyState from './clusters_empty_state.vue'; export default { nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'), @@ -28,6 +29,7 @@ export default { GlSprintf, GlTable, NodeErrorHelpText, + ClustersEmptyState, }, directives: { GlTooltip: GlTooltipDirective, @@ -40,7 +42,7 @@ export default { 'loadingNodes', 'page', 'providers', - 'totalCulsters', + 'totalClusters', ]), contentAlignClasses() { return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start'; @@ -83,9 +85,12 @@ export default { }, ]; }, - hasClusters() { + hasClustersPerPage() { return this.clustersPerPage > 0; }, + hasClusters() { + return this.totalClusters > 0; + }, }, mounted() { this.fetchClusters(); @@ -202,6 +207,7 @@ export default { - - - + + +import { GlEmptyState, GlButton, GlLink } from '@gitlab/ui'; +import { mapState } from 'vuex'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export default { + i18n: { + title: s__('ClusterIntegration|Integrate Kubernetes with a cluster certificate'), + description: s__( + 'ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way.', + ), + learnMoreLinkText: s__('ClusterIntegration|Learn more about Kubernetes'), + buttonText: s__('ClusterIntegration|Integrate with a cluster certificate'), + }, + components: { + GlEmptyState, + GlButton, + GlLink, + }, + inject: ['emptyStateHelpText', 'clustersEmptyStateImage', 'newClusterPath'], + learnMoreHelpUrl: helpPagePath('user/project/clusters/index'), + computed: { + ...mapState(['canAddCluster']), + }, +}; + + + + + + + {{ $options.i18n.description }} + + + + {{ emptyStateHelpText }} + + + + + {{ $options.i18n.learnMoreLinkText }} + + + + + + + {{ $options.i18n.buttonText }} + + + + diff --git a/app/assets/javascripts/clusters_list/load_clusters.js b/app/assets/javascripts/clusters_list/load_clusters.js index 014302308793c4daa703980b26a03cdd1990e38e..1bb3ea546b23e5fdffd927c6888afe4618a6a60a 100644 --- a/app/assets/javascripts/clusters_list/load_clusters.js +++ b/app/assets/javascripts/clusters_list/load_clusters.js @@ -8,8 +8,15 @@ export default (Vue) => { return null; } + const { emptyStateHelpText, newClusterPath, clustersEmptyStateImage } = el.dataset; + return new Vue({ el, + provide: { + emptyStateHelpText, + newClusterPath, + clustersEmptyStateImage, + }, store: createStore(el.dataset), render(createElement) { return createElement(Clusters); diff --git a/app/assets/javascripts/clusters_list/store/mutations.js b/app/assets/javascripts/clusters_list/store/mutations.js index 5b46292851859481ad0b82faf28edaeffd2a7be2..e5c15ccbd6e4b281cf1f4739d21920a99e35ae21 100644 --- a/app/assets/javascripts/clusters_list/store/mutations.js +++ b/app/assets/javascripts/clusters_list/store/mutations.js @@ -12,7 +12,7 @@ export default { clusters: data.clusters, clustersPerPage: paginationInformation.perPage, hasAncestorClusters: data.has_ancestor_clusters, - totalCulsters: paginationInformation.total, + totalClusters: paginationInformation.total, }); }, [types.SET_PAGE](state, value) { diff --git a/app/assets/javascripts/clusters_list/store/state.js b/app/assets/javascripts/clusters_list/store/state.js index 51fafd49479fbde625f8e3c1df19279eb69caa7f..3dcbf58c8d39e6f6229cd66d21db94f9402cd519 100644 --- a/app/assets/javascripts/clusters_list/store/state.js +++ b/app/assets/javascripts/clusters_list/store/state.js @@ -1,3 +1,5 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + export default (initialState = {}) => ({ ancestorHelperPath: initialState.ancestorHelpPath, endpoint: initialState.endpoint, @@ -12,5 +14,6 @@ export default (initialState = {}) => ({ default: { path: initialState.imgTagsDefaultPath, text: initialState.imgTagsDefaultText }, gcp: { path: initialState.imgTagsGcpPath, text: initialState.imgTagsGcpText }, }, - totalCulsters: 0, + totalClusters: 0, + canAddCluster: parseBoolean(initialState.canAddCluster), }); diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 9d5f441ec568b390deaa2c940f52ac91e148c852..cfdf4b3c8d290f2c852a8553feeeca9740e47e63 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -29,15 +29,19 @@ def js_cluster_agents_list_data(clusterable_project) } end - def js_clusters_list_data(path = nil) + def js_clusters_list_data(clusterable) { ancestor_help_path: help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'), - endpoint: path, + endpoint: clusterable.index_path(format: :json), img_tags: { aws: { path: image_path('illustrations/logos/amazon_eks.svg'), text: s_('ClusterIntegration|Amazon EKS') }, default: { path: image_path('illustrations/logos/kubernetes.svg'), text: _('Kubernetes Cluster') }, gcp: { path: image_path('illustrations/logos/google_gke.svg'), text: s_('ClusterIntegration|Google GKE') } - } + }, + clusters_empty_state_image: image_path('illustrations/clusters_empty.svg'), + empty_state_help_text: clusterable.empty_state_help_text, + new_cluster_path: clusterable.new_path(tab: 'create'), + can_add_cluster: clusterable.can_add_cluster?.to_s } end diff --git a/app/views/clusters/clusters/_cluster_list.html.haml b/app/views/clusters/clusters/_cluster_list.html.haml index 38ed7e334c954d9143f35f5a5ca5af3babca266f..7657b72b619a2a1c4da0238c4f5ee91b834ad288 100644 --- a/app/views/clusters/clusters/_cluster_list.html.haml +++ b/app/views/clusters/clusters/_cluster_list.html.haml @@ -1,6 +1,4 @@ -- if clusters.empty? - = render 'empty_state' -- else +- if !clusters.empty? .top-area.adjust .gl-display-block.gl-text-right.gl-my-4.gl-w-full - if clusterable.can_add_cluster? @@ -9,4 +7,4 @@ %span.btn.gl-button.btn-confirm.js-add-cluster.disabled.gl-py-2 = s_("ClusterIntegration|Connect cluster with certificate") - #js-clusters-list-app{ data: js_clusters_list_data(clusterable.index_path(format: :json)) } +#js-clusters-list-app{ data: js_clusters_list_data(clusterable) } diff --git a/app/views/clusters/clusters/_empty_state.html.haml b/app/views/clusters/clusters/_empty_state.html.haml deleted file mode 100644 index feef3e0027fbef7265cd66ef60ed30ad8bc5c3ce..0000000000000000000000000000000000000000 --- a/app/views/clusters/clusters/_empty_state.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -.row.empty-state - .col-12 - .svg-content= image_tag 'illustrations/clusters_empty.svg' - .col-12 - .text-content - %h4.gl-text-center= s_('ClusterIntegration|Integrate Kubernetes with a cluster certificate') - %p.gl-text-center - = s_('ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way.') - = clusterable.empty_state_help_text - = clusterable.learn_more_link - - - if clusterable.can_add_cluster? - .gl-text-center - = link_to s_('ClusterIntegration|Integrate with a cluster certificate'), clusterable.new_path, class: 'gl-button btn btn-confirm', data: { qa_selector: 'add_kubernetes_cluster_link' } diff --git a/qa/qa/page/project/infrastructure/kubernetes/index.rb b/qa/qa/page/project/infrastructure/kubernetes/index.rb index bdcaf7ffaff152a0d6b88efb80181cc49e9da4bd..0f6497b1ce993eb8ce88960f57ed9da0f442080e 100644 --- a/qa/qa/page/project/infrastructure/kubernetes/index.rb +++ b/qa/qa/page/project/infrastructure/kubernetes/index.rb @@ -6,7 +6,7 @@ module Project module Infrastructure module Kubernetes class Index < Page::Base - view 'app/views/clusters/clusters/_empty_state.html.haml' do + view 'app/assets/javascripts/clusters_list/components/clusters_empty_state.vue' do element :add_kubernetes_cluster_link end diff --git a/spec/frontend/clusters_list/components/clusters_empty_state_spec.js b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..147135b7ac03e9653da1ad74b741ffb13b07fb2b --- /dev/null +++ b/spec/frontend/clusters_list/components/clusters_empty_state_spec.js @@ -0,0 +1,77 @@ +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ClustersEmptyState from '~/clusters_list/components/clusters_empty_state.vue'; +import ClusterStore from '~/clusters_list/store'; + +const clustersEmptyStateImage = 'path/to/svg'; +const newClusterPath = '/path/to/connect/cluster'; +const emptyStateHelpText = 'empty state text'; +const canAddCluster = true; + +describe('ClustersEmptyStateComponent', () => { + let wrapper; + + const propsData = { + childComponent: false, + }; + + const provideData = { + clustersEmptyStateImage, + emptyStateHelpText: null, + newClusterPath, + }; + + const entryData = { + canAddCluster, + }; + + const findButton = () => wrapper.findComponent(GlButton); + const findEmptyStateText = () => wrapper.findByTestId('clusters-empty-state-text'); + + beforeEach(() => { + wrapper = shallowMountExtended(ClustersEmptyState, { + store: ClusterStore(entryData), + propsData, + provide: provideData, + stubs: { GlEmptyState }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render the action button', () => { + expect(findButton().exists()).toBe(true); + }); + + describe('when the help text is not provided', () => { + it('should not render the empty state text', () => { + expect(findEmptyStateText().exists()).toBe(false); + }); + }); + + describe('when the help text is provided', () => { + beforeEach(() => { + provideData.emptyStateHelpText = emptyStateHelpText; + wrapper = shallowMountExtended(ClustersEmptyState, { + store: ClusterStore(entryData), + propsData, + provide: provideData, + }); + }); + + it('should show the empty state text', () => { + expect(findEmptyStateText().text()).toBe(emptyStateHelpText); + }); + }); + + describe('when the user cannot add clusters', () => { + beforeEach(() => { + wrapper.vm.$store.state.canAddCluster = false; + }); + it('should disable the button', () => { + expect(findButton().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index 941a3adb6258685b0770222937d905dab12167db..de511788b089ffee00c7528c26ce7edd47987cdc 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -8,6 +8,7 @@ import * as Sentry from '@sentry/browser'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import Clusters from '~/clusters_list/components/clusters.vue'; +import ClustersEmptyState from '~/clusters_list/components/clusters_empty_state.vue'; import ClusterStore from '~/clusters_list/store'; import axios from '~/lib/utils/axios_utils'; import { apiData } from '../mock_data'; @@ -18,18 +19,30 @@ describe('Clusters', () => { let wrapper; const endpoint = 'some/endpoint'; + const totalClustersNumber = 6; + const clustersEmptyStateImage = 'path/to/svg'; + const emptyStateHelpText = null; + const newClusterPath = '/path/to/new/cluster'; const entryData = { endpoint, imgTagsAwsText: 'AWS Icon', imgTagsDefaultText: 'Default Icon', imgTagsGcpText: 'GCP Icon', + totalClusters: totalClustersNumber, }; - const findLoader = () => wrapper.find(GlLoadingIcon); - const findPaginatedButtons = () => wrapper.find(GlPagination); - const findTable = () => wrapper.find(GlTable); + const provideData = { + clustersEmptyStateImage, + emptyStateHelpText, + newClusterPath, + }; + + const findLoader = () => wrapper.findComponent(GlLoadingIcon); + const findPaginatedButtons = () => wrapper.findComponent(GlPagination); + const findTable = () => wrapper.findComponent(GlTable); const findStatuses = () => findTable().findAll('.js-status'); + const findEmptyState = () => wrapper.findComponent(ClustersEmptyState); const mockPollingApi = (response, body, header) => { mock.onGet(`${endpoint}?page=${header['x-page']}`).reply(response, body, header); @@ -37,7 +50,7 @@ describe('Clusters', () => { const mountWrapper = () => { store = ClusterStore(entryData); - wrapper = mount(Clusters, { store }); + wrapper = mount(Clusters, { provide: provideData, store, stubs: { GlTable } }); return axios.waitForAll(); }; @@ -70,7 +83,6 @@ describe('Clusters', () => { describe('when data is loading', () => { beforeEach(() => { wrapper.vm.$store.state.loadingClusters = true; - return wrapper.vm.$nextTick(); }); it('displays a loader instead of the table while loading', () => { @@ -79,23 +91,19 @@ describe('Clusters', () => { }); }); - it('displays a table component', () => { - expect(findTable().exists()).toBe(true); - }); - - it('renders the correct table headers', () => { - const tableHeaders = wrapper.vm.fields; - const headers = findTable().findAll('th'); - - expect(headers.length).toBe(tableHeaders.length); - - tableHeaders.forEach((headerText, i) => - expect(headers.at(i).text()).toEqual(headerText.label), - ); + describe('when clusters are present', () => { + it('displays a table component', () => { + expect(findTable().exists()).toBe(true); + }); }); - it('should stack on smaller devices', () => { - expect(findTable().classes()).toContain('b-table-stacked-md'); + describe('when there are no clusters', () => { + beforeEach(() => { + wrapper.vm.$store.state.totalClusters = 0; + }); + it('should render empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/clusters_list/store/mutations_spec.js b/spec/frontend/clusters_list/store/mutations_spec.js index c0fe634a70360068d66a2ec021acaf1633e91384..f8723bfcdfc0af96d6e4d66d335e5eb21bc4fb67 100644 --- a/spec/frontend/clusters_list/store/mutations_spec.js +++ b/spec/frontend/clusters_list/store/mutations_spec.js @@ -26,7 +26,7 @@ describe('Admin statistics panel mutations', () => { expect(state.clusters).toBe(apiData.clusters); expect(state.clustersPerPage).toBe(paginationInformation.perPage); expect(state.hasAncestorClusters).toBe(apiData.has_ancestor_clusters); - expect(state.totalCulsters).toBe(paginationInformation.total); + expect(state.totalClusters).toBe(paginationInformation.total); }); }); diff --git a/spec/helpers/clusters_helper_spec.rb b/spec/helpers/clusters_helper_spec.rb index 0dcc7baf9fcfe146231ad55b3547ac14d5ab1301..3db906f2e629af258fd97b9fe41903a608026c8d 100644 --- a/spec/helpers/clusters_helper_spec.rb +++ b/spec/helpers/clusters_helper_spec.rb @@ -89,10 +89,14 @@ end describe '#js_clusters_list_data' do - subject { helper.js_clusters_list_data('/path') } + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { build(:project) } + let_it_be(:clusterable) { ClusterablePresenter.fabricate(project, current_user: current_user) } + + subject { helper.js_clusters_list_data(clusterable) } it 'displays endpoint path' do - expect(subject[:endpoint]).to eq('/path') + expect(subject[:endpoint]).to eq("#{project_path(project)}/-/clusters.json") end it 'generates svg image data', :aggregate_failures do @@ -108,6 +112,45 @@ it 'displays and ancestor_help_path' do expect(subject[:ancestor_help_path]).to eq(help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')) end + + it 'displays empty image path' do + expect(subject[:clusters_empty_state_image]).to match(%r(/illustrations/logos/clusters_empty|svg)) + end + + it 'displays create cluster using certificate path' do + expect(subject[:new_cluster_path]).to eq("#{project_path(project)}/-/clusters/new?tab=create") + end + + context 'user has no permissions to create a cluster' do + it 'displays that user can\t add cluster' do + expect(subject[:can_add_cluster]).to eq("false") + end + end + + context 'user is a maintainer' do + before do + project.add_maintainer(current_user) + end + + it 'displays that the user can add cluster' do + expect(subject[:can_add_cluster]).to eq("true") + end + end + + context 'project cluster' do + it 'doesn\'t display empty state help text' do + expect(subject[:empty_state_help_text]).to be_nil + end + end + + context 'group cluster' do + let_it_be(:group) { create(:group) } + let_it_be(:clusterable) { ClusterablePresenter.fabricate(group, current_user: current_user) } + + it 'displays empty state help text' do + expect(subject[:empty_state_help_text]).to eq(s_('ClusterIntegration|Adding an integration to your group will share the cluster across all your projects.')) + end + end end describe '#js_cluster_new' do
+ {{ $options.i18n.description }} +
+ {{ emptyStateHelpText }} +
+ + {{ $options.i18n.learnMoreLinkText }} + +