@rancher/shell 3.0.8-rc.2 → 3.0.8-rc.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/brand/suse/dark/rancher-logo.svg +64 -1
- package/assets/brand/suse/rancher-logo.svg +1 -1
- package/assets/styles/global/_cards.scss +0 -3
- package/assets/styles/themes/_modern.scss +9 -1
- package/assets/styles/themes/_suse.scss +81 -24
- package/assets/translations/en-us.yaml +68 -3
- package/components/AutoscalerCard.vue +113 -0
- package/components/AutoscalerTab.vue +94 -0
- package/components/ClusterIconMenu.vue +1 -1
- package/components/ClusterProviderIcon.vue +1 -1
- package/components/IconOrSvg.vue +2 -2
- package/components/PopoverCard.vue +192 -0
- package/components/Resource/Detail/FetchLoader/composables.ts +18 -4
- package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/identifying-fields.test.ts +1 -1
- package/components/Resource/Detail/Metadata/IdentifyingInformation/identifying-fields.ts +4 -0
- package/components/Resource/Detail/ResourcePopover/ResourcePopoverCard.vue +2 -19
- package/components/Resource/Detail/ResourcePopover/__tests__/ResourcePopoverCard.test.ts +0 -29
- package/components/Resource/Detail/ResourcePopover/__tests__/index.test.ts +132 -150
- package/components/Resource/Detail/ResourcePopover/index.vue +54 -159
- package/components/ResourceDetail/Masthead/latest.vue +29 -0
- package/components/ResourceList/Masthead.vue +1 -1
- package/components/__tests__/AutoscalerCard.test.ts +154 -0
- package/components/__tests__/AutoscalerTab.test.ts +125 -0
- package/components/__tests__/PopoverCard.test.ts +204 -0
- package/components/formatter/Autoscaler.vue +97 -0
- package/components/formatter/InternalExternalIP.vue +195 -24
- package/components/formatter/__tests__/Autoscaler.test.ts +156 -0
- package/components/formatter/__tests__/InternalExternalIP.test.ts +133 -0
- package/components/nav/Group.vue +12 -3
- package/components/nav/TopLevelMenu.vue +2 -2
- package/composables/useInterval.ts +15 -0
- package/config/labels-annotations.js +8 -1
- package/config/product/manager.js +20 -9
- package/config/router/routes.js +4 -0
- package/config/settings.ts +2 -1
- package/config/table-headers.js +8 -0
- package/config/types.js +2 -0
- package/config/version.js +1 -1
- package/core/types-provisioning.ts +3 -0
- package/detail/provisioning.cattle.io.cluster.vue +12 -1
- package/directives/ui-context.ts +8 -2
- package/edit/auth/github.vue +5 -0
- package/edit/cloudcredential.vue +1 -1
- package/edit/fleet.cattle.io.gitrepo.vue +0 -10
- package/edit/provisioning.cattle.io.cluster/CustomCommand.vue +32 -5
- package/edit/provisioning.cattle.io.cluster/__tests__/CustomCommand.test.ts +35 -0
- package/edit/provisioning.cattle.io.cluster/__tests__/Networking.test.ts +132 -0
- package/edit/provisioning.cattle.io.cluster/index.vue +18 -12
- package/edit/provisioning.cattle.io.cluster/rke2.vue +39 -8
- package/edit/provisioning.cattle.io.cluster/tabs/MachinePool.vue +107 -5
- package/edit/provisioning.cattle.io.cluster/tabs/networking/index.vue +90 -3
- package/initialize/install-plugins.js +3 -1
- package/list/provisioning.cattle.io.cluster.vue +15 -2
- package/machine-config/amazonec2.vue +36 -135
- package/machine-config/components/EC2Networking.vue +474 -0
- package/machine-config/components/__tests__/EC2Networking.test.ts +94 -0
- package/machine-config/components/__tests__/utils/vpcSubnetMockData.js +294 -0
- package/machine-config/digitalocean.vue +11 -0
- package/models/cluster/node.js +13 -6
- package/models/cluster.x-k8s.io.machine.js +10 -20
- package/models/cluster.x-k8s.io.machinedeployment.js +5 -1
- package/models/management.cattle.io.kontainerdriver.js +1 -0
- package/models/provisioning.cattle.io.cluster.js +223 -2
- package/package.json +2 -2
- package/pages/c/_cluster/apps/charts/install.vue +1 -1
- package/pages/c/_cluster/manager/hostedprovider/index.vue +209 -0
- package/plugins/dynamic-content.js +13 -0
- package/rancher-components/Form/Checkbox/Checkbox.vue +1 -1
- package/rancher-components/Pill/RcStatusBadge/RcStatusBadge.vue +8 -0
- package/store/features.js +1 -0
- package/store/notifications.ts +32 -1
- package/store/plugins.js +7 -3
- package/store/prefs.js +1 -0
- package/types/notifications/index.ts +24 -3
- package/types/shell/index.d.ts +27 -2
- package/utils/__tests__/object.test.ts +19 -0
- package/utils/autoscaler-utils.ts +7 -0
- package/utils/dynamic-content/__tests__/announcement.test.ts +498 -0
- package/utils/dynamic-content/announcement.ts +112 -0
- package/utils/dynamic-content/example.json +40 -0
- package/utils/dynamic-content/index.ts +6 -2
- package/utils/dynamic-content/new-release.ts +1 -1
- package/utils/dynamic-content/notification-handler.ts +48 -0
- package/utils/dynamic-content/types.d.ts +33 -1
- package/utils/object.js +20 -2
- package/utils/scroll.js +7 -0
- package/utils/settings.ts +15 -0
- package/utils/validators/machine-pool.ts +13 -3
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
/* eslint-disable */
|
|
2
3
|
import { Banner } from '@components/Banner';
|
|
3
4
|
import TitleBar from '@shell/components/Resource/Detail/TitleBar/index.vue';
|
|
4
5
|
import { useDefaultTitleBarProps } from '@shell/components/Resource/Detail/TitleBar/composables';
|
|
@@ -7,6 +8,7 @@ import { useDefaultMetadataForLegacyPagesProps } from '@shell/components/Resourc
|
|
|
7
8
|
import { useResourceDetailBannerProps } from '@shell/components/Resource/Detail/composables';
|
|
8
9
|
import { computed } from 'vue';
|
|
9
10
|
|
|
11
|
+
// We are disabling eslint for this script to allow the use of the Props interface
|
|
10
12
|
export interface Props {
|
|
11
13
|
value?: Object;
|
|
12
14
|
resourceSubtype?: string;
|
|
@@ -15,18 +17,45 @@ export interface Props {
|
|
|
15
17
|
</script>
|
|
16
18
|
|
|
17
19
|
<script lang="ts" setup>
|
|
20
|
+
import { useStore } from 'vuex';
|
|
21
|
+
|
|
18
22
|
const props = withDefaults(defineProps<Props>(), { value: () => ({}), resourceSubtype: undefined });
|
|
19
23
|
|
|
24
|
+
const uiCtxResource = computed(() => {
|
|
25
|
+
const {
|
|
26
|
+
name, metadata, kind, state
|
|
27
|
+
} = (props.value || {}) as any;
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
name,
|
|
31
|
+
namespace: metadata?.namespace,
|
|
32
|
+
kind,
|
|
33
|
+
state,
|
|
34
|
+
};
|
|
35
|
+
});
|
|
20
36
|
const resourceSubtype = computed(() => props.resourceSubtype);
|
|
21
37
|
const titleBarProps = useDefaultTitleBarProps(props.value, resourceSubtype);
|
|
22
38
|
const metadataProps = useDefaultMetadataForLegacyPagesProps(props.value);
|
|
23
39
|
const bannerProps = useResourceDetailBannerProps(props.value);
|
|
40
|
+
|
|
41
|
+
const store = useStore();
|
|
24
42
|
</script>
|
|
25
43
|
|
|
26
44
|
<template>
|
|
27
45
|
<TitleBar v-bind="titleBarProps" />
|
|
28
46
|
<Banner
|
|
29
47
|
v-if="bannerProps"
|
|
48
|
+
v-ui-context="{
|
|
49
|
+
store: store,
|
|
50
|
+
icon: 'icon-info',
|
|
51
|
+
hookable: true,
|
|
52
|
+
value: {
|
|
53
|
+
bannerProps,
|
|
54
|
+
resource: uiCtxResource
|
|
55
|
+
},
|
|
56
|
+
tag: '__details-state-banner',
|
|
57
|
+
description: 'Status Message'
|
|
58
|
+
}"
|
|
30
59
|
class="new state-banner"
|
|
31
60
|
v-bind="bannerProps"
|
|
32
61
|
/>
|
|
@@ -141,7 +141,7 @@ export default {
|
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
// blocked-post means you can post through norman, but not through steve.
|
|
144
|
-
if ( this.schema && !this.schema?.collectionMethods.find((x) => ['blocked-post', 'post'].includes(x.toLowerCase())) ) {
|
|
144
|
+
if ( this.schema && this.schema?.collectionMethods && !this.schema?.collectionMethods.find((x) => ['blocked-post', 'post'].includes(x.toLowerCase())) ) {
|
|
145
145
|
return false;
|
|
146
146
|
}
|
|
147
147
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import AutoscalerCard from '@shell/components/AutoscalerCard.vue';
|
|
4
|
+
import { ref } from 'vue';
|
|
5
|
+
import { createStore } from 'vuex';
|
|
6
|
+
|
|
7
|
+
const mockUseFetch = jest.fn();
|
|
8
|
+
|
|
9
|
+
jest.mock('@shell/components/Resource/Detail/FetchLoader/composables', () => ({ useFetch: (...args: any[]) => mockUseFetch(...args) }));
|
|
10
|
+
|
|
11
|
+
const mockUseInterval = jest.fn();
|
|
12
|
+
|
|
13
|
+
jest.mock('@shell/composables/useInterval', () => ({ useInterval: (...args: any[]) => mockUseInterval(...args) }));
|
|
14
|
+
|
|
15
|
+
describe('component: AutoscalerCard.vue', () => {
|
|
16
|
+
const mockLoadDetails = jest.fn();
|
|
17
|
+
const mockRefresh = jest.fn();
|
|
18
|
+
|
|
19
|
+
const createWrapper = (props: any, useFetchState: any) => {
|
|
20
|
+
// Reset and configure the useFetch mock for each test
|
|
21
|
+
mockUseFetch.mockImplementation(() => {
|
|
22
|
+
return ref({
|
|
23
|
+
loading: false,
|
|
24
|
+
refreshing: false,
|
|
25
|
+
data: null,
|
|
26
|
+
refresh: mockRefresh,
|
|
27
|
+
...useFetchState,
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
return mount(AutoscalerCard, {
|
|
32
|
+
props: {
|
|
33
|
+
value: { loadAutoscalerDetails: mockLoadDetails },
|
|
34
|
+
...props,
|
|
35
|
+
},
|
|
36
|
+
global: { plugins: [createStore({})] },
|
|
37
|
+
// Shallow mount to avoid rendering child components like the dynamic ones
|
|
38
|
+
shallow: true,
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
jest.clearAllMocks();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should call useFetch with the correct loader function', () => {
|
|
47
|
+
createWrapper({}, {});
|
|
48
|
+
// The first argument to useFetch is the loader function
|
|
49
|
+
expect(mockUseFetch).toHaveBeenCalledWith(expect.any(Function));
|
|
50
|
+
// We can invoke the loader to ensure it calls the prop method
|
|
51
|
+
const loader = mockUseFetch.mock.calls[0][0];
|
|
52
|
+
|
|
53
|
+
loader();
|
|
54
|
+
expect(mockLoadDetails).toHaveBeenCalledWith();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should setup a polling interval to refresh data', () => {
|
|
58
|
+
createWrapper({}, {});
|
|
59
|
+
expect(mockUseInterval).toHaveBeenCalledWith(expect.any(Function), 10000);
|
|
60
|
+
|
|
61
|
+
// Invoke the interval function to ensure it calls refresh
|
|
62
|
+
const intervalFn = mockUseInterval.mock.calls[0][0];
|
|
63
|
+
|
|
64
|
+
intervalFn();
|
|
65
|
+
expect(mockRefresh).toHaveBeenCalledWith();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('uI States', () => {
|
|
69
|
+
it('should display a loading spinner on initial load', () => {
|
|
70
|
+
const wrapper = createWrapper({}, { loading: true, refreshing: false });
|
|
71
|
+
|
|
72
|
+
expect(wrapper.find('.loading').exists()).toBe(true);
|
|
73
|
+
expect(wrapper.find('.icon-spinner').exists()).toBe(true);
|
|
74
|
+
expect(wrapper.find('.details').exists()).toBe(false);
|
|
75
|
+
expect(wrapper.find('.text-warning').exists()).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should NOT display the main loading spinner during a background refresh', () => {
|
|
79
|
+
const wrapper = createWrapper({}, {
|
|
80
|
+
loading: true, refreshing: true, data: []
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
expect(wrapper.find('.loading').exists()).toBe(false);
|
|
84
|
+
// Data should still be visible
|
|
85
|
+
expect(wrapper.find('.details').exists()).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should display an error message if loading fails', () => {
|
|
89
|
+
const wrapper = createWrapper({}, { loading: false, data: null });
|
|
90
|
+
|
|
91
|
+
expect(wrapper.find('.text-warning').exists()).toBe(true);
|
|
92
|
+
expect(wrapper.find('.text-warning').text()).toBe('autoscaler.card.loadingError');
|
|
93
|
+
expect(wrapper.find('.loading').exists()).toBe(false);
|
|
94
|
+
expect(wrapper.find('.details').exists()).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should display details when data is loaded', () => {
|
|
98
|
+
const mockData = [
|
|
99
|
+
{ label: 'Status', value: 'Active' },
|
|
100
|
+
{ label: 'Nodes', value: '3' },
|
|
101
|
+
];
|
|
102
|
+
const wrapper = createWrapper({}, { loading: false, data: mockData });
|
|
103
|
+
|
|
104
|
+
expect(wrapper.find('.details').exists()).toBe(true);
|
|
105
|
+
const details = wrapper.findAll('.detail');
|
|
106
|
+
|
|
107
|
+
expect(details).toHaveLength(2);
|
|
108
|
+
expect(details[0].text()).toContain('Status');
|
|
109
|
+
expect(details[0].text()).toContain('Active');
|
|
110
|
+
expect(details[1].text()).toContain('Nodes');
|
|
111
|
+
expect(details[1].text()).toContain('3');
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('detail Rendering', () => {
|
|
116
|
+
it('should render a string value', () => {
|
|
117
|
+
const mockData = [{ label: 'My Label', value: 'My Value' }];
|
|
118
|
+
const wrapper = createWrapper({}, { data: mockData });
|
|
119
|
+
const valueDiv = wrapper.find('.value');
|
|
120
|
+
|
|
121
|
+
expect(valueDiv.find('span').exists()).toBe(true);
|
|
122
|
+
expect(valueDiv.text()).toBe('My Value');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('should render a dynamic component value', () => {
|
|
126
|
+
const DynamicComponent = {
|
|
127
|
+
name: 'DynamicComponent',
|
|
128
|
+
props: ['text'],
|
|
129
|
+
template: '<div>{{ text }}</div>'
|
|
130
|
+
};
|
|
131
|
+
const mockData = [{
|
|
132
|
+
label: 'My Component',
|
|
133
|
+
value: { component: DynamicComponent, props: { text: 'Dynamic Text' } }
|
|
134
|
+
}];
|
|
135
|
+
const wrapper = createWrapper({}, { data: mockData });
|
|
136
|
+
const valueDiv = wrapper.find('.value');
|
|
137
|
+
const renderedComponent = valueDiv.findComponent(DynamicComponent);
|
|
138
|
+
|
|
139
|
+
expect(renderedComponent.exists()).toBe(true);
|
|
140
|
+
expect(renderedComponent.props('text')).toBe('Dynamic Text');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should render a heading for details without a value', () => {
|
|
144
|
+
const mockData = [{ label: 'Section Header' }];
|
|
145
|
+
const wrapper = createWrapper({}, { data: mockData });
|
|
146
|
+
|
|
147
|
+
expect(wrapper.find('h5').exists()).toBe(true);
|
|
148
|
+
expect(wrapper.find('h5').text()).toBe('Section Header');
|
|
149
|
+
// Label and value should not be rendered
|
|
150
|
+
expect(wrapper.find('label').exists()).toBe(false);
|
|
151
|
+
expect(wrapper.find('.value').exists()).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import AutoscalerTab from '@shell/components/AutoscalerTab.vue';
|
|
4
|
+
import { ref } from 'vue';
|
|
5
|
+
import { createStore } from 'vuex';
|
|
6
|
+
|
|
7
|
+
const mockUseFetch = jest.fn();
|
|
8
|
+
|
|
9
|
+
jest.mock('@shell/components/Resource/Detail/FetchLoader/composables', () => ({ useFetch: (...args: any[]) => mockUseFetch(...args) }));
|
|
10
|
+
|
|
11
|
+
const mockUseInterval = jest.fn();
|
|
12
|
+
|
|
13
|
+
jest.mock('@shell/composables/useInterval', () => ({ useInterval: (...args: any[]) => mockUseInterval(...args) }));
|
|
14
|
+
|
|
15
|
+
describe('component: AutoscalerTab.vue', () => {
|
|
16
|
+
const mockLoadEvents = jest.fn();
|
|
17
|
+
const mockRefresh = jest.fn();
|
|
18
|
+
const mockChangeSort = jest.fn();
|
|
19
|
+
|
|
20
|
+
const SortableTableStub = {
|
|
21
|
+
name: 'SortableTable',
|
|
22
|
+
template: '<div></div>',
|
|
23
|
+
setup() {
|
|
24
|
+
return { changeSort: mockChangeSort };
|
|
25
|
+
},
|
|
26
|
+
props: ['namespaced', 'rowActions', 'defaultSortBy', 'headers', 'rows']
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const createWrapper = (props: any, useFetchState: any) => {
|
|
30
|
+
mockUseFetch.mockImplementation(() => {
|
|
31
|
+
return ref({
|
|
32
|
+
data: null,
|
|
33
|
+
refresh: mockRefresh,
|
|
34
|
+
...useFetchState,
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
return mount(AutoscalerTab, {
|
|
39
|
+
props: {
|
|
40
|
+
value: { loadAutoscalerEvents: mockLoadEvents },
|
|
41
|
+
...props,
|
|
42
|
+
},
|
|
43
|
+
global: {
|
|
44
|
+
plugins: [createStore({})],
|
|
45
|
+
stubs: {
|
|
46
|
+
Tab: {
|
|
47
|
+
name: 'Tab',
|
|
48
|
+
template: '<div><slot/></div>',
|
|
49
|
+
props: ['label']
|
|
50
|
+
},
|
|
51
|
+
SortableTable: SortableTableStub,
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
jest.clearAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('initialization and Data Fetching', () => {
|
|
62
|
+
it('should call useFetch with the correct loader function', () => {
|
|
63
|
+
createWrapper({}, {});
|
|
64
|
+
expect(mockUseFetch).toHaveBeenCalledWith(expect.any(Function));
|
|
65
|
+
|
|
66
|
+
const loader = mockUseFetch.mock.calls[0][0];
|
|
67
|
+
|
|
68
|
+
loader();
|
|
69
|
+
expect(mockLoadEvents).toHaveBeenCalledWith();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should setup a polling interval to refresh data', () => {
|
|
73
|
+
createWrapper({}, {});
|
|
74
|
+
expect(mockUseInterval).toHaveBeenCalledWith(expect.any(Function), 20000);
|
|
75
|
+
|
|
76
|
+
const intervalFn = mockUseInterval.mock.calls[0][0];
|
|
77
|
+
|
|
78
|
+
intervalFn();
|
|
79
|
+
expect(mockRefresh).toHaveBeenCalledWith();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should call changeSort on the table on mounted', () => {
|
|
83
|
+
createWrapper({}, {});
|
|
84
|
+
expect(mockChangeSort).toHaveBeenCalledWith('date', true);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('sortableTable props', () => {
|
|
89
|
+
it('should pass static props to the table', () => {
|
|
90
|
+
const wrapper = createWrapper({}, {});
|
|
91
|
+
const table = wrapper.findComponent(SortableTableStub);
|
|
92
|
+
|
|
93
|
+
expect(table.props('namespaced')).toBe(false);
|
|
94
|
+
expect(table.props('rowActions')).toBe(false);
|
|
95
|
+
expect(table.props('defaultSortBy')).toBe('date');
|
|
96
|
+
expect(table.props('headers')).toHaveLength(4);
|
|
97
|
+
expect(table.props('headers')[0].name).toBe('type');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should pass an empty array to rows when data is null', () => {
|
|
101
|
+
const wrapper = createWrapper({}, { data: null });
|
|
102
|
+
const table = wrapper.findComponent(SortableTableStub);
|
|
103
|
+
|
|
104
|
+
expect(table.props('rows')).toStrictEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should pass fetched data to the rows prop', () => {
|
|
108
|
+
const mockEvents = [
|
|
109
|
+
{ id: 1, message: 'Event 1' },
|
|
110
|
+
{ id: 2, message: 'Event 2' },
|
|
111
|
+
];
|
|
112
|
+
const wrapper = createWrapper({}, { data: mockEvents });
|
|
113
|
+
const table = wrapper.findComponent(SortableTableStub);
|
|
114
|
+
|
|
115
|
+
expect(table.props('rows')).toStrictEqual(mockEvents);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should pass correct label to Tab component', () => {
|
|
120
|
+
const wrapper = createWrapper({}, {});
|
|
121
|
+
const tab = wrapper.findComponent({ name: 'Tab' });
|
|
122
|
+
|
|
123
|
+
expect(tab.props('label')).toBe('autoscaler.tab.title');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
|
|
2
|
+
import { mount } from '@vue/test-utils';
|
|
3
|
+
import PopoverCard from '@shell/components/PopoverCard.vue';
|
|
4
|
+
|
|
5
|
+
const mockFocusTrap = jest.fn();
|
|
6
|
+
|
|
7
|
+
jest.mock('@shell/composables/focusTrap', () => ({
|
|
8
|
+
...jest.requireActual('@shell/composables/focusTrap'), // Keep DEFAULT_FOCUS_TRAP_OPTS
|
|
9
|
+
useWatcherBasedSetupFocusTrapWithDestroyIncluded: (...args: any[]) => mockFocusTrap(...args),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const VDropdownStub = {
|
|
13
|
+
props: ['shown'],
|
|
14
|
+
template: `
|
|
15
|
+
<div>
|
|
16
|
+
<slot />
|
|
17
|
+
<div v-if="shown">
|
|
18
|
+
<slot name="popper" />
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
`,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
describe('component: PopoverCard.vue', () => {
|
|
25
|
+
const createWrapper = (props = {}, slots = {}) => {
|
|
26
|
+
return mount(PopoverCard, {
|
|
27
|
+
props: {
|
|
28
|
+
cardTitle: 'Test Title',
|
|
29
|
+
...props,
|
|
30
|
+
},
|
|
31
|
+
slots,
|
|
32
|
+
global: {
|
|
33
|
+
stubs: {
|
|
34
|
+
VDropdown: VDropdownStub,
|
|
35
|
+
Card: {
|
|
36
|
+
template: `
|
|
37
|
+
<div>
|
|
38
|
+
<slot name="heading-action" />
|
|
39
|
+
<slot />
|
|
40
|
+
</div>
|
|
41
|
+
`,
|
|
42
|
+
},
|
|
43
|
+
RcButton: { template: '<button><slot /></button>' },
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
mockFocusTrap.mockClear();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('props', () => {
|
|
54
|
+
it('should use default props', () => {
|
|
55
|
+
const wrapper = createWrapper();
|
|
56
|
+
const button = wrapper.find('button');
|
|
57
|
+
|
|
58
|
+
expect(button.attributes('aria-label')).toBe('Show more');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should accept and render custom props', () => {
|
|
62
|
+
const props = {
|
|
63
|
+
cardTitle: 'My Custom Title',
|
|
64
|
+
showPopoverAriaLabel: 'Click for details'
|
|
65
|
+
};
|
|
66
|
+
const wrapper = createWrapper(props);
|
|
67
|
+
const button = wrapper.find('button');
|
|
68
|
+
|
|
69
|
+
expect(button.attributes('aria-label')).toBe(props.showPopoverAriaLabel);
|
|
70
|
+
// Note: cardTitle is passed to the Card component inside the popper,
|
|
71
|
+
// which is only rendered when the popover is shown.
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('popover Visibility', () => {
|
|
76
|
+
it('should not be visible initially', () => {
|
|
77
|
+
const wrapper = createWrapper();
|
|
78
|
+
|
|
79
|
+
expect(wrapper.find('[id="popover-card"]').exists()).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should show on mouseenter and hide on mouseleave', async() => {
|
|
83
|
+
const wrapper = createWrapper();
|
|
84
|
+
const target = wrapper.find('.popover-card-target');
|
|
85
|
+
|
|
86
|
+
await target.trigger('mouseenter');
|
|
87
|
+
expect(wrapper.vm.showPopover).toBe(true);
|
|
88
|
+
|
|
89
|
+
const root = wrapper.find('.popover-card-base');
|
|
90
|
+
|
|
91
|
+
await root.trigger('mouseleave');
|
|
92
|
+
expect(wrapper.vm.showPopover).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should show on button click', async() => {
|
|
96
|
+
const wrapper = createWrapper();
|
|
97
|
+
const button = wrapper.find('button');
|
|
98
|
+
|
|
99
|
+
await button.trigger('click');
|
|
100
|
+
expect(wrapper.vm.showPopover).toBe(true);
|
|
101
|
+
expect(wrapper.vm.focusOpen).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should hide on Escape keydown', async() => {
|
|
105
|
+
const wrapper = createWrapper();
|
|
106
|
+
|
|
107
|
+
// Open it first
|
|
108
|
+
await wrapper.find('button').trigger('click');
|
|
109
|
+
expect(wrapper.vm.showPopover).toBe(true);
|
|
110
|
+
expect(wrapper.vm.focusOpen).toBe(true);
|
|
111
|
+
|
|
112
|
+
// Trigger escape
|
|
113
|
+
const root = wrapper.find('.popover-card-base');
|
|
114
|
+
|
|
115
|
+
await root.trigger('keydown.escape');
|
|
116
|
+
|
|
117
|
+
expect(wrapper.vm.showPopover).toBe(false);
|
|
118
|
+
expect(wrapper.vm.focusOpen).toBe(false);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('focus Trap', () => {
|
|
123
|
+
it('should NOT setup focus trap on mouseenter', async() => {
|
|
124
|
+
const wrapper = createWrapper();
|
|
125
|
+
const target = wrapper.find('.popover-card-target');
|
|
126
|
+
|
|
127
|
+
await target.trigger('mouseenter');
|
|
128
|
+
await wrapper.vm.$nextTick();
|
|
129
|
+
|
|
130
|
+
expect(wrapper.vm.focusOpen).toBe(false);
|
|
131
|
+
expect(mockFocusTrap).not.toHaveBeenCalled();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should setup focus trap when opened via click', async() => {
|
|
135
|
+
const wrapper = createWrapper({ fallbackFocus: '#my-fallback' });
|
|
136
|
+
const button = wrapper.find('button');
|
|
137
|
+
|
|
138
|
+
await button.trigger('click');
|
|
139
|
+
await wrapper.vm.$nextTick(); // Let watcher for `card` run
|
|
140
|
+
|
|
141
|
+
expect(wrapper.vm.focusOpen).toBe(true);
|
|
142
|
+
expect(mockFocusTrap).toHaveBeenCalledTimes(1);
|
|
143
|
+
|
|
144
|
+
// Check arguments passed to the composable
|
|
145
|
+
const focusTrapOptions = mockFocusTrap.mock.calls[0][2];
|
|
146
|
+
|
|
147
|
+
expect(focusTrapOptions.fallbackFocus).toBe('#my-fallback');
|
|
148
|
+
expect(focusTrapOptions.setReturnFocus()).toBe('.focus-button');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('slots', () => {
|
|
153
|
+
it('should render the default slot content', () => {
|
|
154
|
+
const wrapper = createWrapper({}, { default: '<span class="default-slot-content">Hello</span>' });
|
|
155
|
+
|
|
156
|
+
expect(wrapper.find('.default-slot-content').exists()).toBe(true);
|
|
157
|
+
expect(wrapper.find('.default-slot-content').text()).toBe('Hello');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should render the card-body slot content', async() => {
|
|
161
|
+
const wrapper = createWrapper({}, { 'card-body': '<div class="card-body-content">Card Body</div>' });
|
|
162
|
+
|
|
163
|
+
// Open popover to render the slot
|
|
164
|
+
await wrapper.find('button').trigger('click');
|
|
165
|
+
|
|
166
|
+
expect(wrapper.find('.card-body-content').exists()).toBe(true);
|
|
167
|
+
expect(wrapper.find('.card-body-content').text()).toBe('Card Body');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should pass a close function to the heading-action slot', async() => {
|
|
171
|
+
const wrapper = createWrapper({}, {
|
|
172
|
+
'heading-action': `
|
|
173
|
+
<template #heading-action="{ close }">
|
|
174
|
+
<button class="close-button" @click="close">Close</button>
|
|
175
|
+
</template>
|
|
176
|
+
`
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// Open popover
|
|
180
|
+
await wrapper.find('button').trigger('click');
|
|
181
|
+
expect(wrapper.vm.showPopover).toBe(true);
|
|
182
|
+
expect(wrapper.vm.focusOpen).toBe(true);
|
|
183
|
+
|
|
184
|
+
// Click the button that uses the `close` slot prop
|
|
185
|
+
await wrapper.find('.close-button').trigger('click');
|
|
186
|
+
|
|
187
|
+
// Due to the bug, this should be true, not false
|
|
188
|
+
expect(wrapper.vm.showPopover).toBe(false);
|
|
189
|
+
expect(wrapper.vm.focusOpen).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should allow overriding the entire card via the card slot', async() => {
|
|
193
|
+
const wrapper = createWrapper({}, { card: '<div class="custom-card">My Custom Card</div>' });
|
|
194
|
+
|
|
195
|
+
// Open popover
|
|
196
|
+
await wrapper.find('button').trigger('click');
|
|
197
|
+
|
|
198
|
+
expect(wrapper.find('.custom-card').exists()).toBe(true);
|
|
199
|
+
expect(wrapper.find('.custom-card').text()).toBe('My Custom Card');
|
|
200
|
+
// The default Card component should not be rendered
|
|
201
|
+
expect(wrapper.find('[id="popover-card"]').exists()).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import PopoverCard from '@shell/components/PopoverCard.vue';
|
|
3
|
+
import { computed } from 'vue';
|
|
4
|
+
import RcButton from '@components/RcButton/RcButton.vue';
|
|
5
|
+
import { useI18n } from '@shell/composables/useI18n';
|
|
6
|
+
import { useStore } from 'vuex';
|
|
7
|
+
import AutoscalerCard from '@shell/components/AutoscalerCard.vue';
|
|
8
|
+
|
|
9
|
+
export interface Props {
|
|
10
|
+
value: string | boolean;
|
|
11
|
+
row: any;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface Detail {
|
|
15
|
+
label: string;
|
|
16
|
+
value?: string | { component: any; props: any };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const props = withDefaults(defineProps<Props>(), { value: true });
|
|
20
|
+
const store = useStore();
|
|
21
|
+
const i18n = useI18n(store);
|
|
22
|
+
|
|
23
|
+
const checked = computed(() => props.value === true || props.value === 'true');
|
|
24
|
+
const actionIcon = computed(() => props.row.isAutoscalerPaused ? 'icon-play' : 'icon-pause');
|
|
25
|
+
const actionText = computed(() => props.row.isAutoscalerPaused ? i18n.t('autoscaler.card.resume') : i18n.t('autoscaler.card.pause'));
|
|
26
|
+
const stopPropagation = (event: Event) => {
|
|
27
|
+
// This is to prevent click events from getting to the table row which ends up selecting the row
|
|
28
|
+
event.stopPropagation();
|
|
29
|
+
};
|
|
30
|
+
</script>
|
|
31
|
+
|
|
32
|
+
<template>
|
|
33
|
+
<span
|
|
34
|
+
v-if="checked"
|
|
35
|
+
class="autoscaler"
|
|
36
|
+
@click="stopPropagation"
|
|
37
|
+
>
|
|
38
|
+
<PopoverCard
|
|
39
|
+
:card-title="i18n.t('autoscaler.card.title')"
|
|
40
|
+
fallback-focus=".autoscaler .action"
|
|
41
|
+
>
|
|
42
|
+
<i class="icon icon-checkmark" />
|
|
43
|
+
<template
|
|
44
|
+
v-if="props.row.canExplore"
|
|
45
|
+
#heading-action="{close}"
|
|
46
|
+
>
|
|
47
|
+
<RcButton
|
|
48
|
+
v-if="row.canPauseResumeAutoscaler"
|
|
49
|
+
secondary
|
|
50
|
+
small
|
|
51
|
+
class="action"
|
|
52
|
+
@click="() => {props.row.toggleAutoscalerRunner(); close()}"
|
|
53
|
+
>
|
|
54
|
+
<i :class="`icon ${actionIcon} icon-sm`" />
|
|
55
|
+
{{ actionText }}
|
|
56
|
+
</RcButton>
|
|
57
|
+
</template>
|
|
58
|
+
<template #card-body>
|
|
59
|
+
<AutoscalerCard :value="props.row" />
|
|
60
|
+
</template>
|
|
61
|
+
</PopoverCard>
|
|
62
|
+
</span>
|
|
63
|
+
<span
|
|
64
|
+
v-else
|
|
65
|
+
class="text-muted autoscaler"
|
|
66
|
+
>
|
|
67
|
+
—
|
|
68
|
+
</span>
|
|
69
|
+
</template>
|
|
70
|
+
|
|
71
|
+
<style lang="scss" scoped>
|
|
72
|
+
.autoscaler {
|
|
73
|
+
&:deep() {
|
|
74
|
+
.heading {
|
|
75
|
+
height: 24px;
|
|
76
|
+
|
|
77
|
+
.title {
|
|
78
|
+
font-size: 16px;
|
|
79
|
+
font-weight: 600;
|
|
80
|
+
line-height: 24px;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
button.btn.action {
|
|
85
|
+
line-height: 15px;
|
|
86
|
+
font-size: 12px;
|
|
87
|
+
height: 24px;
|
|
88
|
+
min-height: initial;
|
|
89
|
+
padding: 0 8px;
|
|
90
|
+
|
|
91
|
+
i {
|
|
92
|
+
margin-right: 8px;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
</style>
|