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';
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 {
<ancestor-notice />
<gl-table
v-if="hasClusters"
:items="clusters"
:fields="fields"
stacked="md"
......@@ -241,7 +247,7 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<NodeErrorHelpText
<node-error-help-text
v-else-if="item.kubernetes_errors"
:class="contentAlignClasses"
:error-type="item.kubernetes_errors.connection_error"
......@@ -262,7 +268,7 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<NodeErrorHelpText
<node-error-help-text
v-else-if="item.kubernetes_errors"
:class="contentAlignClasses"
:error-type="item.kubernetes_errors.node_connection_error"
......@@ -283,7 +289,7 @@ export default {
<gl-skeleton-loading v-else-if="loadingNodes" :lines="1" :class="contentAlignClasses" />
<NodeErrorHelpText
<node-error-help-text
v-else-if="item.kubernetes_errors"
:class="contentAlignClasses"
:error-type="item.kubernetes_errors.metrics_connection_error"
......@@ -298,11 +304,13 @@ export default {
</template>
</gl-table>
<clusters-empty-state v-else />
<gl-pagination
v-if="hasClusters"
v-if="hasClustersPerPage"
v-model="currentPage"
:per-page="clustersPerPage"
:total-items="totalCulsters"
:total-items="totalClusters"
:prev-text="__('Prev')"
:next-text="__('Next')"
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) => {
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);
......
......@@ -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) {
......
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),
});
......@@ -29,15 +29,19 @@ module ClustersHelper
}
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
......
- 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) }
.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
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
......
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';
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);
});
});
});
......
......@@ -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);
});
});
......
......@@ -89,10 +89,14 @@ RSpec.describe ClustersHelper do
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 @@ RSpec.describe ClustersHelper 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'))
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
......
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