Commit 5fc6db78 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '340759-implementation-revisit-the-kubernetes-section-ux-2' into 'master'

Move clusters empty state to Vue

See merge request gitlab-org/gitlab!73064
parents cf327555 689fc98c
...@@ -14,6 +14,7 @@ import { __, sprintf } from '~/locale'; ...@@ -14,6 +14,7 @@ import { __, sprintf } from '~/locale';
import { CLUSTER_TYPES, STATUSES } from '../constants'; import { CLUSTER_TYPES, STATUSES } from '../constants';
import AncestorNotice from './ancestor_notice.vue'; import AncestorNotice from './ancestor_notice.vue';
import NodeErrorHelpText from './node_error_help_text.vue'; import NodeErrorHelpText from './node_error_help_text.vue';
import ClustersEmptyState from './clusters_empty_state.vue';
export default { export default {
nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'), nodeMemoryText: __('%{totalMemory} (%{freeSpacePercentage}%{percentSymbol} free)'),
...@@ -28,6 +29,7 @@ export default { ...@@ -28,6 +29,7 @@ export default {
GlSprintf, GlSprintf,
GlTable, GlTable,
NodeErrorHelpText, NodeErrorHelpText,
ClustersEmptyState,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -40,7 +42,7 @@ export default { ...@@ -40,7 +42,7 @@ export default {
'loadingNodes', 'loadingNodes',
'page', 'page',
'providers', 'providers',
'totalCulsters', 'totalClusters',
]), ]),
contentAlignClasses() { contentAlignClasses() {
return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start'; return 'gl-display-flex gl-align-items-center gl-justify-content-end gl-justify-content-md-start';
...@@ -83,9 +85,12 @@ export default { ...@@ -83,9 +85,12 @@ export default {
}, },
]; ];
}, },
hasClusters() { hasClustersPerPage() {
return this.clustersPerPage > 0; return this.clustersPerPage > 0;
}, },
hasClusters() {
return this.totalClusters > 0;
},
}, },
mounted() { mounted() {
this.fetchClusters(); this.fetchClusters();
...@@ -202,6 +207,7 @@ export default { ...@@ -202,6 +207,7 @@ export default {
<ancestor-notice /> <ancestor-notice />
<gl-table <gl-table
v-if="hasClusters"
:items="clusters" :items="clusters"
:fields="fields" :fields="fields"
stacked="md" stacked="md"
...@@ -241,7 +247,7 @@ export default { ...@@ -241,7 +247,7 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<NodeErrorHelpText <node-error-help-text
v-else-if="item.kubernetes_errors" v-else-if="item.kubernetes_errors"
:class="contentAlignClasses" :class="contentAlignClasses"
:error-type="item.kubernetes_errors.connection_error" :error-type="item.kubernetes_errors.connection_error"
...@@ -262,7 +268,7 @@ export default { ...@@ -262,7 +268,7 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<NodeErrorHelpText <node-error-help-text
v-else-if="item.kubernetes_errors" v-else-if="item.kubernetes_errors"
:class="contentAlignClasses" :class="contentAlignClasses"
:error-type="item.kubernetes_errors.node_connection_error" :error-type="item.kubernetes_errors.node_connection_error"
...@@ -283,7 +289,7 @@ export default { ...@@ -283,7 +289,7 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" /> <gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<NodeErrorHelpText <node-error-help-text
v-else-if="item.kubernetes_errors" v-else-if="item.kubernetes_errors"
:class="contentAlignClasses" :class="contentAlignClasses"
:error-type="item.kubernetes_errors.metrics_connection_error" :error-type="item.kubernetes_errors.metrics_connection_error"
...@@ -298,11 +304,13 @@ export default { ...@@ -298,11 +304,13 @@ export default {
</template> </template>
</gl-table> </gl-table>
<clusters-empty-state v-else />
<gl-pagination <gl-pagination
v-if="hasClusters" v-if="hasClustersPerPage"
v-model="currentPage" v-model="currentPage"
:per-page="clustersPerPage" :per-page="clustersPerPage"
:total-items="totalCulsters" :total-items="totalClusters"
:prev-text="__('Prev')" :prev-text="__('Prev')"
:next-text="__('Next')" :next-text="__('Next')"
align="center" align="center"
......
<script>
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']),
},
};
</script>
<template>
<gl-empty-state :svg-path="clustersEmptyStateImage" :title="$options.i18n.title">
<template #description>
<p>
{{ $options.i18n.description }}
</p>
<p v-if="emptyStateHelpText" data-testid="clusters-empty-state-text">
{{ emptyStateHelpText }}
</p>
<p>
<gl-link :href="$options.learnMoreHelpUrl" target="_blank" data-testid="clusters-docs-link">
{{ $options.i18n.learnMoreLinkText }}
</gl-link>
</p>
</template>
<template #actions>
<gl-button
data-testid="integration-primary-button"
data-qa-selector="add_kubernetes_cluster_link"
category="primary"
variant="confirm"
:disabled="!canAddCluster"
:href="newClusterPath"
>
{{ $options.i18n.buttonText }}
</gl-button>
</template>
</gl-empty-state>
</template>
...@@ -8,8 +8,15 @@ export default (Vue) => { ...@@ -8,8 +8,15 @@ export default (Vue) => {
return null; return null;
} }
const { emptyStateHelpText, newClusterPath, clustersEmptyStateImage } = el.dataset;
return new Vue({ return new Vue({
el, el,
provide: {
emptyStateHelpText,
newClusterPath,
clustersEmptyStateImage,
},
store: createStore(el.dataset), store: createStore(el.dataset),
render(createElement) { render(createElement) {
return createElement(Clusters); return createElement(Clusters);
......
...@@ -12,7 +12,7 @@ export default { ...@@ -12,7 +12,7 @@ export default {
clusters: data.clusters, clusters: data.clusters,
clustersPerPage: paginationInformation.perPage, clustersPerPage: paginationInformation.perPage,
hasAncestorClusters: data.has_ancestor_clusters, hasAncestorClusters: data.has_ancestor_clusters,
totalCulsters: paginationInformation.total, totalClusters: paginationInformation.total,
}); });
}, },
[types.SET_PAGE](state, value) { [types.SET_PAGE](state, value) {
......
import { parseBoolean } from '~/lib/utils/common_utils';
export default (initialState = {}) => ({ export default (initialState = {}) => ({
ancestorHelperPath: initialState.ancestorHelpPath, ancestorHelperPath: initialState.ancestorHelpPath,
endpoint: initialState.endpoint, endpoint: initialState.endpoint,
...@@ -12,5 +14,6 @@ export default (initialState = {}) => ({ ...@@ -12,5 +14,6 @@ export default (initialState = {}) => ({
default: { path: initialState.imgTagsDefaultPath, text: initialState.imgTagsDefaultText }, default: { path: initialState.imgTagsDefaultPath, text: initialState.imgTagsDefaultText },
gcp: { path: initialState.imgTagsGcpPath, text: initialState.imgTagsGcpText }, gcp: { path: initialState.imgTagsGcpPath, text: initialState.imgTagsGcpText },
}, },
totalCulsters: 0, totalClusters: 0,
canAddCluster: parseBoolean(initialState.canAddCluster),
}); });
...@@ -29,15 +29,19 @@ module ClustersHelper ...@@ -29,15 +29,19 @@ module ClustersHelper
} }
end 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'), ancestor_help_path: help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'),
endpoint: path, endpoint: clusterable.index_path(format: :json),
img_tags: { img_tags: {
aws: { path: image_path('illustrations/logos/amazon_eks.svg'), text: s_('ClusterIntegration|Amazon EKS') }, 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') }, 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') } 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 end
......
- if clusters.empty? - if !clusters.empty?
= render 'empty_state'
- else
.top-area.adjust .top-area.adjust
.gl-display-block.gl-text-right.gl-my-4.gl-w-full .gl-display-block.gl-text-right.gl-my-4.gl-w-full
- if clusterable.can_add_cluster? - if clusterable.can_add_cluster?
...@@ -9,4 +7,4 @@ ...@@ -9,4 +7,4 @@
%span.btn.gl-button.btn-confirm.js-add-cluster.disabled.gl-py-2 %span.btn.gl-button.btn-confirm.js-add-cluster.disabled.gl-py-2
= s_("ClusterIntegration|Connect cluster with certificate") = 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) }
.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' }
...@@ -6,7 +6,7 @@ module QA ...@@ -6,7 +6,7 @@ module QA
module Infrastructure module Infrastructure
module Kubernetes module Kubernetes
class Index < Page::Base 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 element :add_kubernetes_cluster_link
end end
......
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);
});
});
});
...@@ -8,6 +8,7 @@ import * as Sentry from '@sentry/browser'; ...@@ -8,6 +8,7 @@ import * as Sentry from '@sentry/browser';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Clusters from '~/clusters_list/components/clusters.vue'; 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 ClusterStore from '~/clusters_list/store';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { apiData } from '../mock_data'; import { apiData } from '../mock_data';
...@@ -18,18 +19,30 @@ describe('Clusters', () => { ...@@ -18,18 +19,30 @@ describe('Clusters', () => {
let wrapper; let wrapper;
const endpoint = 'some/endpoint'; const endpoint = 'some/endpoint';
const totalClustersNumber = 6;
const clustersEmptyStateImage = 'path/to/svg';
const emptyStateHelpText = null;
const newClusterPath = '/path/to/new/cluster';
const entryData = { const entryData = {
endpoint, endpoint,
imgTagsAwsText: 'AWS Icon', imgTagsAwsText: 'AWS Icon',
imgTagsDefaultText: 'Default Icon', imgTagsDefaultText: 'Default Icon',
imgTagsGcpText: 'GCP Icon', imgTagsGcpText: 'GCP Icon',
totalClusters: totalClustersNumber,
}; };
const findLoader = () => wrapper.find(GlLoadingIcon); const provideData = {
const findPaginatedButtons = () => wrapper.find(GlPagination); clustersEmptyStateImage,
const findTable = () => wrapper.find(GlTable); emptyStateHelpText,
newClusterPath,
};
const findLoader = () => wrapper.findComponent(GlLoadingIcon);
const findPaginatedButtons = () => wrapper.findComponent(GlPagination);
const findTable = () => wrapper.findComponent(GlTable);
const findStatuses = () => findTable().findAll('.js-status'); const findStatuses = () => findTable().findAll('.js-status');
const findEmptyState = () => wrapper.findComponent(ClustersEmptyState);
const mockPollingApi = (response, body, header) => { const mockPollingApi = (response, body, header) => {
mock.onGet(`${endpoint}?page=${header['x-page']}`).reply(response, body, header); mock.onGet(`${endpoint}?page=${header['x-page']}`).reply(response, body, header);
...@@ -37,7 +50,7 @@ describe('Clusters', () => { ...@@ -37,7 +50,7 @@ describe('Clusters', () => {
const mountWrapper = () => { const mountWrapper = () => {
store = ClusterStore(entryData); store = ClusterStore(entryData);
wrapper = mount(Clusters, { store }); wrapper = mount(Clusters, { provide: provideData, store, stubs: { GlTable } });
return axios.waitForAll(); return axios.waitForAll();
}; };
...@@ -70,7 +83,6 @@ describe('Clusters', () => { ...@@ -70,7 +83,6 @@ describe('Clusters', () => {
describe('when data is loading', () => { describe('when data is loading', () => {
beforeEach(() => { beforeEach(() => {
wrapper.vm.$store.state.loadingClusters = true; wrapper.vm.$store.state.loadingClusters = true;
return wrapper.vm.$nextTick();
}); });
it('displays a loader instead of the table while loading', () => { it('displays a loader instead of the table while loading', () => {
...@@ -79,23 +91,19 @@ describe('Clusters', () => { ...@@ -79,23 +91,19 @@ describe('Clusters', () => {
}); });
}); });
it('displays a table component', () => { describe('when clusters are present', () => {
expect(findTable().exists()).toBe(true); 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),
);
}); });
it('should stack on smaller devices', () => { describe('when there are no clusters', () => {
expect(findTable().classes()).toContain('b-table-stacked-md'); beforeEach(() => {
wrapper.vm.$store.state.totalClusters = 0;
});
it('should render empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
}); });
}); });
......
...@@ -26,7 +26,7 @@ describe('Admin statistics panel mutations', () => { ...@@ -26,7 +26,7 @@ describe('Admin statistics panel mutations', () => {
expect(state.clusters).toBe(apiData.clusters); expect(state.clusters).toBe(apiData.clusters);
expect(state.clustersPerPage).toBe(paginationInformation.perPage); expect(state.clustersPerPage).toBe(paginationInformation.perPage);
expect(state.hasAncestorClusters).toBe(apiData.has_ancestor_clusters); expect(state.hasAncestorClusters).toBe(apiData.has_ancestor_clusters);
expect(state.totalCulsters).toBe(paginationInformation.total); expect(state.totalClusters).toBe(paginationInformation.total);
}); });
}); });
......
...@@ -89,10 +89,14 @@ RSpec.describe ClustersHelper do ...@@ -89,10 +89,14 @@ RSpec.describe ClustersHelper do
end end
describe '#js_clusters_list_data' do 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 it 'displays endpoint path' do
expect(subject[:endpoint]).to eq('/path') expect(subject[:endpoint]).to eq("#{project_path(project)}/-/clusters.json")
end end
it 'generates svg image data', :aggregate_failures do it 'generates svg image data', :aggregate_failures do
...@@ -108,6 +112,45 @@ RSpec.describe ClustersHelper do ...@@ -108,6 +112,45 @@ RSpec.describe ClustersHelper do
it 'displays and ancestor_help_path' do it 'displays and ancestor_help_path' do
expect(subject[:ancestor_help_path]).to eq(help_page_path('user/group/clusters/index', anchor: 'cluster-precedence')) expect(subject[:ancestor_help_path]).to eq(help_page_path('user/group/clusters/index', anchor: 'cluster-precedence'))
end 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 end
describe '#js_cluster_new' do describe '#js_cluster_new' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment