@rancher/shell 3.0.8-rc.8 → 3.0.8

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 (260) hide show
  1. package/apis/impl/apis.ts +61 -0
  2. package/apis/index.ts +40 -0
  3. package/apis/intf/modal.ts +90 -0
  4. package/apis/intf/shell.ts +36 -0
  5. package/apis/intf/slide-in.ts +98 -0
  6. package/apis/intf/system.ts +41 -0
  7. package/apis/shell/__tests__/modal.test.ts +80 -0
  8. package/apis/shell/__tests__/notifications.test.ts +71 -0
  9. package/apis/shell/__tests__/slide-in.test.ts +54 -0
  10. package/apis/shell/__tests__/system.test.ts +129 -0
  11. package/apis/shell/index.ts +38 -0
  12. package/apis/shell/modal.ts +41 -0
  13. package/apis/shell/notifications.ts +65 -0
  14. package/apis/shell/slide-in.ts +33 -0
  15. package/apis/shell/system.ts +65 -0
  16. package/apis/vue-shim.d.ts +11 -0
  17. package/assets/brand/suse/dark/rancher-logo.svg +1 -64
  18. package/assets/styles/global/_tooltip.scss +6 -1
  19. package/assets/translations/en-us.yaml +14 -1
  20. package/components/ActionMenuShell.vue +3 -1
  21. package/components/BackLink.vue +8 -0
  22. package/components/BannerGraphic.vue +1 -5
  23. package/components/BrandImage.vue +17 -6
  24. package/components/Cron/CronExpressionEditor.vue +1 -1
  25. package/components/Cron/CronExpressionEditorModal.vue +1 -1
  26. package/components/CruResource.vue +8 -1
  27. package/components/Drawer/ResourceDetailDrawer/ConfigTab.vue +1 -0
  28. package/components/Drawer/ResourceDetailDrawer/__tests__/composables.test.ts +50 -1
  29. package/components/Drawer/ResourceDetailDrawer/composables.ts +19 -0
  30. package/components/Drawer/ResourceDetailDrawer/index.vue +4 -1
  31. package/components/Drawer/ResourceDetailDrawer/types.ts +2 -1
  32. package/components/LocaleSelector.vue +2 -2
  33. package/components/ModalManager.vue +11 -1
  34. package/components/Questions/__tests__/Yaml.test.ts +1 -1
  35. package/components/Questions/__tests__/index.test.ts +159 -0
  36. package/components/RelatedResources.vue +5 -0
  37. package/components/Resource/Detail/Metadata/Annotations/index.vue +2 -2
  38. package/components/Resource/Detail/Metadata/Labels/index.vue +2 -2
  39. package/components/Resource/Detail/Metadata/index.vue +3 -3
  40. package/components/Resource/Detail/ResourcePopover/index.vue +5 -1
  41. package/components/Resource/Detail/composables.ts +2 -2
  42. package/components/ResourceDetail/Masthead/latest.vue +23 -21
  43. package/components/ResourceDetail/index.vue +3 -0
  44. package/components/ResourceTable.vue +54 -21
  45. package/components/SlideInPanelManager.vue +16 -11
  46. package/components/SortableTable/THead.vue +2 -1
  47. package/components/SortableTable/index.vue +20 -2
  48. package/components/Tabbed/__tests__/index.test.ts +86 -0
  49. package/components/Tabbed/index.vue +37 -2
  50. package/components/__tests__/NamespaceFilter.test.ts +49 -0
  51. package/components/auth/SelectPrincipal.vue +28 -6
  52. package/components/auth/__tests__/SelectPrincipal.test.ts +119 -0
  53. package/components/auth/login/ldap.vue +3 -3
  54. package/components/fleet/FleetSecretSelector.vue +1 -1
  55. package/components/form/KeyValue.vue +1 -1
  56. package/components/form/NameNsDescription.vue +1 -1
  57. package/components/form/NodeScheduling.vue +2 -2
  58. package/components/form/ResourceTabs/composable.ts +2 -2
  59. package/components/form/ResourceTabs/index.vue +0 -2
  60. package/components/form/__tests__/NameNsDescription.test.ts +42 -0
  61. package/components/formatter/InternalExternalIP.vue +4 -1
  62. package/components/formatter/LinkName.vue +5 -0
  63. package/components/formatter/__tests__/InternalExternalIP.test.ts +1 -1
  64. package/components/nav/Group.vue +25 -7
  65. package/components/nav/Header.vue +1 -1
  66. package/components/nav/NamespaceFilter.vue +1 -0
  67. package/components/nav/Type.vue +17 -6
  68. package/components/nav/WindowManager/panels/TabBodyContainer.vue +1 -1
  69. package/components/nav/__tests__/Type.test.ts +59 -0
  70. package/components/templates/standalone.vue +1 -1
  71. package/composables/cruResource.ts +27 -0
  72. package/composables/focusTrap.ts +3 -1
  73. package/composables/resourceDetail.ts +15 -0
  74. package/composables/useI18n.ts +10 -1
  75. package/composables/useLabeledFormElement.ts +3 -4
  76. package/config/__test__/uiplugins.test.ts +309 -0
  77. package/config/labels-annotations.js +1 -0
  78. package/config/product/explorer.js +3 -1
  79. package/config/product/fleet.js +1 -1
  80. package/config/router/navigation-guards/clusters.js +3 -3
  81. package/config/router/navigation-guards/products.js +1 -1
  82. package/config/router/routes.js +7 -7
  83. package/config/types.js +7 -0
  84. package/config/uiplugins.js +46 -2
  85. package/core/__tests__/extension-manager-impl.test.js +437 -0
  86. package/core/extension-manager-impl.js +21 -25
  87. package/core/plugin-helpers.ts +2 -2
  88. package/core/plugin.ts +9 -1
  89. package/core/plugins-loader.js +2 -2
  90. package/core/types-provisioning.ts +5 -1
  91. package/core/types.ts +35 -0
  92. package/detail/provisioning.cattle.io.cluster.vue +9 -6
  93. package/dialog/DeveloperLoadExtensionDialog.vue +13 -4
  94. package/dialog/MoveNamespaceDialog.vue +20 -4
  95. package/dialog/RollbackWorkloadDialog.vue +2 -5
  96. package/dialog/SearchDialog.vue +1 -0
  97. package/dialog/__tests__/MoveNamespaceDialog.test.ts +249 -0
  98. package/directives/__tests__/clean-tooltip.test.ts +298 -0
  99. package/directives/clean-tooltip.ts +234 -0
  100. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +2 -2
  101. package/edit/__tests__/fleet.cattle.io.helmop.test.ts +100 -3
  102. package/edit/autoscaling.horizontalpodautoscaler/index.vue +1 -0
  103. package/edit/configmap.vue +1 -0
  104. package/edit/constraints.gatekeeper.sh.constraint/index.vue +1 -0
  105. package/edit/fleet.cattle.io.helmop.vue +11 -6
  106. package/edit/helm.cattle.io.projecthelmchart.vue +1 -0
  107. package/edit/k8s.cni.cncf.io.networkattachmentdefinition.vue +1 -0
  108. package/edit/logging-flow/index.vue +1 -0
  109. package/edit/logging.banzaicloud.io.output/index.vue +1 -0
  110. package/edit/management.cattle.io.fleetworkspace.vue +1 -1
  111. package/edit/management.cattle.io.project.vue +1 -0
  112. package/edit/monitoring.coreos.com.alertmanagerconfig/index.vue +4 -1
  113. package/edit/monitoring.coreos.com.alertmanagerconfig/receiverConfig.vue +2 -1
  114. package/edit/monitoring.coreos.com.prometheusrule/index.vue +1 -0
  115. package/edit/monitoring.coreos.com.receiver/index.vue +2 -1
  116. package/edit/monitoring.coreos.com.route.vue +1 -1
  117. package/edit/namespace.vue +1 -0
  118. package/edit/networking.istio.io.destinationrule/index.vue +1 -0
  119. package/edit/networking.k8s.io.ingress/index.vue +1 -0
  120. package/edit/networking.k8s.io.networkpolicy/PolicyRules.vue +1 -0
  121. package/edit/networking.k8s.io.networkpolicy/index.vue +1 -0
  122. package/edit/node.vue +1 -0
  123. package/edit/persistentvolume/index.vue +27 -22
  124. package/edit/persistentvolume/plugins/awsElasticBlockStore.vue +13 -14
  125. package/edit/persistentvolume/plugins/azureDisk.vue +49 -48
  126. package/edit/persistentvolume/plugins/azureFile.vue +15 -14
  127. package/edit/persistentvolume/plugins/cephfs.vue +15 -14
  128. package/edit/persistentvolume/plugins/cinder.vue +15 -14
  129. package/edit/persistentvolume/plugins/csi.vue +18 -16
  130. package/edit/persistentvolume/plugins/fc.vue +13 -14
  131. package/edit/persistentvolume/plugins/flexVolume.vue +15 -14
  132. package/edit/persistentvolume/plugins/flocker.vue +1 -3
  133. package/edit/persistentvolume/plugins/gcePersistentDisk.vue +13 -14
  134. package/edit/persistentvolume/plugins/glusterfs.vue +15 -14
  135. package/edit/persistentvolume/plugins/hostPath.vue +40 -39
  136. package/edit/persistentvolume/plugins/iscsi.vue +13 -14
  137. package/edit/persistentvolume/plugins/local.vue +1 -3
  138. package/edit/persistentvolume/plugins/longhorn.vue +23 -22
  139. package/edit/persistentvolume/plugins/nfs.vue +15 -14
  140. package/edit/persistentvolume/plugins/photonPersistentDisk.vue +1 -14
  141. package/edit/persistentvolume/plugins/portworxVolume.vue +15 -14
  142. package/edit/persistentvolume/plugins/quobyte.vue +15 -14
  143. package/edit/persistentvolume/plugins/rbd.vue +15 -14
  144. package/edit/persistentvolume/plugins/scaleIO.vue +15 -14
  145. package/edit/persistentvolume/plugins/storageos.vue +15 -14
  146. package/edit/persistentvolume/plugins/vsphereVolume.vue +1 -3
  147. package/edit/provisioning.cattle.io.cluster/__tests__/rke2.test.ts +21 -21
  148. package/edit/provisioning.cattle.io.cluster/index.vue +5 -5
  149. package/edit/provisioning.cattle.io.cluster/rke2.vue +9 -8
  150. package/edit/resources.cattle.io.restore.vue +1 -1
  151. package/edit/secret/index.vue +1 -1
  152. package/edit/service.vue +1 -0
  153. package/edit/serviceaccount.vue +1 -0
  154. package/edit/storage.k8s.io.storageclass/index.vue +1 -0
  155. package/edit/workload/Job.vue +2 -2
  156. package/edit/workload/index.vue +2 -1
  157. package/edit/workload/mixins/workload.js +1 -1
  158. package/initialize/App.vue +4 -4
  159. package/initialize/install-plugins.js +19 -5
  160. package/machine-config/azure.vue +1 -1
  161. package/machine-config/components/GCEImage.vue +1 -1
  162. package/mixins/__tests__/brand.spec.ts +2 -2
  163. package/mixins/brand.js +1 -7
  164. package/mixins/create-edit-view/index.js +5 -0
  165. package/models/__tests__/provisioning.cattle.io.cluster.test.ts +128 -5
  166. package/models/chart.js +70 -74
  167. package/models/management.cattle.io.cluster.js +21 -3
  168. package/models/provisioning.cattle.io.cluster.js +31 -11
  169. package/package.json +11 -10
  170. package/pages/auth/login.vue +4 -6
  171. package/pages/auth/setup.vue +1 -1
  172. package/pages/auth/verify.vue +3 -3
  173. package/pages/c/_cluster/apps/charts/__tests__/chart.test.ts +135 -0
  174. package/pages/c/_cluster/apps/charts/chart.vue +33 -15
  175. package/pages/c/_cluster/apps/charts/index.vue +122 -24
  176. package/pages/c/_cluster/apps/charts/install.vue +33 -0
  177. package/pages/c/_cluster/explorer/__tests__/index.test.ts +1 -1
  178. package/pages/c/_cluster/explorer/index.vue +8 -6
  179. package/pages/c/_cluster/fleet/index.vue +4 -7
  180. package/pages/c/_cluster/manager/hostedprovider/index.vue +12 -6
  181. package/pages/c/_cluster/settings/brand.vue +1 -1
  182. package/pages/c/_cluster/settings/index.vue +5 -0
  183. package/pages/c/_cluster/uiplugins/__tests__/index.test.ts +7 -0
  184. package/pages/c/_cluster/uiplugins/catalogs.vue +147 -0
  185. package/pages/c/_cluster/uiplugins/index.vue +126 -184
  186. package/pkg/auto-import.js +3 -3
  187. package/pkg/dynamic-importer.lib.js +1 -1
  188. package/pkg/import.js +1 -1
  189. package/plugins/__tests__/mutations.tests.ts +179 -0
  190. package/plugins/dashboard-client-init.js +3 -0
  191. package/plugins/dashboard-store/getters.js +19 -2
  192. package/plugins/dashboard-store/model-loader.js +1 -1
  193. package/plugins/dashboard-store/mutations.js +23 -2
  194. package/plugins/dashboard-store/resource-class.js +11 -5
  195. package/plugins/i18n.js +8 -0
  196. package/plugins/plugin.js +2 -2
  197. package/plugins/steve/__tests__/steve-pagination-utils.test.ts +506 -0
  198. package/plugins/steve/steve-class.js +1 -1
  199. package/plugins/steve/steve-pagination-utils.ts +131 -47
  200. package/rancher-components/Form/Checkbox/Checkbox.vue +1 -1
  201. package/rancher-components/Form/LabeledInput/LabeledInput.vue +1 -1
  202. package/rancher-components/Pill/RcStatusBadge/RcStatusBadge.vue +6 -42
  203. package/rancher-components/Pill/RcStatusBadge/index.ts +0 -1
  204. package/rancher-components/Pill/RcStatusBadge/types.ts +1 -1
  205. package/rancher-components/Pill/RcStatusIndicator/RcStatusIndicator.vue +5 -28
  206. package/rancher-components/Pill/RcStatusIndicator/types.ts +2 -1
  207. package/rancher-components/Pill/types.ts +0 -1
  208. package/rancher-components/RcDropdown/useDropdownContext.ts +2 -4
  209. package/rancher-components/RcIcon/RcIcon.test.ts +51 -0
  210. package/rancher-components/RcIcon/RcIcon.vue +46 -0
  211. package/rancher-components/RcIcon/index.ts +1 -0
  212. package/rancher-components/RcIcon/types.ts +160 -0
  213. package/rancher-components/RcItemCard/RcItemCard.vue +1 -1
  214. package/rancher-components/utils/status.test.ts +67 -0
  215. package/rancher-components/utils/status.ts +77 -0
  216. package/scripts/publish-shell.sh +25 -0
  217. package/scripts/typegen.sh +1 -0
  218. package/store/__tests__/catalog.test.ts +1 -1
  219. package/store/__tests__/type-map.test.ts +164 -2
  220. package/store/action-menu.js +8 -0
  221. package/store/auth.js +25 -13
  222. package/store/catalog.js +6 -0
  223. package/store/i18n.js +3 -3
  224. package/store/index.js +8 -6
  225. package/store/notifications.ts +2 -0
  226. package/store/prefs.js +6 -7
  227. package/store/type-map.js +17 -7
  228. package/store/wm.ts +4 -4
  229. package/types/internal-api/shell/modal.d.ts +6 -6
  230. package/types/notifications/index.ts +126 -15
  231. package/types/rancher/index.d.ts +9 -0
  232. package/types/shell/index.d.ts +54 -3
  233. package/types/store/__tests__/pagination.types.spec.ts +137 -0
  234. package/types/store/pagination.types.ts +157 -9
  235. package/types/vue-shim.d.ts +5 -4
  236. package/utils/__tests__/provider.test.ts +98 -0
  237. package/utils/__tests__/router.test.js +238 -0
  238. package/utils/__tests__/selector-typed.test.ts +263 -0
  239. package/utils/cluster.js +4 -1
  240. package/utils/color.js +1 -1
  241. package/utils/dynamic-content/__tests__/info.test.ts +6 -0
  242. package/utils/dynamic-content/info.ts +43 -0
  243. package/utils/favicon.js +4 -4
  244. package/utils/fleet.ts +8 -1
  245. package/utils/pagination-utils.ts +2 -2
  246. package/utils/pagination-wrapper.ts +1 -1
  247. package/utils/provider.ts +14 -0
  248. package/utils/router.js +50 -0
  249. package/utils/selector-typed.ts +6 -2
  250. package/utils/unit-tests/pagination-utils.spec.ts +8 -8
  251. package/vue.config.js +3 -3
  252. package/composables/useExtensionManager.ts +0 -17
  253. package/core/plugins.js +0 -38
  254. package/directives/clean-tooltip.js +0 -32
  255. package/plugins/internal-api/index.ts +0 -37
  256. package/plugins/internal-api/shared/base-api.ts +0 -13
  257. package/plugins/internal-api/shell/shell.api.ts +0 -108
  258. package/plugins/nuxt-client-init.js +0 -3
  259. package/types/internal-api/shell/growl.d.ts +0 -25
  260. package/types/internal-api/shell/slideIn.d.ts +0 -15
@@ -0,0 +1,298 @@
1
+ const mockCreateTooltip = jest.fn();
2
+ const mockDestroyTooltip = jest.fn();
3
+ const mockPurifyHTML = jest.fn((content) => (content || '').trim());
4
+
5
+ jest.mock('floating-vue', () => ({
6
+ createTooltip: mockCreateTooltip,
7
+ destroyTooltip: mockDestroyTooltip,
8
+ }));
9
+
10
+ jest.mock('@shell/plugins/clean-html', () => ({ purifyHTML: mockPurifyHTML }));
11
+
12
+ // A simple mock for the tooltip instance returned by createTooltip
13
+ const mockTooltipInstance = { show: jest.fn() };
14
+
15
+ describe('clean-tooltip.ts', () => {
16
+ let el: any;
17
+ let cleanTooltipDirective: any;
18
+ let onMouseEnter: (e: MouseEvent | FocusEvent) => void;
19
+ let onMouseLeave: (e: MouseEvent | FocusEvent) => void;
20
+ let onMouseClick: (e: MouseEvent) => void;
21
+
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+ jest.isolateModules(() => {
25
+ const module = require('../clean-tooltip');
26
+
27
+ cleanTooltipDirective = module.default;
28
+ onMouseEnter = module.onMouseEnter;
29
+ onMouseLeave = module.onMouseLeave;
30
+ onMouseClick = module.onMouseClick;
31
+ });
32
+
33
+ mockCreateTooltip.mockReturnValue(mockTooltipInstance);
34
+
35
+ el = document.createElement('div');
36
+ document.body.appendChild(el);
37
+ });
38
+
39
+ afterEach(() => {
40
+ if (document.body.contains(el)) {
41
+ document.body.removeChild(el);
42
+ }
43
+ });
44
+
45
+ describe('directive: cleanTooltipDirective', () => {
46
+ describe('mounted', () => {
47
+ it('should add event listeners and set initial properties for string value', () => {
48
+ const addEventListenerSpy = jest.spyOn(el, 'addEventListener');
49
+ const binding = {
50
+ value: 'Test Tooltip',
51
+ modifiers: {},
52
+ };
53
+
54
+ cleanTooltipDirective.mounted(el, binding);
55
+
56
+ expect(el.classList.contains('has-clean-tooltip')).toBe(true);
57
+ expect(el.__tooltipOptions__).toStrictEqual({ content: 'Test Tooltip' });
58
+
59
+ expect(addEventListenerSpy).toHaveBeenCalledWith('mouseenter', onMouseEnter);
60
+ expect(addEventListenerSpy).toHaveBeenCalledWith('mouseleave', onMouseLeave);
61
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('focus', onMouseEnter);
62
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('blur', onMouseLeave);
63
+ });
64
+
65
+ it('should parse object value and modifiers correctly', () => {
66
+ const binding = {
67
+ value: {
68
+ content: 'Object Tooltip',
69
+ placement: 'bottom',
70
+ popperClass: 'custom-class',
71
+ delay: { show: 500, hide: 200 },
72
+ },
73
+ modifiers: { right: true },
74
+ };
75
+
76
+ cleanTooltipDirective.mounted(el, binding);
77
+
78
+ expect(el.__tooltipOptions__).toStrictEqual({
79
+ content: 'Object Tooltip',
80
+ placement: 'right', // Modifier should override
81
+ popperClass: 'custom-class',
82
+ delay: { show: 500, hide: 200 },
83
+ });
84
+ });
85
+
86
+ it('should not add has-clean-tooltip class if content is empty', () => {
87
+ const binding = { value: '', modifiers: {} };
88
+
89
+ cleanTooltipDirective.mounted(el, binding);
90
+ expect(el.classList.contains('has-clean-tooltip')).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe('updated', () => {
95
+ it('should update the stored tooltip options', () => {
96
+ const initialBinding = { value: 'Initial', modifiers: {} };
97
+
98
+ cleanTooltipDirective.mounted(el, initialBinding);
99
+
100
+ const updatedBinding = { value: 'Updated', modifiers: { bottom: true } };
101
+
102
+ cleanTooltipDirective.updated(el, updatedBinding);
103
+
104
+ expect(el.__tooltipOptions__).toStrictEqual({ content: 'Updated', placement: 'bottom' });
105
+ });
106
+
107
+ it('should re-show the tooltip if it is currently active on the element', () => {
108
+ const binding = { value: { content: 'Test' }, modifiers: {} };
109
+
110
+ cleanTooltipDirective.mounted(el, binding);
111
+
112
+ const mouseEnterEvent = new MouseEvent('mouseenter');
113
+
114
+ Object.defineProperty(mouseEnterEvent, 'currentTarget', { value: el });
115
+ onMouseEnter(mouseEnterEvent);
116
+
117
+ expect(mockCreateTooltip).toHaveBeenCalledTimes(1);
118
+ expect(mockTooltipInstance.show).toHaveBeenCalledTimes(1);
119
+
120
+ const updatedBinding = { value: { content: 'Updated Content' }, modifiers: {} };
121
+
122
+ cleanTooltipDirective.updated(el, updatedBinding);
123
+
124
+ expect(mockDestroyTooltip).toHaveBeenCalledTimes(1);
125
+ expect(mockCreateTooltip).toHaveBeenCalledTimes(2);
126
+ expect(mockTooltipInstance.show).toHaveBeenCalledTimes(2);
127
+ expect(mockCreateTooltip).toHaveBeenCalledWith(el, expect.objectContaining({ content: 'Updated Content' }), {});
128
+ });
129
+ });
130
+
131
+ describe('unmounted', () => {
132
+ it('should remove event listeners and class', () => {
133
+ const removeEventListenerSpy = jest.spyOn(el, 'removeEventListener');
134
+ const binding = { value: 'Test', modifiers: {} };
135
+
136
+ cleanTooltipDirective.mounted(el, binding);
137
+ el.classList.add('has-clean-tooltip');
138
+
139
+ cleanTooltipDirective.unmounted(el, binding);
140
+
141
+ expect(el.classList.contains('has-clean-tooltip')).toBe(false);
142
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('mouseenter', onMouseEnter);
143
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('mouseleave', onMouseLeave);
144
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('focus', onMouseEnter);
145
+ expect(removeEventListenerSpy).toHaveBeenCalledWith('blur', onMouseLeave);
146
+ });
147
+
148
+ it('should hide the tooltip if it is active on the element', () => {
149
+ const binding = { value: { content: 'Test' }, modifiers: {} };
150
+
151
+ cleanTooltipDirective.mounted(el, binding);
152
+
153
+ const mouseEnterEvent = new MouseEvent('mouseenter');
154
+
155
+ Object.defineProperty(mouseEnterEvent, 'currentTarget', { value: el });
156
+ onMouseEnter(mouseEnterEvent);
157
+
158
+ expect(mockCreateTooltip).toHaveBeenCalledTimes(1);
159
+
160
+ cleanTooltipDirective.unmounted(el, binding);
161
+
162
+ expect(mockDestroyTooltip).toHaveBeenCalledTimes(1);
163
+ expect(mockDestroyTooltip).toHaveBeenCalledWith(el);
164
+ });
165
+ });
166
+ });
167
+
168
+ describe('event handlers', () => {
169
+ beforeEach(() => {
170
+ el.__tooltipOptions__ = {
171
+ content: 'Handler Test',
172
+ delay: { show: 1, hide: 1 },
173
+ };
174
+ });
175
+
176
+ it('onMouseEnter should show the tooltip', () => {
177
+ const event = new MouseEvent('mouseenter');
178
+
179
+ Object.defineProperty(event, 'currentTarget', { value: el });
180
+ onMouseEnter(event);
181
+
182
+ expect(mockCreateTooltip).toHaveBeenCalledTimes(1);
183
+ expect(mockCreateTooltip).toHaveBeenCalledWith(el, {
184
+ content: 'Handler Test',
185
+ delay: { show: 1, hide: 1 },
186
+ }, {});
187
+ expect(mockTooltipInstance.show).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it('onMouseLeave should hide the tooltip', () => {
191
+ const enterEvent = new MouseEvent('mouseenter');
192
+
193
+ Object.defineProperty(enterEvent, 'currentTarget', { value: el });
194
+ onMouseEnter(enterEvent);
195
+
196
+ const leaveEvent = new MouseEvent('mouseleave');
197
+
198
+ Object.defineProperty(leaveEvent, 'currentTarget', { value: el });
199
+ onMouseLeave(leaveEvent);
200
+
201
+ expect(mockDestroyTooltip).toHaveBeenCalledTimes(1);
202
+ expect(mockDestroyTooltip).toHaveBeenCalledWith(el);
203
+ });
204
+
205
+ it('onMouseClick should toggle the tooltip', () => {
206
+ const event = new MouseEvent('click');
207
+
208
+ el.__tooltipOptions__.triggers = ['click'];
209
+ Object.defineProperty(event, 'currentTarget', { value: el });
210
+
211
+ // First click shows tooltip
212
+ onMouseClick(event);
213
+ expect(mockCreateTooltip).toHaveBeenCalledTimes(1);
214
+ expect(mockTooltipInstance.show).toHaveBeenCalledTimes(1);
215
+
216
+ // To simulate it's open, we need to set the internal currentTarget.
217
+ // We can do this by calling onMouseEnter.
218
+ const enterEvent = new MouseEvent('mouseenter');
219
+
220
+ Object.defineProperty(enterEvent, 'currentTarget', { value: el });
221
+ onMouseEnter(enterEvent);
222
+
223
+ // onMouseEnter destroys the previous tooltip and creates a new one.
224
+ expect(mockDestroyTooltip).toHaveBeenCalledTimes(1);
225
+ expect(mockCreateTooltip).toHaveBeenCalledTimes(2);
226
+
227
+ // Now that the tooltip for `el` is considered active, a click should hide it.
228
+ onMouseClick(event);
229
+
230
+ expect(mockDestroyTooltip).toHaveBeenCalledTimes(2);
231
+ expect(mockDestroyTooltip).toHaveBeenLastCalledWith(el);
232
+ });
233
+ });
234
+
235
+ describe('content', () => {
236
+ it('should not show tooltip for empty content', () => {
237
+ const binding = { value: ' ', modifiers: {} };
238
+
239
+ cleanTooltipDirective.mounted(el, binding);
240
+
241
+ const enterEvent = new MouseEvent('mouseenter');
242
+
243
+ Object.defineProperty(enterEvent, 'currentTarget', { value: el });
244
+ onMouseEnter(enterEvent);
245
+
246
+ expect(mockCreateTooltip).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it('should purify string content', () => {
250
+ const binding = { value: '<h1>Hello</h1>', modifiers: {} };
251
+
252
+ cleanTooltipDirective.mounted(el, binding);
253
+
254
+ const enterEvent = new MouseEvent('mouseenter');
255
+
256
+ Object.defineProperty(enterEvent, 'currentTarget', { value: el });
257
+ onMouseEnter(enterEvent);
258
+
259
+ expect(mockPurifyHTML).toHaveBeenCalledWith('<h1>Hello</h1>');
260
+ expect(mockCreateTooltip).toHaveBeenCalledWith(el, expect.objectContaining({ content: '<h1>Hello</h1>' }), {});
261
+ });
262
+
263
+ it('should purify content within an object value', () => {
264
+ const binding = { value: { content: '<p>World</p>' }, modifiers: {} };
265
+
266
+ cleanTooltipDirective.mounted(el, binding);
267
+
268
+ const enterEvent = new MouseEvent('mouseenter');
269
+
270
+ Object.defineProperty(enterEvent, 'currentTarget', { value: el });
271
+ onMouseEnter(enterEvent);
272
+
273
+ expect(mockPurifyHTML).toHaveBeenCalledWith('<p>World</p>');
274
+ expect(mockCreateTooltip).toHaveBeenCalledWith(el, expect.objectContaining({ content: '<p>World</p>' }), {});
275
+ });
276
+ });
277
+
278
+ describe('triggers', () => {
279
+ it('should only add click listeners if triggers: [\'click\'] is provided', () => {
280
+ const addEventListenerSpy = jest.spyOn(el, 'addEventListener');
281
+ const binding = {
282
+ value: {
283
+ content: 'Click Tooltip',
284
+ triggers: ['click'],
285
+ },
286
+ modifiers: {},
287
+ };
288
+
289
+ cleanTooltipDirective.mounted(el, binding);
290
+
291
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('mouseenter', onMouseEnter);
292
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('mouseleave', onMouseLeave);
293
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('focus', onMouseEnter);
294
+ expect(addEventListenerSpy).not.toHaveBeenCalledWith('blur', onMouseLeave);
295
+ expect(addEventListenerSpy).toHaveBeenCalledWith('click', onMouseClick);
296
+ });
297
+ });
298
+ });
@@ -0,0 +1,234 @@
1
+ import { DirectiveBinding, Directive } from 'vue';
2
+ import { destroyTooltip, createTooltip } from 'floating-vue';
3
+ import { purifyHTML } from '@shell/plugins/clean-html';
4
+
5
+ // This is a singleton tooltip implementation that improves performance on pages with many tooltips.
6
+ // Instead of instantiating a Vue component for every tooltip on the page, this directive attaches lightweight event listeners.
7
+ // It then imperatively creates and destroys a single tooltip instance as needed, avoiding the high upfront memory and processing cost of many Vue components.
8
+ let singleton: ReturnType<typeof createTooltip> | null = null;
9
+ let currentTarget: HTMLElement | null = null;
10
+
11
+ interface TooltipDelay {
12
+ show: number;
13
+ hide: number;
14
+ }
15
+
16
+ // Options are optional, to be handled by floating-vue's defaults
17
+ interface TooltipOptions {
18
+ content?: string;
19
+ placement?: string;
20
+ popperClass?: string;
21
+ delay?: TooltipDelay;
22
+ triggers?: string[];
23
+ }
24
+
25
+ interface TooltipHTMLElement extends HTMLElement {
26
+ // Store the whole options object for the tooltip
27
+ __tooltipOptions__: TooltipOptions;
28
+ }
29
+
30
+ /**
31
+ * Shows a singleton tooltip for the given target element.
32
+ * If a tooltip is already active, it is hidden before showing the new one.
33
+ * @param {HTMLElement} target The element to which the tooltip is attached.
34
+ * @param {TooltipOptions} options The options for the tooltip.
35
+ */
36
+ function showSingletonTooltip(target: HTMLElement, options: TooltipOptions) {
37
+ // If a tooltip is already active, it should be hidden before showing the new one.
38
+ if (singleton) {
39
+ destroyTooltip(currentTarget);
40
+ singleton = null;
41
+ }
42
+
43
+ const purifiedContent = options.content ? purifyContent(options.content) : '';
44
+
45
+ // Don't show the tooltip if the content is empty.
46
+ if (!purifiedContent) {
47
+ return;
48
+ }
49
+
50
+ const tooltipConfig = {
51
+ ...options,
52
+ content: purifiedContent,
53
+ };
54
+
55
+ // Create a new tooltip instance.
56
+ singleton = createTooltip(target, tooltipConfig, {});
57
+
58
+ singleton.show();
59
+ currentTarget = target;
60
+ }
61
+
62
+ /**
63
+ * Hides the singleton tooltip if it is currently shown for the given target element.
64
+ * @param {HTMLElement} target The element from which the tooltip should be hidden.
65
+ */
66
+ function hideSingletonTooltip(target: HTMLElement) {
67
+ if (!singleton) {
68
+ return;
69
+ }
70
+
71
+ if (currentTarget === target) {
72
+ destroyTooltip(target);
73
+ singleton = null;
74
+ currentTarget = null;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Purifies and trims the HTML content of the tooltip to prevent XSS attacks.
80
+ * @param {string} rawValue The raw content string to be purified and trimmed.
81
+ * @returns {string} The purified and trimmed content string.
82
+ */
83
+ function purifyContent(rawValue: string): string {
84
+ const purified = purifyHTML(rawValue);
85
+
86
+ return purified.trim();
87
+ }
88
+
89
+ /**
90
+ * A Vue directive that provides a clean singleton tooltip using floating-vue.
91
+ */
92
+ const cleanTooltipDirective: Directive = {
93
+ /**
94
+ * Called when the directive is mounted to an element.
95
+ * It sets up the tooltip options and adds event listeners.
96
+ * @param {HTMLElement} el The element the directive is bound to.
97
+ * @param {object} binding The directive binding object.
98
+ */
99
+ mounted(el: TooltipHTMLElement, binding: DirectiveBinding) {
100
+ el.__tooltipOptions__ = getTooltipOptions(binding.value, binding.modifiers);
101
+
102
+ const triggers = el.__tooltipOptions__.triggers || ['hover'];
103
+
104
+ if (triggers.includes('hover')) {
105
+ el.addEventListener('mouseenter', onMouseEnter);
106
+ el.addEventListener('mouseleave', onMouseLeave);
107
+ }
108
+ if (triggers.includes('focus')) {
109
+ el.addEventListener('focus', onMouseEnter);
110
+ el.addEventListener('blur', onMouseLeave);
111
+ }
112
+ if (triggers.includes('click')) {
113
+ el.addEventListener('click', onMouseClick);
114
+ }
115
+
116
+ if (el.__tooltipOptions__.content) {
117
+ // Add a class to the element to indicate that it has a clean tooltip.
118
+ el.classList.add('has-clean-tooltip');
119
+ }
120
+ },
121
+ /**
122
+ * Called when the directive's binding value is updated.
123
+ * It updates the tooltip options and shows the tooltip if it is already active.
124
+ * @param {HTMLElement} el The element the directive is bound to.
125
+ * @param {object} binding The directive binding object.
126
+ */
127
+ updated(el: TooltipHTMLElement, binding: DirectiveBinding) {
128
+ el.__tooltipOptions__ = getTooltipOptions(binding.value, binding.modifiers);
129
+
130
+ // doing this here too because the tooltip content may change after mount.
131
+ if (el.__tooltipOptions__.content) {
132
+ el.classList.add('has-clean-tooltip');
133
+ } else {
134
+ el.classList.remove('has-clean-tooltip');
135
+ }
136
+
137
+ // If this element's tooltip is currently shown, update it
138
+ if (currentTarget === el) {
139
+ showSingletonTooltip(el, el.__tooltipOptions__);
140
+ }
141
+ },
142
+ /**
143
+ * Called when the directive is unmounted from an element.
144
+ * It removes the event listeners and hides the tooltip if it is active.
145
+ * @param {HTMLElement} el The element the directive is bound to.
146
+ */
147
+ unmounted(el: TooltipHTMLElement) {
148
+ el.removeEventListener('mouseenter', onMouseEnter);
149
+ el.removeEventListener('mouseleave', onMouseLeave);
150
+ el.removeEventListener('focus', onMouseEnter);
151
+ el.removeEventListener('blur', onMouseLeave);
152
+ el.removeEventListener('click', onMouseClick);
153
+ el.classList.remove('has-clean-tooltip');
154
+
155
+ // If this element's tooltip is currently shown, hide it
156
+ if (currentTarget === el) {
157
+ hideSingletonTooltip(el);
158
+ }
159
+ },
160
+ };
161
+
162
+ /**
163
+ * Event handler for mouseenter and focus events.
164
+ * @param {Event} e The event object.
165
+ */
166
+ function onMouseEnter(e: MouseEvent | FocusEvent) {
167
+ const el = e.currentTarget as TooltipHTMLElement;
168
+
169
+ showSingletonTooltip(el, el.__tooltipOptions__);
170
+ }
171
+
172
+ /**
173
+ * Event handler for mouseleave and blur events.
174
+ * @param {Event} e The event object.
175
+ */
176
+ function onMouseLeave(e: MouseEvent | FocusEvent) {
177
+ const el = e.currentTarget as TooltipHTMLElement;
178
+
179
+ hideSingletonTooltip(el);
180
+ }
181
+
182
+ /**
183
+ * Event handler for click events.
184
+ * @param {Event} e The event object.
185
+ */
186
+ function onMouseClick(e: MouseEvent) {
187
+ const el = e.currentTarget as TooltipHTMLElement;
188
+
189
+ if (currentTarget === el) {
190
+ hideSingletonTooltip(el);
191
+ } else {
192
+ showSingletonTooltip(el, el.__tooltipOptions__);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Parses the tooltip options from the directive's value and modifiers.
198
+ * @param {string|object} value The value of the directive.
199
+ * @param {object} modifiers The modifiers of the directive.
200
+ * @returns {object} The parsed tooltip options.
201
+ */
202
+ function getTooltipOptions(value: string | TooltipOptions, modifiers: Partial<Record<string, boolean>>): TooltipOptions {
203
+ let options: TooltipOptions;
204
+
205
+ if (typeof value === 'string') {
206
+ options = { content: value };
207
+ } else if (value && typeof value === 'object') {
208
+ options = { ...value };
209
+ } else {
210
+ options = {};
211
+ }
212
+
213
+ // Modifiers can also specify placement (e.g., v-clean-tooltip.bottom)
214
+ if (modifiers.top) {
215
+ options.placement = 'top';
216
+ } else if (modifiers.bottom) {
217
+ options.placement = 'bottom';
218
+ } else if (modifiers.left) {
219
+ options.placement = 'left';
220
+ } else if (modifiers.right) {
221
+ options.placement = 'right';
222
+ }
223
+
224
+ return options;
225
+ }
226
+
227
+ export default cleanTooltipDirective;
228
+
229
+ // Exporting for unit testing purposes
230
+ export {
231
+ onMouseEnter,
232
+ onMouseLeave,
233
+ onMouseClick
234
+ };
@@ -109,7 +109,7 @@ describe.each([
109
109
  const correctDriftCheckbox = wrapper.find('[data-testid="gitRepo-correctDrift-checkbox"]');
110
110
  const tooltip = wrapper.find('[data-testid="gitRepo-correctDrift-checkbox"]');
111
111
 
112
- expect(tooltip.element.classList).toContain('v-popper--has-tooltip');
112
+ expect(tooltip.element.classList).toContain('has-clean-tooltip');
113
113
  expect(correctDriftCheckbox.exists()).toBeTruthy();
114
114
  expect(correctDriftCheckbox.attributes().value).toBeFalsy();
115
115
  });
@@ -118,7 +118,7 @@ describe.each([
118
118
  const correctDriftCheckbox = wrapper.find('[data-testid="gitRepo-keepResources-checkbox"]');
119
119
  const tooltip = wrapper.find('[data-testid="gitRepo-keepResources-checkbox"]');
120
120
 
121
- expect(tooltip.element.classList).toContain('v-popper--has-tooltip');
121
+ expect(tooltip.element.classList).toContain('has-clean-tooltip');
122
122
  expect(correctDriftCheckbox.exists()).toBeTruthy();
123
123
  expect(correctDriftCheckbox.attributes().value).toBeFalsy();
124
124
  });
@@ -100,6 +100,49 @@ describe('view: fleet.cattle.io.helmop, mode: view', () => {
100
100
  });
101
101
  });
102
102
 
103
+ describe('helmOp component lifecycle', () => {
104
+ it('should have registerBeforeHook method available and call updateBeforeSave', () => {
105
+ const helmOpOptions = {
106
+ metadata: {
107
+ name: 'test-helmop',
108
+ namespace: 'test-namespace',
109
+ labels: {}
110
+ }
111
+ };
112
+
113
+ const wrapper = mount(HelmOpComponent, initHelmOp({ mode: _CREATE }, helmOpOptions));
114
+
115
+ // Mock registerBeforeHook to spy on calls
116
+ const mockRegisterBeforeHook = jest.fn();
117
+
118
+ wrapper.vm.registerBeforeHook = mockRegisterBeforeHook;
119
+
120
+ // Verify updateBeforeSave method exists
121
+ expect(typeof wrapper.vm.updateBeforeSave).toBe('function');
122
+
123
+ // Call the method that would be called during created lifecycle
124
+ wrapper.vm.registerBeforeHook(wrapper.vm.updateBeforeSave);
125
+
126
+ // Verify that registerBeforeHook was called with updateBeforeSave function
127
+ expect(mockRegisterBeforeHook).toHaveBeenCalledWith(wrapper.vm.updateBeforeSave);
128
+ });
129
+
130
+ it('should have doCreateSecrets method available for registerBeforeHook', () => {
131
+ const helmOpOptions = {
132
+ metadata: {
133
+ name: 'test-helmop',
134
+ namespace: 'test-namespace',
135
+ labels: {}
136
+ }
137
+ };
138
+
139
+ const wrapper = mount(HelmOpComponent, initHelmOp({ mode: _CREATE }, helmOpOptions));
140
+
141
+ // Verify doCreateSecrets method exists
142
+ expect(typeof wrapper.vm.doCreateSecrets).toBe('function');
143
+ });
144
+ });
145
+
103
146
  describe.each([
104
147
  _CREATE,
105
148
  _EDIT,
@@ -174,7 +217,7 @@ describe.each([
174
217
 
175
218
  expect(pollingCheckbox.exists()).toBe(true);
176
219
  expect(pollingCheckbox.vm.value).toBe(enabled);
177
- expect(pollingCheckbox.element.classList.value.includes('v-popper--has-tooltip')).toBe(!enabled);
220
+ expect(pollingCheckbox.element.classList.value.includes('has-clean-tooltip')).toBe(!enabled);
178
221
  expect(pollingIntervalInput.exists()).toBe(enabled);
179
222
  expect(pollingIntervalMinimumValueWarning.exists()).toBe(minValueWarnVisible);
180
223
  });
@@ -243,7 +286,7 @@ describe.each([
243
286
 
244
287
  await fleetSecretSelector.vm.$emit('update:value', ['secret2', 'secret3']);
245
288
 
246
- expect(wrapper.vm.value.spec.helm.downstreamResources).toStrictEqual([{ name: 'secret2', kind: 'Secret' }, { name: 'secret3', kind: 'Secret' }]);
289
+ expect(wrapper.vm.value.spec.downstreamResources).toStrictEqual([{ name: 'secret2', kind: 'Secret' }, { name: 'secret3', kind: 'Secret' }]);
247
290
  });
248
291
 
249
292
  it('should update downstreamResources with new ConfigMaps when FleetConfigMapSelector emits update event', async() => {
@@ -260,6 +303,60 @@ describe.each([
260
303
 
261
304
  await fleetConfigMapSelector.vm.$emit('update:value', ['configMap2', 'configMap3']);
262
305
 
263
- expect(wrapper.vm.value.spec.helm.downstreamResources).toStrictEqual([{ name: 'configMap2', kind: 'ConfigMap' }, { name: 'configMap3', kind: 'ConfigMap' }]);
306
+ expect(wrapper.vm.value.spec.downstreamResources).toStrictEqual([{ name: 'configMap2', kind: 'ConfigMap' }, { name: 'configMap3', kind: 'ConfigMap' }]);
264
307
  });
308
+
309
+ if (mode === _CREATE) {
310
+ it('should set created-by-user-id label when updateBeforeSave is called in CREATE mode', () => {
311
+ const mockCurrentUser = { id: 'user-123' };
312
+ const helmOpOptions = {
313
+ metadata: {
314
+ name: 'test-helmop',
315
+ namespace: 'test-namespace',
316
+ labels: {}
317
+ }
318
+ };
319
+ const wrapper = mount(HelmOpComponent, initHelmOp({ mode, realMode: mode }, helmOpOptions));
320
+
321
+ // Ensure metadata.labels exists
322
+ if (!wrapper.vm.value.metadata.labels) {
323
+ wrapper.vm.value.metadata.labels = {};
324
+ }
325
+
326
+ // Mock the currentUser
327
+ (wrapper.vm as any).currentUser = mockCurrentUser;
328
+
329
+ // Call updateBeforeSave method
330
+ wrapper.vm.updateBeforeSave();
331
+
332
+ // Should set the created-by-user-id label in CREATE mode
333
+ expect(wrapper.vm.value.metadata.labels['fleet.cattle.io/created-by-user-id']).toBe('user-123');
334
+ });
335
+ } else {
336
+ it('should not set created-by-user-id label when updateBeforeSave is called in EDIT mode', () => {
337
+ const mockCurrentUser = { id: 'user-123' };
338
+ const helmOpOptions = {
339
+ metadata: {
340
+ name: 'test-helmop',
341
+ namespace: 'test-namespace',
342
+ labels: {}
343
+ }
344
+ };
345
+ const wrapper = mount(HelmOpComponent, initHelmOp({ mode, realMode: mode }, helmOpOptions));
346
+
347
+ // Ensure metadata.labels exists
348
+ if (!wrapper.vm.value.metadata.labels) {
349
+ wrapper.vm.value.metadata.labels = {};
350
+ }
351
+
352
+ // Mock the currentUser
353
+ (wrapper.vm as any).currentUser = mockCurrentUser;
354
+
355
+ // Call updateBeforeSave method
356
+ wrapper.vm.updateBeforeSave();
357
+
358
+ // Should not set the label in EDIT mode
359
+ expect(wrapper.vm.value.metadata.labels['fleet.cattle.io/created-by-user-id']).toBeUndefined();
360
+ });
361
+ }
265
362
  });
@@ -209,6 +209,7 @@ export default {
209
209
  <Tabbed
210
210
  :side-tabs="true"
211
211
  :use-hash="useTabbedHash"
212
+ :default-tab="defaultTab"
212
213
  >
213
214
  <Tab
214
215
  name="target"
@@ -111,6 +111,7 @@ export default {
111
111
  <Tabbed
112
112
  :side-tabs="true"
113
113
  :use-hash="useTabbedHash"
114
+ :default-tab="defaultTab"
114
115
  >
115
116
  <Tab
116
117
  name="data"