Commit b28bf503 authored by anna_vovchenko's avatar anna_vovchenko Committed by Nicolò Maria Mezzopera

Moved clusters empty state to Vue component

As we want to change the Kubernetes section UX,
we need to have all related components in Vue.

Changelog: changed
parent 93310098
...@@ -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"
...@@ -298,11 +304,13 @@ export default { ...@@ -298,11 +304,13 @@ export default {
</template> </template>
</gl-table> </gl-table>
<ClustersEmptyState 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 { helpPagePath } from '~/helpers/help_page_helper';
export default {
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="s__('ClusterIntegration|Integrate Kubernetes with a cluster certificate')"
>
<template #description>
<p class="mw-460 gl-mx-auto gl-text-left">
{{
s__(
'ClusterIntegration|Kubernetes clusters allow you to use review apps, deploy your applications, run your pipelines, and much more in an easy way.',
)
}}
</p>
<p
v-if="emptyStateHelpText"
class="mw-460 gl-mx-auto gl-text-left"
data-testid="clusters-empty-state-text"
>
{{ emptyStateHelpText }}
</p>
<p class="mw-460 gl-mx-auto">
<gl-link :href="$options.learnMoreHelpUrl" target="_blank" data-testid="clusters-docs-link">
{{ s__('ClusterIntegration|Learn more about Kubernetes') }}
</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"
>
{{ s__('ClusterIntegration|Integrate with a cluster certificate') }}
</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? .top-area.adjust
= render 'empty_state' .gl-display-block.gl-text-right.gl-my-4.gl-w-full
- else - if clusterable.can_add_cluster?
.top-area.adjust = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-confirm js-add-cluster gl-py-2', qa_selector: :integrate_kubernetes_cluster_button
.gl-display-block.gl-text-right.gl-my-4.gl-w-full - else
- if clusterable.can_add_cluster? %span.btn.gl-button.btn-confirm.js-add-cluster.disabled.gl-py-2
= link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-confirm js-add-cluster gl-py-2', qa_selector: :integrate_kubernetes_cluster_button = s_("ClusterIntegration|Connect cluster with certificate")
- else
%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 ...@@ -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().exists()).toBe(true);
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,34 @@ describe('Clusters', () => { ...@@ -79,23 +91,34 @@ 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', () => { it('renders the correct table headers', () => {
const tableHeaders = wrapper.vm.fields; const tableHeaders = wrapper.vm.fields;
const headers = findTable().findAll('th'); const headers = findTable().findAll('th');
expect(headers.length).toBe(tableHeaders.length); expect(headers.length).toBe(tableHeaders.length);
tableHeaders.forEach((headerText, i) => tableHeaders.forEach((headerText, i) =>
expect(headers.at(i).text()).toEqual(headerText.label), expect(headers.at(i).text()).toEqual(headerText.label),
); );
});
it('should stack on smaller devices', () => {
expect(findTable().classes()).toContain('b-table-stacked-md');
});
}); });
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(clusterable.index_path(format: :json))
end end
it 'generates svg image data', :aggregate_failures do it 'generates svg image data', :aggregate_failures do
...@@ -108,6 +112,22 @@ RSpec.describe ClustersHelper do ...@@ -108,6 +112,22 @@ 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 empty state help text' do
expect(subject[:empty_state_help_text]).to match(clusterable.empty_state_help_text)
end
it 'displays create cluster using certificate path' do
expect(subject[:new_cluster_path]).to match(clusterable.new_path(tab: 'create'))
end
it 'displays whether the user can add cluster' do
expect(subject[:can_add_cluster]).to match(clusterable.can_add_cluster?.to_s)
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