@rancher/shell 3.0.8-rc.9 → 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 (146) 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/styles/global/_tooltip.scss +6 -1
  18. package/assets/translations/en-us.yaml +5 -0
  19. package/components/ActionMenuShell.vue +3 -1
  20. package/components/CruResource.vue +8 -1
  21. package/components/Drawer/ResourceDetailDrawer/__tests__/composables.test.ts +50 -1
  22. package/components/Drawer/ResourceDetailDrawer/composables.ts +19 -0
  23. package/components/Drawer/ResourceDetailDrawer/index.vue +3 -1
  24. package/components/LocaleSelector.vue +2 -2
  25. package/components/ModalManager.vue +11 -1
  26. package/components/Questions/__tests__/Yaml.test.ts +1 -1
  27. package/components/RelatedResources.vue +5 -0
  28. package/components/Resource/Detail/ResourcePopover/index.vue +5 -1
  29. package/components/ResourceDetail/Masthead/latest.vue +23 -21
  30. package/components/ResourceDetail/index.vue +3 -0
  31. package/components/ResourceTable.vue +54 -21
  32. package/components/SlideInPanelManager.vue +16 -11
  33. package/components/SortableTable/THead.vue +2 -1
  34. package/components/SortableTable/index.vue +20 -2
  35. package/components/Tabbed/index.vue +37 -2
  36. package/components/__tests__/NamespaceFilter.test.ts +49 -0
  37. package/components/auth/SelectPrincipal.vue +4 -0
  38. package/components/auth/login/ldap.vue +3 -3
  39. package/components/fleet/FleetSecretSelector.vue +1 -1
  40. package/components/form/KeyValue.vue +1 -1
  41. package/components/form/NameNsDescription.vue +1 -1
  42. package/components/form/NodeScheduling.vue +2 -2
  43. package/components/form/ResourceTabs/composable.ts +2 -2
  44. package/components/form/ResourceTabs/index.vue +0 -2
  45. package/components/form/__tests__/NameNsDescription.test.ts +42 -0
  46. package/components/formatter/LinkName.vue +5 -0
  47. package/components/nav/Group.vue +25 -7
  48. package/components/nav/Header.vue +1 -1
  49. package/components/nav/NamespaceFilter.vue +1 -0
  50. package/components/nav/Type.vue +17 -6
  51. package/components/nav/WindowManager/panels/TabBodyContainer.vue +1 -1
  52. package/components/nav/__tests__/Type.test.ts +59 -0
  53. package/composables/cruResource.ts +27 -0
  54. package/composables/focusTrap.ts +3 -1
  55. package/composables/resourceDetail.ts +15 -0
  56. package/composables/useLabeledFormElement.ts +3 -4
  57. package/config/product/fleet.js +1 -1
  58. package/config/router/navigation-guards/clusters.js +3 -3
  59. package/config/router/navigation-guards/products.js +1 -1
  60. package/config/router/routes.js +1 -5
  61. package/core/__tests__/extension-manager-impl.test.js +437 -0
  62. package/core/extension-manager-impl.js +6 -27
  63. package/core/plugin-helpers.ts +2 -2
  64. package/core/plugin.ts +9 -1
  65. package/core/plugins-loader.js +2 -2
  66. package/core/types-provisioning.ts +4 -0
  67. package/core/types.ts +35 -0
  68. package/detail/provisioning.cattle.io.cluster.vue +8 -6
  69. package/dialog/DeveloperLoadExtensionDialog.vue +1 -1
  70. package/dialog/MoveNamespaceDialog.vue +20 -4
  71. package/dialog/SearchDialog.vue +1 -0
  72. package/dialog/__tests__/MoveNamespaceDialog.test.ts +249 -0
  73. package/directives/__tests__/clean-tooltip.test.ts +298 -0
  74. package/directives/clean-tooltip.ts +234 -0
  75. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +2 -2
  76. package/edit/__tests__/fleet.cattle.io.helmop.test.ts +98 -1
  77. package/edit/fleet.cattle.io.helmop.vue +5 -0
  78. package/edit/provisioning.cattle.io.cluster/__tests__/rke2.test.ts +21 -21
  79. package/edit/provisioning.cattle.io.cluster/index.vue +5 -5
  80. package/edit/provisioning.cattle.io.cluster/rke2.vue +8 -8
  81. package/edit/resources.cattle.io.restore.vue +1 -1
  82. package/edit/workload/Job.vue +2 -2
  83. package/edit/workload/index.vue +1 -1
  84. package/initialize/install-plugins.js +4 -5
  85. package/machine-config/azure.vue +1 -1
  86. package/machine-config/components/GCEImage.vue +1 -1
  87. package/models/__tests__/provisioning.cattle.io.cluster.test.ts +16 -0
  88. package/models/chart.js +70 -74
  89. package/models/management.cattle.io.cluster.js +1 -1
  90. package/models/provisioning.cattle.io.cluster.js +11 -3
  91. package/package.json +7 -7
  92. package/pages/auth/login.vue +3 -3
  93. package/pages/auth/setup.vue +1 -1
  94. package/pages/auth/verify.vue +3 -3
  95. package/pages/c/_cluster/apps/charts/index.vue +122 -24
  96. package/pages/c/_cluster/apps/charts/install.vue +33 -0
  97. package/pages/c/_cluster/explorer/__tests__/index.test.ts +1 -1
  98. package/pages/c/_cluster/fleet/index.vue +4 -7
  99. package/pages/c/_cluster/settings/index.vue +5 -0
  100. package/pkg/auto-import.js +3 -3
  101. package/pkg/dynamic-importer.lib.js +1 -1
  102. package/pkg/import.js +1 -1
  103. package/plugins/__tests__/mutations.tests.ts +179 -0
  104. package/plugins/dashboard-store/getters.js +1 -1
  105. package/plugins/dashboard-store/model-loader.js +1 -1
  106. package/plugins/dashboard-store/mutations.js +23 -2
  107. package/plugins/dashboard-store/resource-class.js +8 -3
  108. package/plugins/plugin.js +2 -2
  109. package/plugins/steve/__tests__/steve-pagination-utils.test.ts +301 -128
  110. package/plugins/steve/steve-class.js +1 -1
  111. package/plugins/steve/steve-pagination-utils.ts +108 -43
  112. package/rancher-components/Form/Checkbox/Checkbox.vue +1 -1
  113. package/rancher-components/Form/LabeledInput/LabeledInput.vue +1 -1
  114. package/rancher-components/RcDropdown/useDropdownContext.ts +2 -4
  115. package/rancher-components/RcItemCard/RcItemCard.vue +1 -1
  116. package/scripts/publish-shell.sh +25 -0
  117. package/store/__tests__/catalog.test.ts +1 -1
  118. package/store/__tests__/type-map.test.ts +164 -2
  119. package/store/auth.js +23 -11
  120. package/store/i18n.js +3 -3
  121. package/store/index.js +5 -3
  122. package/store/notifications.ts +2 -0
  123. package/store/prefs.js +2 -2
  124. package/store/type-map.js +17 -7
  125. package/types/internal-api/shell/modal.d.ts +6 -6
  126. package/types/notifications/index.ts +126 -15
  127. package/types/rancher/index.d.ts +9 -0
  128. package/types/shell/index.d.ts +16 -1
  129. package/types/vue-shim.d.ts +5 -4
  130. package/utils/__tests__/router.test.js +238 -0
  131. package/utils/cluster.js +4 -1
  132. package/utils/fleet.ts +8 -1
  133. package/utils/pagination-utils.ts +2 -2
  134. package/utils/pagination-wrapper.ts +1 -1
  135. package/utils/router.js +50 -0
  136. package/utils/unit-tests/pagination-utils.spec.ts +8 -8
  137. package/vue.config.js +3 -3
  138. package/composables/useExtensionManager.ts +0 -17
  139. package/core/__test__/extension-manager-impl.test.js +0 -236
  140. package/core/plugins.js +0 -38
  141. package/directives/clean-tooltip.js +0 -32
  142. package/plugins/internal-api/index.ts +0 -37
  143. package/plugins/internal-api/shared/base-api.ts +0 -13
  144. package/plugins/internal-api/shell/shell.api.ts +0 -108
  145. package/types/internal-api/shell/growl.d.ts +0 -25
  146. package/types/internal-api/shell/slideIn.d.ts +0 -15
@@ -6,6 +6,8 @@ import LabeledSelect from '@shell/components/form/LabeledSelect';
6
6
  import { MANAGEMENT } from '@shell/config/types';
7
7
  import { PROJECT } from '@shell/config/labels-annotations';
8
8
 
9
+ const NONE_VALUE = ' ';
10
+
9
11
  export default {
10
12
  emits: ['close'],
11
13
 
@@ -48,8 +50,12 @@ export default {
48
50
  return this.toMove.filter((namespace) => !!namespace.project).map((namespace) => namespace.project.shortId);
49
51
  },
50
52
 
53
+ isAllInProject() {
54
+ return this.toMove.every((namespace) => !!namespace.project);
55
+ },
56
+
51
57
  projectOptions() {
52
- return this.projects.reduce((inCluster, project) => {
58
+ const options = this.projects.reduce((inCluster, project) => {
53
59
  if (!this.excludedProjects.includes(project.shortId) && project.spec?.clusterName === this.currentCluster.id) {
54
60
  inCluster.push({
55
61
  value: project.shortId,
@@ -59,6 +65,16 @@ export default {
59
65
 
60
66
  return inCluster;
61
67
  }, []);
68
+
69
+ // To be consistent with listed projects we should only provide the option if it applies too all of the namespaces
70
+ if (this.isAllInProject) {
71
+ options.unshift({
72
+ value: NONE_VALUE,
73
+ label: this.t('moveModal.noProject')
74
+ });
75
+ }
76
+
77
+ return options;
62
78
  }
63
79
  },
64
80
 
@@ -69,10 +85,10 @@ export default {
69
85
 
70
86
  async move(finish) {
71
87
  const cluster = this.$store.getters['currentCluster'];
72
- const clusterWithProjectId = `${ cluster.id }:${ this.targetProject }`;
88
+ const clusterWithProjectId = this.targetProject && this.targetProject !== NONE_VALUE ? `${ cluster.id }:${ this.targetProject }` : null;
73
89
 
74
90
  const promises = this.toMove.map((namespace) => {
75
- namespace.setLabel(PROJECT, this.targetProject);
91
+ namespace.setLabel(PROJECT, this.targetProject && this.targetProject !== NONE_VALUE ? this.targetProject : null);
76
92
  namespace.setAnnotation(PROJECT, clusterWithProjectId);
77
93
 
78
94
  return namespace.save();
@@ -128,7 +144,7 @@ export default {
128
144
  <AsyncButton
129
145
  :action-label="t('moveModal.moveButtonLabel')"
130
146
  class="btn bg-primary ml-10"
131
- :disabled="!targetProject"
147
+ :disabled="targetProject === null"
132
148
  @click="move"
133
149
  />
134
150
  </template>
@@ -106,6 +106,7 @@ export default {
106
106
  :group="g"
107
107
  :can-collapse="false"
108
108
  :fixed-open="true"
109
+ :highlight-route="false"
109
110
  @close="$emit('close')"
110
111
  >
111
112
  <template #accordion>
@@ -0,0 +1,249 @@
1
+ import { shallowMount, VueWrapper } from '@vue/test-utils';
2
+ import MoveNamespaceDialog from '@shell/dialog/MoveNamespaceDialog.vue';
3
+
4
+ const t = (key: string): string => key;
5
+ const NONE_VALUE = ' ';
6
+
7
+ describe('component: MoveNamespaceDialog', () => {
8
+ let wrapper: VueWrapper<any>;
9
+
10
+ const mockProjects = [
11
+ {
12
+ shortId: 'p-abc123',
13
+ nameDisplay: 'Project A',
14
+ spec: { clusterName: 'local' }
15
+ },
16
+ {
17
+ shortId: 'p-def456',
18
+ nameDisplay: 'Project B',
19
+ spec: { clusterName: 'local' }
20
+ },
21
+ {
22
+ shortId: 'p-other',
23
+ nameDisplay: 'Other Cluster Project',
24
+ spec: { clusterName: 'other-cluster' }
25
+ }
26
+ ];
27
+
28
+ const createMockNamespace = (projectId: string | null = null) => {
29
+ const namespace: any = {
30
+ nameDisplay: 'test-namespace',
31
+ projectId,
32
+ project: projectId ? { shortId: projectId } : null,
33
+ setLabel: jest.fn(),
34
+ setAnnotation: jest.fn(),
35
+ save: jest.fn().mockResolvedValue({}),
36
+ };
37
+
38
+ return namespace;
39
+ };
40
+
41
+ const mountComponent = (propsData = {}, options = {}) => {
42
+ const store = {
43
+ dispatch: jest.fn().mockResolvedValue(mockProjects),
44
+ getters: { currentCluster: { id: 'local' } }
45
+ };
46
+
47
+ const defaultProps = {
48
+ resources: [createMockNamespace('p-abc123')],
49
+ movingCb: jest.fn(),
50
+ registerBackgroundClosing: jest.fn(),
51
+ };
52
+
53
+ return shallowMount(MoveNamespaceDialog, {
54
+ propsData: {
55
+ ...defaultProps,
56
+ ...propsData,
57
+ },
58
+ global: {
59
+ mocks: {
60
+ $store: store,
61
+ t,
62
+ },
63
+ },
64
+ ...options,
65
+ });
66
+ };
67
+
68
+ afterEach(() => {
69
+ if (wrapper) {
70
+ wrapper.unmount();
71
+ }
72
+ });
73
+
74
+ describe('projectOptions', () => {
75
+ it('should include "None" option as first item', async() => {
76
+ wrapper = mountComponent();
77
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
78
+
79
+ const options = wrapper.vm.projectOptions;
80
+
81
+ expect(options[0]).toStrictEqual({
82
+ value: NONE_VALUE,
83
+ label: 'moveModal.noProject'
84
+ });
85
+ });
86
+
87
+ it('should include projects from current cluster', async() => {
88
+ // Use a namespace not in any project so no projects get excluded
89
+ const namespace = createMockNamespace(null);
90
+
91
+ wrapper = mountComponent({ resources: [namespace] });
92
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
93
+
94
+ const options = wrapper.vm.projectOptions;
95
+ const projectLabels = options.map((o: any) => o.label);
96
+
97
+ expect(projectLabels).toContain('Project A');
98
+ expect(projectLabels).toContain('Project B');
99
+ });
100
+
101
+ it('should exclude projects from other clusters', async() => {
102
+ wrapper = mountComponent();
103
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
104
+
105
+ const options = wrapper.vm.projectOptions;
106
+ const projectLabels = options.map((o: any) => o.label);
107
+
108
+ expect(projectLabels).not.toContain('Other Cluster Project');
109
+ });
110
+
111
+ it('should exclude current project of namespaces being moved', async() => {
112
+ const namespace = createMockNamespace('p-abc123');
113
+
114
+ wrapper = mountComponent({ resources: [namespace] });
115
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
116
+
117
+ const options = wrapper.vm.projectOptions;
118
+ const projectValues = options.map((o: any) => o.value);
119
+
120
+ expect(projectValues).not.toContain('p-abc123');
121
+ expect(projectValues).toContain('p-def456');
122
+ });
123
+
124
+ it('should NOT include "None" option when some namespaces are not in a project', async() => {
125
+ const namespaceInProject = createMockNamespace('p-abc123');
126
+ const namespaceNotInProject = createMockNamespace(null);
127
+
128
+ wrapper = mountComponent({ resources: [namespaceInProject, namespaceNotInProject] });
129
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
130
+
131
+ const options = wrapper.vm.projectOptions;
132
+ const optionValues = options.map((o: any) => o.value);
133
+
134
+ expect(optionValues).not.toContain(NONE_VALUE);
135
+ });
136
+
137
+ it('should NOT include "None" option when no namespaces are in a project', async() => {
138
+ const namespace = createMockNamespace(null);
139
+
140
+ wrapper = mountComponent({ resources: [namespace] });
141
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
142
+
143
+ const options = wrapper.vm.projectOptions;
144
+ const optionValues = options.map((o: any) => o.value);
145
+
146
+ expect(optionValues).not.toContain(NONE_VALUE);
147
+ });
148
+ });
149
+
150
+ describe('targetProject default value', () => {
151
+ it('should default to empty string (None option)', () => {
152
+ wrapper = mountComponent();
153
+
154
+ expect(wrapper.vm.targetProject).toBeNull();
155
+ });
156
+ });
157
+
158
+ describe('move button disabled state', () => {
159
+ it('should be enabled when targetProject is NONE_VALUE (None)', () => {
160
+ wrapper = mountComponent();
161
+ wrapper.vm.targetProject = NONE_VALUE;
162
+
163
+ // The button should be enabled when targetProject !== null
164
+ expect(wrapper.vm.targetProject === null).toBe(false);
165
+ });
166
+
167
+ it('should be enabled when targetProject is a project id', () => {
168
+ wrapper = mountComponent();
169
+ wrapper.vm.targetProject = 'p-def456';
170
+
171
+ expect(wrapper.vm.targetProject === null).toBe(false);
172
+ });
173
+ });
174
+
175
+ describe('move method', () => {
176
+ it('should clear labels and annotations when targetProject is NONE_VALUE (None)', async() => {
177
+ const namespace = createMockNamespace('p-abc123');
178
+
179
+ wrapper = mountComponent({ resources: [namespace] });
180
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
181
+
182
+ wrapper.vm.targetProject = NONE_VALUE;
183
+
184
+ const finish = jest.fn();
185
+
186
+ await wrapper.vm.move(finish);
187
+
188
+ expect(namespace.setLabel).toHaveBeenCalledWith('field.cattle.io/projectId', null);
189
+ expect(namespace.setAnnotation).toHaveBeenCalledWith('field.cattle.io/projectId', null);
190
+ expect(namespace.save).toHaveBeenCalledWith();
191
+ expect(finish).toHaveBeenCalledWith(true);
192
+ });
193
+
194
+ it('should set labels and annotations when moving to a project', async() => {
195
+ const namespace = createMockNamespace('p-abc123');
196
+
197
+ wrapper = mountComponent({ resources: [namespace] });
198
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
199
+
200
+ wrapper.vm.targetProject = 'p-def456';
201
+
202
+ const finish = jest.fn();
203
+
204
+ await wrapper.vm.move(finish);
205
+
206
+ expect(namespace.setLabel).toHaveBeenCalledWith('field.cattle.io/projectId', 'p-def456');
207
+ expect(namespace.setAnnotation).toHaveBeenCalledWith('field.cattle.io/projectId', 'local:p-def456');
208
+ expect(namespace.save).toHaveBeenCalledWith();
209
+ expect(finish).toHaveBeenCalledWith(true);
210
+ });
211
+
212
+ it('should handle multiple namespaces', async() => {
213
+ const namespace1 = createMockNamespace('p-abc123');
214
+ const namespace2 = createMockNamespace('p-abc123');
215
+
216
+ wrapper = mountComponent({ resources: [namespace1, namespace2] });
217
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
218
+
219
+ wrapper.vm.targetProject = NONE_VALUE;
220
+
221
+ const finish = jest.fn();
222
+
223
+ await wrapper.vm.move(finish);
224
+
225
+ expect(namespace1.setLabel).toHaveBeenCalledWith('field.cattle.io/projectId', null);
226
+ expect(namespace1.setAnnotation).toHaveBeenCalledWith('field.cattle.io/projectId', null);
227
+ expect(namespace2.setLabel).toHaveBeenCalledWith('field.cattle.io/projectId', null);
228
+ expect(namespace2.setAnnotation).toHaveBeenCalledWith('field.cattle.io/projectId', null);
229
+ expect(namespace1.save).toHaveBeenCalledWith();
230
+ expect(namespace2.save).toHaveBeenCalledWith();
231
+ });
232
+
233
+ it('should call finish with false when save fails', async() => {
234
+ const namespace = createMockNamespace('p-abc123');
235
+
236
+ jest.spyOn(namespace, 'save').mockImplementation().mockRejectedValue(new Error('Save failed'));
237
+ wrapper = mountComponent({ resources: [namespace] });
238
+ await wrapper.vm.$options.fetch.call(wrapper.vm);
239
+
240
+ wrapper.vm.targetProject = NONE_VALUE;
241
+
242
+ const finish = jest.fn();
243
+
244
+ await wrapper.vm.move(finish);
245
+
246
+ expect(finish).toHaveBeenCalledWith(false);
247
+ });
248
+ });
249
+ });
@@ -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
+ });