@rancher/shell 3.0.11 → 3.0.12-rc.1

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 (98) hide show
  1. package/assets/styles/base/_mixins.scss +31 -0
  2. package/assets/styles/base/_variables.scss +2 -0
  3. package/assets/styles/themes/_modern.scss +6 -5
  4. package/assets/translations/en-us.yaml +5 -4
  5. package/assets/translations/zh-hans.yaml +0 -3
  6. package/components/EmptyProductPage.vue +76 -0
  7. package/components/Resource/Detail/CopyToClipboard.vue +1 -2
  8. package/components/Resource/Detail/Metadata/KeyValueRow.vue +9 -3
  9. package/components/Resource/Detail/TitleBar/__tests__/__snapshots__/index.test.ts.snap +31 -0
  10. package/components/Resource/Detail/TitleBar/__tests__/index.test.ts +45 -1
  11. package/components/Resource/Detail/TitleBar/index.vue +1 -1
  12. package/components/Resource/Detail/ViewOptions/__tests__/__snapshots__/index.test.ts.snap +9 -0
  13. package/components/Resource/Detail/ViewOptions/__tests__/index.test.ts +62 -0
  14. package/components/Resource/Detail/ViewOptions/index.vue +2 -1
  15. package/components/ResourceList/Masthead.vue +25 -2
  16. package/components/SideNav.vue +13 -0
  17. package/components/__tests__/PromptModal.test.ts +2 -0
  18. package/components/fleet/FleetClusters.vue +1 -0
  19. package/components/fleet/__tests__/FleetClusters.test.ts +71 -0
  20. package/components/form/NodeScheduling.vue +17 -3
  21. package/components/form/PrivateRegistry.vue +69 -0
  22. package/components/form/__tests__/PrivateRegistry.test.ts +133 -0
  23. package/components/formatter/WorkloadHealthScale.vue +3 -1
  24. package/components/nav/Group.vue +26 -3
  25. package/components/nav/Header.vue +32 -7
  26. package/components/nav/TopLevelMenu.vue +15 -1
  27. package/config/pagination-table-headers.js +8 -1
  28. package/config/product/apps.js +2 -1
  29. package/config/product/auth.js +1 -0
  30. package/config/product/backup.js +1 -0
  31. package/config/product/compliance.js +1 -1
  32. package/config/product/explorer.js +25 -6
  33. package/config/product/fleet.js +1 -0
  34. package/config/product/gatekeeper.js +1 -0
  35. package/config/product/istio.js +1 -0
  36. package/config/product/logging.js +1 -0
  37. package/config/product/longhorn.js +2 -1
  38. package/config/product/manager.js +1 -0
  39. package/config/product/monitoring.js +1 -0
  40. package/config/product/navlinks.js +1 -0
  41. package/config/product/neuvector.js +2 -1
  42. package/config/product/settings.js +1 -0
  43. package/config/product/uiplugins.js +1 -0
  44. package/core/__tests__/plugin-products-helpers.test.ts +454 -0
  45. package/core/__tests__/plugin-products.test.ts +3219 -0
  46. package/core/extension-manager-impl.js +30 -1
  47. package/core/plugin-products-base.ts +375 -0
  48. package/core/plugin-products-extending.ts +44 -0
  49. package/core/plugin-products-helpers.ts +262 -0
  50. package/core/plugin-products-top-level.ts +66 -0
  51. package/core/plugin-products-type-guards.ts +33 -0
  52. package/core/plugin-products.ts +50 -0
  53. package/core/plugin-types.ts +222 -0
  54. package/core/plugin.ts +45 -10
  55. package/core/productDebugger.js +48 -0
  56. package/core/types.ts +95 -11
  57. package/detail/__tests__/__snapshots__/fleet.cattle.io.bundle.test.ts.snap +52 -0
  58. package/detail/__tests__/fleet.cattle.io.bundle.test.ts +171 -0
  59. package/detail/fleet.cattle.io.bundle.vue +21 -34
  60. package/dialog/ExtensionCatalogInstallDialog.vue +1 -1
  61. package/dialog/InstallExtensionDialog.vue +6 -27
  62. package/dialog/UninstallExistingExtensionDialog.vue +141 -0
  63. package/dialog/UninstallExtensionDialog.vue +4 -26
  64. package/dialog/__tests__/UninstallExistingExtensionDialog.test.ts +114 -0
  65. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +1 -0
  66. package/edit/provisioning.cattle.io.cluster/__tests__/Ingress.test.ts +176 -0
  67. package/edit/provisioning.cattle.io.cluster/rke2.vue +4 -1
  68. package/edit/provisioning.cattle.io.cluster/tabs/Basics.vue +6 -0
  69. package/edit/provisioning.cattle.io.cluster/tabs/Ingress.vue +7 -2
  70. package/list/provisioning.cattle.io.cluster.vue +0 -1
  71. package/list/workload.vue +11 -4
  72. package/mixins/resource-fetch.js +12 -3
  73. package/models/pod.js +18 -0
  74. package/models/workload.js +20 -2
  75. package/package.json +1 -2
  76. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +0 -1
  77. package/pages/c/_cluster/settings/brand.vue +4 -4
  78. package/pages/c/_cluster/uiplugins/__tests__/index.test.ts +231 -13
  79. package/pages/c/_cluster/uiplugins/index.vue +143 -37
  80. package/plugins/dashboard-store/__tests__/resource-class.test.ts +1 -0
  81. package/plugins/dashboard-store/actions.js +3 -2
  82. package/plugins/dashboard-store/resource-class.js +62 -6
  83. package/plugins/plugin.js +16 -0
  84. package/plugins/steve/steve-pagination-utils.ts +7 -0
  85. package/scripts/typegen.sh +13 -1
  86. package/store/__tests__/type-map.test.ts +84 -24
  87. package/store/type-map.js +42 -3
  88. package/tsconfig.paths.json +1 -0
  89. package/types/resources/pod.ts +18 -0
  90. package/types/shell/index.d.ts +8506 -2909
  91. package/types/store/dashboard-store.types.ts +5 -0
  92. package/types/store/pagination.types.ts +6 -0
  93. package/utils/axios.js +1 -4
  94. package/utils/dynamic-importer.js +3 -2
  95. package/utils/pagination-utils.ts +1 -1
  96. package/utils/uiplugins.ts +12 -16
  97. package/utils/validators/__tests__/private-registry.test.ts +76 -0
  98. package/utils/validators/private-registry.ts +28 -0
@@ -161,3 +161,34 @@
161
161
  // we need to use !important because it needs to superseed other classes that might impact outlines
162
162
  outline: 2px solid var(--primary-keyboard-focus);
163
163
  }
164
+
165
+ // -------------------------------------------------------------------------------------------------
166
+ // Extension dialog styles
167
+
168
+ @mixin extension-dialog {
169
+ padding: 8px 16px 16px 16px;
170
+
171
+ h4 {
172
+ font-weight: bold;
173
+ }
174
+
175
+ .dialog-panel {
176
+ display: flex;
177
+ flex-direction: column;
178
+ min-height: 96px;
179
+
180
+ .dialog-info {
181
+ flex: 1;
182
+ }
183
+ }
184
+
185
+ .dialog-buttons {
186
+ display: flex;
187
+ justify-content: flex-end;
188
+ margin-top: 24px;
189
+
190
+ > *:not(:last-child) {
191
+ margin-right: 8px;
192
+ }
193
+ }
194
+ }
@@ -42,6 +42,8 @@ $z-indexes: (
42
42
 
43
43
  cruFooter: 19,
44
44
 
45
+ copyToClipboard: 20,
46
+
45
47
  loadingMain: 51,
46
48
 
47
49
  slide-in: 52,
@@ -74,6 +74,7 @@ $contrasted-light: $lightest !default;
74
74
  $selected: rgba(#3D98D3, .5);
75
75
 
76
76
  $drag-over: #DCDEE7;
77
+ $body-bg : $lightest;
77
78
 
78
79
  BODY, .theme-light {
79
80
 
@@ -418,7 +419,7 @@ BODY, .theme-light {
418
419
  }
419
420
 
420
421
 
421
- --body-bg : #{$lightest};
422
+ --body-bg : #{$body-bg};
422
423
  --body-text : #{$darkest};
423
424
  --body-text-hover : var(--body-text);
424
425
  --scrollbar-thumb : #{$dark};
@@ -724,8 +725,8 @@ BODY, .theme-light {
724
725
  --rc-disabled-background: #{$gray001};
725
726
  --rc-disabled-text-color: #{$gray004};
726
727
 
727
- --rc-section-background-primary: #{$lightest};
728
- --rc-section-background-secondary: #{$lighter};
728
+ --rc-section-background-primary: #{$body-bg};
729
+ --rc-section-background-secondary: #{$grey-5};
729
730
  --rc-section-action-color: #{$gray009};
730
731
 
731
732
  --rc-image-bg: #{$lightest};
@@ -1072,8 +1073,8 @@ BODY, .theme-dark {
1072
1073
  --rc-disabled-background: #{$gray005};
1073
1074
  --rc-disabled-text-color: #{$gray004};
1074
1075
 
1075
- --rc-section-background-primary: #{$gray007};
1076
- --rc-section-background-secondary: #{$gray008};
1076
+ --rc-section-background-primary: #{$body-bg};
1077
+ --rc-section-background-secondary: #{$darkest};
1077
1078
  --rc-section-action-color: #{$gray010};
1078
1079
 
1079
1080
  --rc-image-bg: #{$lightest};
@@ -2989,7 +2989,7 @@ fleet:
2989
2989
  add: Add Selector
2990
2990
  tolerations:
2991
2991
  label: Tolerations
2992
- description: "List of node taints to tolerate (requires Kubernetes >= 1.6)"
2992
+ description: "List of node taints to tolerate."
2993
2993
  add: Add Toleration
2994
2994
  priorityClassName:
2995
2995
  label: Priority Class Name
@@ -5578,6 +5578,9 @@ plugins:
5578
5578
  prompt: "Are you sure that you want to install this extension?"
5579
5579
  version: Version
5580
5580
  warnNotCertified: Please ensure that you are aware of the risks of installing Extensions from untrusted authors
5581
+ alreadyInstalledTitle: This extension is already installed from another source
5582
+ alreadyInstalledPrompt: To install it from this source, you need to uninstall the existing version first and reload the page (required). Would you like to continue?
5583
+ uninstallExisting: Uninstall existing version
5581
5584
  upgrade:
5582
5585
  label: Upgrade
5583
5586
  title: Upgrade extension {name}
@@ -6988,6 +6991,7 @@ tableHeaders:
6988
6991
  progress: Progress
6989
6992
  podImages: Image
6990
6993
  podRestarts: Restarts
6994
+ podLastRestart: Last Restart
6991
6995
  pods: Pods
6992
6996
  pod-Selector: Pod-Selector
6993
6997
  providers: Providers
@@ -8045,10 +8049,7 @@ typeDescription:
8045
8049
  monitoring.coreos.com.prometheus: A Prometheus server is a Prometheus deployment whose scrape configuration and rules are determined by selected ServiceMonitors, PodMonitors, and PrometheusRules and whose alerts will be sent to all selected Alertmanagers with the custom resource's configuration.
8046
8050
  monitoring.coreos.com.alertmanager: An alert manager is deployment whose configuration will be specified by a secret in the same namespace, which determines which alerts should go to which receiver.
8047
8051
  node: The base Kubernetes Node resource represents a virtual or physical machine which hosts deployments. To manage the machine lifecycle, if available, go to Cluster Management.
8048
- catalog.cattle.io.clusterrepo: 'A chart repository is a Helm repository or {vendor} git based application catalog. It provides the list of available charts in the cluster.'
8049
- catalog.cattle.io.clusterrepo.local: ' A chart repository is a Helm repository or {vendor} git based application catalog. It provides the list of available charts in the cluster. Cluster Templates are deployed via Helm charts.'
8050
8052
  catalog.cattle.io.operation: An operation is the list of recent Helm operations that have been applied to the cluster.
8051
- catalog.cattle.io.app: An installed application is a Helm 3 chart that was installed either via our charts or through the Helm CLI.
8052
8053
  logging.banzaicloud.io.clusterflow: Logs from the cluster will be collected and logged to the selected Cluster Output.
8053
8054
  logging.banzaicloud.io.clusteroutput: A cluster output defines which logging providers that logs can be sent to and is only effective when deployed in the namespace that the logging operator is in.
8054
8055
  logging.banzaicloud.io.flow: A flow defines which logs to collect and filter as well as which output to send the logs. The flow is a namespaced resource, which means logs will only be collected from the namespace that the flow is deployed in.
@@ -6339,10 +6339,7 @@ typeDescription:
6339
6339
  monitoring.coreos.com.prometheus: Prometheus server 是一个 Prometheus deployment,其抓取的配置和规则由选定的 ServiceMonitor、PodMonitor 和 PrometheusRule 决定。它将其告警信息发送给所有选择的具有定制资源配置的 AlertManager。
6340
6340
  monitoring.coreos.com.alertmanager: Alertmanager 是一个 deployment。其配置由同一命名空间中的密文指定,该密文决定了告警的接收器。
6341
6341
  node: Kubernetes 节点资源展示了承载 Deployment 的虚拟机或物理机。请进入"集群管理"页面管理可用节点的生命周期。
6342
- catalog.cattle.io.clusterrepo: 'Chart 仓库是一个 Helm 仓库或 {vendor} 基于 Git 的应用商店。此处列出了集群中可用的 Chart。'
6343
- catalog.cattle.io.clusterrepo.local: ' Chart 仓库是一个 Helm 仓库或 {vendor} 基于 Git 的应用商店。此处列出了集群中可用的 Chart。集群模板是通过 Helm Chart 部署的。'
6344
6342
  catalog.cattle.io.operation: 最近的操作指的是最近应用于集群的一系列 Helm 操作。
6345
- catalog.cattle.io.app: 已安装的应用指的是通过我们的 Chart 或 Helm CLI 安装的 Helm 3 Chart。
6346
6343
  logging.banzaicloud.io.clusterflow: 集群日志将被收集并投递到选定的 ClusterOutput 中。
6347
6344
  logging.banzaicloud.io.clusteroutput: ClusterOutput 定义了日志可以发送到哪些日志提供程序。只有部署在 Logging operator 所在的命名空间中时,ClusterOutput 才生效。
6348
6345
  logging.banzaicloud.io.flow: Flow 定义了要收集和过滤的日志,以及日志的输出目标。Flow 是一个命名空间资源。换言之,只有部署了该 Flow 的命名空间日志才能被该 Flow 收集。
@@ -0,0 +1,76 @@
1
+ <script>
2
+ export default {
3
+ name: 'EmptyProductPage',
4
+ layout: 'plain',
5
+ data() {
6
+ const err = this.$route.meta?.pageError;
7
+
8
+ let msg;
9
+
10
+ switch (err) {
11
+ case 'no-nav':
12
+ msg = [
13
+ 'When a component is not provided for a product, the layout with side navigation is used',
14
+ 'No child items were specified, so this "Default" empty view has been added',
15
+ 'Please add child items to this product'
16
+ ];
17
+ break;
18
+ default:
19
+ msg = ['No component defined for this extension product... Define a component or child pages so it can be rendered here.'];
20
+ }
21
+
22
+ return {
23
+ img: require('~shell/assets/images/generic-plugin.svg'),
24
+ msg,
25
+ };
26
+ },
27
+ };
28
+ </script>
29
+
30
+ <template>
31
+ <div class="empty-product-page">
32
+ <img
33
+ :src="img"
34
+ alt="Extension Product Error"
35
+ >
36
+ <div class="err-messages">
37
+ <p
38
+ v-for="(m, index) in msg"
39
+ :key="index"
40
+ >
41
+ {{ m }}
42
+ </p>
43
+ </div>
44
+ </div>
45
+ </template>
46
+
47
+ <style lang="scss" scoped>
48
+ .empty-product-page {
49
+ align-items: center;
50
+ display: flex;
51
+ justify-content: center;
52
+ opacity: 0.75;
53
+
54
+ > img {
55
+ width: 128px;
56
+ margin-bottom: 20px;
57
+ }
58
+
59
+ .err-messages {
60
+ display: flex;
61
+ align-items: center;
62
+ text-align: center;
63
+ flex-direction: column;
64
+
65
+ > * {
66
+ margin-bottom: 8px;
67
+ font-size: 16px;
68
+
69
+ &:last-child {
70
+ font-weight: bold;
71
+ color: var(--error);
72
+ }
73
+ }
74
+ }
75
+ }
76
+ </style>
@@ -70,8 +70,7 @@ const onClick = (ev: MouseEvent) => {
70
70
  border-color: var(--success-border);
71
71
  color: var(--success-text);
72
72
 
73
- transition: all 0.25s;
74
- transition-timing-function: ease;
73
+ transition: background-color 0.25s ease, border-color 0.25s ease, color 0.25s ease;
75
74
  }
76
75
 
77
76
  &:focus-visible {
@@ -77,12 +77,15 @@ const previewId = randomStr();
77
77
  position: relative;
78
78
  padding: 0;
79
79
 
80
+ $topShift: -6px;
81
+ $topShiftHidden: -100vh; //100% of the viewport
82
+
80
83
  .copy-to-clipboard {
81
84
  position: fixed;
82
85
 
83
86
  right: -20px;
84
- top: -6px;
85
- z-index: 20px;
87
+ top: $topShiftHidden;
88
+ z-index: z-index('copyToClipboard');
86
89
  }
87
90
 
88
91
  &, .btn, .rc-tag {
@@ -126,13 +129,16 @@ const previewId = randomStr();
126
129
  }
127
130
 
128
131
  & + .copy-to-clipboard {
132
+ // This is how we "hide" the component but still allow it to be visible to accessibility (tab focus)
129
133
  position: absolute;
134
+ top: $topShift;
130
135
  }
131
136
  }
132
137
 
133
138
  .copy-to-clipboard:focus-visible, .copy-to-clipboard:hover {
139
+ // This is how we "hide" the component but still allow it to be visible to accessibility (tab focus)
134
140
  position: absolute;
135
-
141
+ top: $topShift;
136
142
  }
137
143
 
138
144
  .btn:has(+ .copy-to-clipboard:focus-visible), .btn:has(+ .copy-to-clipboard:hover) {
@@ -0,0 +1,31 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`component: TitleBar/index should match the full component snapshot 1`] = `
4
+ <div class="title-bar">
5
+ <div class="top">
6
+ <h1 class="title">
7
+ <!----><a class="resource-link">RESOURCE_TYPE_LABEL: </a><span class="resource-name masthead-resource-title">RESOURCE_NAME</span><span class="badge-state bg-success badge-state"><!--v-if--><span class="msg">Active</span></span>
8
+ </h1>
9
+ <div class="actions"><button role="button" class="rc-button btn variant-primary btn-medium">
10
+ <!--v-if-->Deploy
11
+ <!--v-if-->
12
+ </button><button role="button" class="rc-button btn variant-secondary btn-large">
13
+ <!--v-if-->Rollback
14
+ <!--v-if-->
15
+ </button><button role="button" class="rc-button btn variant-primary btn-large show-configuration" data-testid="show-configuration-cta" aria-label="component.resource.detail.titleBar.ariaLabel.showConfiguration-{&quot;resource&quot;:&quot;RESOURCE_NAME&quot;}">
16
+ <!--v-if--><i class="icon icon-document" aria-hidden="true"></i> component.resource.detail.titleBar.showConfiguration
17
+ <!--v-if-->
18
+ </button>
19
+ <div class="v-popper v-popper--theme-dropdown"><button role="button" class="rc-button btn variant-multi-action btn-medium" aria-haspopup="menu" aria-expanded="false" data-testid="masthead-action-menu" aria-label="component.resource.detail.titleBar.ariaLabel.actionMenu-{&quot;resource&quot;:&quot;RESOURCE_NAME&quot;}">
20
+ <!--v-if--><i class="icon icon-actions"></i>
21
+ <!--v-if-->
22
+ </button></div>
23
+ <div class="popperContainer">
24
+ <!--Empty container for mounting popper content-->
25
+ </div>
26
+ </div>
27
+ </div>
28
+ <div class="bottom description text-deemphasized">A test description</div>
29
+ <!--v-if-->
30
+ </div>
31
+ `;
@@ -1,5 +1,5 @@
1
1
  import { mount, RouterLinkStub } from '@vue/test-utils';
2
- import TitleBar from '@shell/components/Resource/Detail/TitleBar/index.vue';
2
+ import TitleBar, { AdditionalActionButton } from '@shell/components/Resource/Detail/TitleBar/index.vue';
3
3
  import ActionMenu from '@shell/components/ActionMenuShell.vue';
4
4
  import { createStore } from 'vuex';
5
5
  import { defineComponent, h } from 'vue';
@@ -240,5 +240,49 @@ describe('component: TitleBar/index', () => {
240
240
  expect(wrapper.find('.slot-button').exists()).toBeTruthy();
241
241
  expect(wrapper.find('.slot-button').text()).toBe('Slot Button');
242
242
  });
243
+
244
+ it('should render the actions container correctly when additional-actions slot contains nested buttons', async() => {
245
+ const wrapper = mount(TitleBar, {
246
+ props: { resourceTypeLabel, resourceName },
247
+ slots: { 'additional-actions': '<div class="btn-group"><button class="nested-btn">A</button><button class="nested-btn">B</button></div>' },
248
+ global: { stubs: { 'router-link': RouterLinkStub }, provide: { store } }
249
+ });
250
+
251
+ const actions = wrapper.find('.actions');
252
+
253
+ expect(actions.find('.btn-group').exists()).toBeTruthy();
254
+ expect(actions.findAll('.btn-group .nested-btn')).toHaveLength(2);
255
+ });
256
+ });
257
+
258
+ it('should match the full component snapshot', () => {
259
+ const additionalActions: AdditionalActionButton[] = [
260
+ {
261
+ label: 'Deploy', variant: 'primary', onClick: jest.fn()
262
+ },
263
+ {
264
+ label: 'Rollback', variant: 'secondary', size: 'large', onClick: jest.fn()
265
+ },
266
+ ];
267
+
268
+ const wrapper = mount(TitleBar, {
269
+ props: {
270
+ resource: {},
271
+ resourceTypeLabel,
272
+ resourceName,
273
+ resourceTo,
274
+ description: 'A test description',
275
+ badge: { color: 'bg-success', label: 'Active' },
276
+ additionalActions,
277
+ actionMenuResource: { resource: 'test-menu' },
278
+ onShowConfiguration() {},
279
+ },
280
+ global: {
281
+ stubs: { 'router-link': RouterLinkStub },
282
+ provide: { store }
283
+ }
284
+ });
285
+
286
+ expect(wrapper.html()).toMatchSnapshot();
243
287
  });
244
288
  });
@@ -178,7 +178,7 @@ const showAdditionalActionButtons = computed(() => isArray(additionalActions));
178
178
  align-items: center;
179
179
  }
180
180
 
181
- .show-configuration, &:deep() .actions button {
181
+ .show-configuration, &:deep() .actions > button {
182
182
  margin-left: 16px;
183
183
  }
184
184
 
@@ -0,0 +1,9 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`component: ViewOptions/index should match the snapshot 1`] = `
4
+ <div class="btn-group"><button data-testid="button-group-child-0" type="button" class="btn bg-primary" role="button" aria-label="%resourceDetail.masthead.detail%" aria-pressed="true">
5
+ <!--v-if--><span k="resourceDetail.masthead.detail"></span>
6
+ </button><button data-testid="button-group-child-1" type="button" class="btn bg-disabled" role="button" aria-label="%resourceDetail.masthead.graph%" aria-pressed="false">
7
+ <!--v-if--><span k="resourceDetail.masthead.graph"></span>
8
+ </button></div>
9
+ `;
@@ -0,0 +1,62 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import ViewOptions from '@shell/components/Resource/Detail/ViewOptions/index.vue';
3
+ import ButtonGroup from '@shell/components/ButtonGroup.vue';
4
+ import { _CONFIG, _GRAPH } from '@shell/config/query-params';
5
+
6
+ const mockPush = jest.fn();
7
+ const mockQuery = { view: _CONFIG };
8
+
9
+ jest.mock('vue-router', () => ({
10
+ useRouter: () => ({ push: mockPush }),
11
+ useRoute: () => ({ query: mockQuery }),
12
+ }));
13
+
14
+ describe('component: ViewOptions/index', () => {
15
+ beforeEach(() => {
16
+ jest.clearAllMocks();
17
+ mockQuery.view = _CONFIG;
18
+ });
19
+
20
+ const createWrapper = () => {
21
+ return mount(ViewOptions);
22
+ };
23
+
24
+ it('should render a ButtonGroup component', () => {
25
+ const wrapper = createWrapper();
26
+
27
+ expect(wrapper.findComponent(ButtonGroup).exists()).toBeTruthy();
28
+ });
29
+
30
+ it('should provide two view options: detail and graph', () => {
31
+ const wrapper = createWrapper();
32
+ const buttonGroup = wrapper.findComponent(ButtonGroup);
33
+ const options = buttonGroup.props('options');
34
+
35
+ expect(options).toHaveLength(2);
36
+ expect(options[0].value).toStrictEqual(_CONFIG);
37
+ expect(options[1].value).toStrictEqual(_GRAPH);
38
+ });
39
+
40
+ it('should set the initial view from the route query', () => {
41
+ const wrapper = createWrapper();
42
+ const buttonGroup = wrapper.findComponent(ButtonGroup);
43
+
44
+ expect(buttonGroup.props('value')).toStrictEqual(_CONFIG);
45
+ });
46
+
47
+ it('should push to router when view changes', async() => {
48
+ const wrapper = createWrapper();
49
+ const buttons = wrapper.findAll('.btn-group button');
50
+ const graphButton = buttons[1];
51
+
52
+ await graphButton.trigger('click');
53
+
54
+ expect(mockPush).toHaveBeenCalledWith({ query: { view: _GRAPH } });
55
+ });
56
+
57
+ it('should match the snapshot', () => {
58
+ const wrapper = createWrapper();
59
+
60
+ expect(wrapper.html()).toMatchSnapshot();
61
+ });
62
+ });
@@ -14,7 +14,8 @@ const view = ref(currentView.value);
14
14
  const viewOptions = computed(() => {
15
15
  return [
16
16
  {
17
- labelKey: 'resourceDetail.masthead.config',
17
+ labelKey: 'resourceDetail.masthead.detail',
18
+ // _CONFIG is the default when there is no query on the router
18
19
  value: _CONFIG,
19
20
  },
20
21
  {
@@ -85,7 +85,29 @@ export default {
85
85
  data() {
86
86
  const params = { ...this.$route.params };
87
87
 
88
- const formRoute = { name: `${ this.$route.name }-create`, params };
88
+ // Determine if the current product has a topLevelProduct defined, and if so,
89
+ // use that for the formRoute instead of the current route's product.
90
+ // This allows resources from extensions (new product registration) to use the correct route for creation,
91
+ // which may be different from the route of the resource list.
92
+ let currPluginName = '';
93
+ let formRoute;
94
+ let overrideCreateLocationByExtension = false;
95
+ const plugins = this.$extension.getPlugins();
96
+
97
+ Object.keys(plugins).forEach((key) => {
98
+ if (plugins[key].productNames.includes(this.$store.getters['productId'])) {
99
+ currPluginName = key;
100
+ }
101
+ });
102
+
103
+ if (currPluginName && plugins[currPluginName]?.topLevelProduct) {
104
+ // override create route for extension resource lists
105
+ formRoute = { name: `${ this.$route.name }-create`, params: { ...params, product: this.$store.getters['productId'] } };
106
+ overrideCreateLocationByExtension = true;
107
+ } else {
108
+ // this was the original logic before the topLevelProduct override was added
109
+ formRoute = { name: `${ this.$route.name }-create`, params };
110
+ }
89
111
 
90
112
  const hasEditComponent = this.$store.getters['type-map/hasCustomEdit'](this.resource);
91
113
 
@@ -96,6 +118,7 @@ export default {
96
118
  };
97
119
 
98
120
  return {
121
+ overrideCreateLocationByExtension,
99
122
  formRoute,
100
123
  yamlRoute,
101
124
  hasEditComponent,
@@ -149,7 +172,7 @@ export default {
149
172
  },
150
173
 
151
174
  _createLocation() {
152
- return this.createLocation || this.formRoute;
175
+ return this.overrideCreateLocationByExtension ? this.formRoute : this.createLocation || this.formRoute;
153
176
  },
154
177
 
155
178
  _yamlCreateLocation() {
@@ -229,6 +229,19 @@ export default {
229
229
 
230
230
  this.getExplorerGroups(out);
231
231
 
232
+ // If there's a root group, pull its children up to the top level
233
+ // so that we can order them alongside group items in the nav
234
+ const rootGroupIndex = out.findIndex((g) => g.name.toLowerCase() === 'root');
235
+ const rootGroup = out[rootGroupIndex];
236
+
237
+ if (rootGroup && rootGroup.children?.length) {
238
+ out.splice(rootGroupIndex, 1);
239
+
240
+ rootGroup.children.forEach((child) => {
241
+ addObject(out, { ...child, children: [] });
242
+ });
243
+ }
244
+
232
245
  replaceWith(this.groups, ...sortBy(out, ['weight:desc', 'label']));
233
246
 
234
247
  this.gettingGroups = false;
@@ -24,6 +24,7 @@ import DeveloperLoadExtensionDialog from '@shell/dialog/DeveloperLoadExtensionDi
24
24
  import AddExtensionReposDialog from '@shell/dialog/AddExtensionReposDialog.vue';
25
25
  import InstallExtensionDialog from '@shell/dialog/InstallExtensionDialog.vue';
26
26
  import UninstallExtensionDialog from '@shell/dialog/UninstallExtensionDialog.vue';
27
+ import UninstallExistingExtensionDialog from '@shell/dialog/UninstallExistingExtensionDialog.vue';
27
28
  import KnownHostsEditDialog from '@shell/dialog/KnownHostsEditDialog.vue';
28
29
  import ImportDialog from '@shell/dialog/ImportDialog.vue';
29
30
  import SearchDialog from '@shell/dialog/SearchDialog.vue';
@@ -110,6 +111,7 @@ describe('component: PromptModal', () => {
110
111
  ['AddExtensionReposDialog', AddExtensionReposDialog],
111
112
  ['InstallExtensionDialog', InstallExtensionDialog],
112
113
  ['UninstallExtensionDialog', UninstallExtensionDialog],
114
+ ['UninstallExistingExtensionDialog', UninstallExistingExtensionDialog],
113
115
  ['KnownHostsEditDialog', KnownHostsEditDialog],
114
116
  ['ImportDialog', ImportDialog],
115
117
  ['SearchDialog', SearchDialog],
@@ -107,6 +107,7 @@ export default {
107
107
  :schema="schema"
108
108
  :headers="headers"
109
109
  :rows="rows"
110
+ :sub-rows="true"
110
111
  :loading="loading"
111
112
  :use-query-params-for-simple-filtering="useQueryParamsForSimpleFiltering"
112
113
  :ignore-filter="ignoreFilter"
@@ -573,4 +573,75 @@ describe('component: FleetClusters', () => {
573
573
  expect(additionalSubRow.exists()).toBe(false);
574
574
  });
575
575
  });
576
+
577
+ describe('labels visibility regardless of error state', () => {
578
+ it('should pass sub-rows prop as true to ResourceTable so labels always render', () => {
579
+ const wrapper = createWrapper();
580
+
581
+ // sub-rows=true ensures SortableTable.showSubRow() returns true,
582
+ // which makes the #additional-sub-row slot render regardless of stateDescription.
583
+ // Without this, labels only appear when there is an error (stateDescription).
584
+ const resourceTableStub = wrapper.findComponent('.resource-table') as any;
585
+
586
+ expect(resourceTableStub.props('subRows')).toBe(true);
587
+ });
588
+
589
+ it('should render labels when cluster has no stateDescription (no error)', () => {
590
+ const rows = [{
591
+ customLabels: ['env:prod', 'team:backend'],
592
+ displayCustomLabels: false,
593
+ stateDescription: undefined,
594
+ }];
595
+
596
+ const wrapper = createWrapper({ rows });
597
+ const tags = wrapper.findAll('.tag');
598
+
599
+ expect(tags).toHaveLength(2);
600
+ expect(tags[0].text()).toBe('env:prod');
601
+ expect(tags[1].text()).toBe('team:backend');
602
+ });
603
+
604
+ it('should render labels when cluster has a stateDescription (error)', () => {
605
+ const rows = [{
606
+ customLabels: ['env:prod', 'team:backend'],
607
+ displayCustomLabels: false,
608
+ stateDescription: 'Something went wrong',
609
+ }];
610
+
611
+ const wrapper = createWrapper({ rows });
612
+ const tags = wrapper.findAll('.tag');
613
+
614
+ expect(tags).toHaveLength(2);
615
+ expect(tags[0].text()).toBe('env:prod');
616
+ expect(tags[1].text()).toBe('team:backend');
617
+ });
618
+
619
+ it('should render labels when stateDescription is empty string', () => {
620
+ const rows = [{
621
+ customLabels: ['env:staging'],
622
+ displayCustomLabels: false,
623
+ stateDescription: '',
624
+ }];
625
+
626
+ const wrapper = createWrapper({ rows });
627
+ const tags = wrapper.findAll('.tag');
628
+
629
+ expect(tags).toHaveLength(1);
630
+ expect(tags[0].text()).toBe('env:staging');
631
+ });
632
+
633
+ it('should render labels when stateDescription is null', () => {
634
+ const rows = [{
635
+ customLabels: ['region:eu-west'],
636
+ displayCustomLabels: false,
637
+ stateDescription: null,
638
+ }];
639
+
640
+ const wrapper = createWrapper({ rows });
641
+ const tags = wrapper.findAll('.tag');
642
+
643
+ expect(tags).toHaveLength(1);
644
+ expect(tags[0].text()).toBe('region:eu-west');
645
+ });
646
+ });
576
647
  });