@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.
Files changed (88) hide show
  1. package/assets/brand/suse/dark/rancher-logo.svg +64 -1
  2. package/assets/brand/suse/rancher-logo.svg +1 -1
  3. package/assets/styles/global/_cards.scss +0 -3
  4. package/assets/styles/themes/_modern.scss +9 -1
  5. package/assets/styles/themes/_suse.scss +81 -24
  6. package/assets/translations/en-us.yaml +68 -3
  7. package/components/AutoscalerCard.vue +113 -0
  8. package/components/AutoscalerTab.vue +94 -0
  9. package/components/ClusterIconMenu.vue +1 -1
  10. package/components/ClusterProviderIcon.vue +1 -1
  11. package/components/IconOrSvg.vue +2 -2
  12. package/components/PopoverCard.vue +192 -0
  13. package/components/Resource/Detail/FetchLoader/composables.ts +18 -4
  14. package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/identifying-fields.test.ts +1 -1
  15. package/components/Resource/Detail/Metadata/IdentifyingInformation/identifying-fields.ts +4 -0
  16. package/components/Resource/Detail/ResourcePopover/ResourcePopoverCard.vue +2 -19
  17. package/components/Resource/Detail/ResourcePopover/__tests__/ResourcePopoverCard.test.ts +0 -29
  18. package/components/Resource/Detail/ResourcePopover/__tests__/index.test.ts +132 -150
  19. package/components/Resource/Detail/ResourcePopover/index.vue +54 -159
  20. package/components/ResourceDetail/Masthead/latest.vue +29 -0
  21. package/components/ResourceList/Masthead.vue +1 -1
  22. package/components/__tests__/AutoscalerCard.test.ts +154 -0
  23. package/components/__tests__/AutoscalerTab.test.ts +125 -0
  24. package/components/__tests__/PopoverCard.test.ts +204 -0
  25. package/components/formatter/Autoscaler.vue +97 -0
  26. package/components/formatter/InternalExternalIP.vue +195 -24
  27. package/components/formatter/__tests__/Autoscaler.test.ts +156 -0
  28. package/components/formatter/__tests__/InternalExternalIP.test.ts +133 -0
  29. package/components/nav/Group.vue +12 -3
  30. package/components/nav/TopLevelMenu.vue +2 -2
  31. package/composables/useInterval.ts +15 -0
  32. package/config/labels-annotations.js +8 -1
  33. package/config/product/manager.js +20 -9
  34. package/config/router/routes.js +4 -0
  35. package/config/settings.ts +2 -1
  36. package/config/table-headers.js +8 -0
  37. package/config/types.js +2 -0
  38. package/config/version.js +1 -1
  39. package/core/types-provisioning.ts +3 -0
  40. package/detail/provisioning.cattle.io.cluster.vue +12 -1
  41. package/directives/ui-context.ts +8 -2
  42. package/edit/auth/github.vue +5 -0
  43. package/edit/cloudcredential.vue +1 -1
  44. package/edit/fleet.cattle.io.gitrepo.vue +0 -10
  45. package/edit/provisioning.cattle.io.cluster/CustomCommand.vue +32 -5
  46. package/edit/provisioning.cattle.io.cluster/__tests__/CustomCommand.test.ts +35 -0
  47. package/edit/provisioning.cattle.io.cluster/__tests__/Networking.test.ts +132 -0
  48. package/edit/provisioning.cattle.io.cluster/index.vue +18 -12
  49. package/edit/provisioning.cattle.io.cluster/rke2.vue +39 -8
  50. package/edit/provisioning.cattle.io.cluster/tabs/MachinePool.vue +107 -5
  51. package/edit/provisioning.cattle.io.cluster/tabs/networking/index.vue +90 -3
  52. package/initialize/install-plugins.js +3 -1
  53. package/list/provisioning.cattle.io.cluster.vue +15 -2
  54. package/machine-config/amazonec2.vue +36 -135
  55. package/machine-config/components/EC2Networking.vue +474 -0
  56. package/machine-config/components/__tests__/EC2Networking.test.ts +94 -0
  57. package/machine-config/components/__tests__/utils/vpcSubnetMockData.js +294 -0
  58. package/machine-config/digitalocean.vue +11 -0
  59. package/models/cluster/node.js +13 -6
  60. package/models/cluster.x-k8s.io.machine.js +10 -20
  61. package/models/cluster.x-k8s.io.machinedeployment.js +5 -1
  62. package/models/management.cattle.io.kontainerdriver.js +1 -0
  63. package/models/provisioning.cattle.io.cluster.js +223 -2
  64. package/package.json +2 -2
  65. package/pages/c/_cluster/apps/charts/install.vue +1 -1
  66. package/pages/c/_cluster/manager/hostedprovider/index.vue +209 -0
  67. package/plugins/dynamic-content.js +13 -0
  68. package/rancher-components/Form/Checkbox/Checkbox.vue +1 -1
  69. package/rancher-components/Pill/RcStatusBadge/RcStatusBadge.vue +8 -0
  70. package/store/features.js +1 -0
  71. package/store/notifications.ts +32 -1
  72. package/store/plugins.js +7 -3
  73. package/store/prefs.js +1 -0
  74. package/types/notifications/index.ts +24 -3
  75. package/types/shell/index.d.ts +27 -2
  76. package/utils/__tests__/object.test.ts +19 -0
  77. package/utils/autoscaler-utils.ts +7 -0
  78. package/utils/dynamic-content/__tests__/announcement.test.ts +498 -0
  79. package/utils/dynamic-content/announcement.ts +112 -0
  80. package/utils/dynamic-content/example.json +40 -0
  81. package/utils/dynamic-content/index.ts +6 -2
  82. package/utils/dynamic-content/new-release.ts +1 -1
  83. package/utils/dynamic-content/notification-handler.ts +48 -0
  84. package/utils/dynamic-content/types.d.ts +33 -1
  85. package/utils/object.js +20 -2
  86. package/utils/scroll.js +7 -0
  87. package/utils/settings.ts +15 -0
  88. 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
+ &mdash;
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>