@rancher/shell 3.0.5-rc.8 → 3.0.5

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 (199) hide show
  1. package/assets/styles/base/_color.scss +4 -1
  2. package/assets/styles/global/_tooltip.scss +7 -4
  3. package/assets/styles/themes/_dark.scss +11 -0
  4. package/assets/styles/themes/_light.scss +13 -1
  5. package/assets/styles/themes/_modern.scss +22 -0
  6. package/assets/translations/en-us.yaml +147 -19
  7. package/assets/translations/zh-hans.yaml +0 -1
  8. package/chart/monitoring/grafana/index.vue +8 -2
  9. package/components/ActionMenuShell.vue +3 -1
  10. package/components/Cron/CronExpressionEditor.vue +299 -0
  11. package/components/Cron/CronExpressionEditorModal.vue +247 -0
  12. package/components/Cron/CronTooltip.vue +87 -0
  13. package/components/Cron/types.ts +13 -0
  14. package/components/ForceDirectedTreeChart/composable.ts +11 -0
  15. package/components/PodSecurityAdmission.vue +2 -0
  16. package/components/PromptModal.vue +1 -1
  17. package/components/Resource/Detail/Card/__tests__/StateCard.test.ts +1 -0
  18. package/components/Resource/Detail/CopyToClipboard.vue +78 -0
  19. package/components/Resource/Detail/FetchLoader/__tests__/composables.test.ts +69 -0
  20. package/components/Resource/Detail/FetchLoader/composables.ts +27 -0
  21. package/components/Resource/Detail/Metadata/Annotations/__tests__/index.test.ts +1 -1
  22. package/components/Resource/Detail/Metadata/Annotations/index.vue +1 -1
  23. package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/identifying-fields.test.ts +13 -61
  24. package/components/Resource/Detail/Metadata/IdentifyingInformation/__tests__/index.test.ts +33 -6
  25. package/components/Resource/Detail/Metadata/IdentifyingInformation/identifying-fields.ts +24 -38
  26. package/components/Resource/Detail/Metadata/IdentifyingInformation/index.vue +25 -5
  27. package/components/Resource/Detail/Metadata/KeyValue.vue +12 -23
  28. package/components/Resource/Detail/Metadata/KeyValueRow.vue +144 -0
  29. package/components/Resource/Detail/Metadata/Labels/__tests__/index.test.ts +1 -0
  30. package/components/Resource/Detail/Metadata/Labels/index.vue +1 -0
  31. package/components/Resource/Detail/Metadata/__tests__/KeyValue.test.ts +30 -32
  32. package/components/Resource/Detail/Metadata/__tests__/KeyValueRow.test.ts +108 -0
  33. package/components/Resource/Detail/Metadata/__tests__/composables.test.ts +0 -3
  34. package/components/Resource/Detail/Metadata/__tests__/index.test.ts +12 -5
  35. package/components/Resource/Detail/Metadata/composables.ts +1 -4
  36. package/components/Resource/Detail/Metadata/index.vue +1 -0
  37. package/components/Resource/Detail/Preview/Content.vue +63 -0
  38. package/components/Resource/Detail/Preview/Preview.vue +128 -0
  39. package/components/Resource/Detail/Preview/__tests__/Content.spec.ts +71 -0
  40. package/components/Resource/Detail/Preview/__tests__/Preview.spec.ts +121 -0
  41. package/components/Resource/Detail/ResourcePopover/ResourcePopoverCard.vue +141 -0
  42. package/components/Resource/Detail/ResourcePopover/__tests__/ResourcePopoverCard.test.ts +136 -0
  43. package/components/Resource/Detail/ResourcePopover/__tests__/index.test.ts +245 -0
  44. package/components/Resource/Detail/ResourcePopover/index.vue +226 -0
  45. package/components/Resource/Detail/SpacedRow.vue +1 -0
  46. package/components/Resource/Detail/TitleBar/__tests__/composables.test.ts +0 -5
  47. package/components/Resource/Detail/TitleBar/__tests__/index.test.ts +1 -1
  48. package/components/Resource/Detail/TitleBar/composables.ts +1 -3
  49. package/components/Resource/Detail/TitleBar/index.vue +2 -29
  50. package/components/Resource/Detail/ViewOptions/composable.ts +9 -0
  51. package/components/Resource/Detail/ViewOptions/index.vue +41 -0
  52. package/components/Resource/Detail/__tests__/CopyToClipboard.spec.ts +82 -0
  53. package/components/ResourceDetail/Masthead/legacy.vue +0 -19
  54. package/components/ResourceDetail/index.vue +1 -26
  55. package/components/ResourceTable.vue +24 -0
  56. package/components/SortableTable/index.vue +7 -1
  57. package/components/SortableTable/paging.js +3 -0
  58. package/components/Tabbed/Tab.vue +43 -1
  59. package/components/Tabbed/index.vue +3 -1
  60. package/components/__tests__/Cron/CronExpressionEditor.test.ts +151 -0
  61. package/components/__tests__/Cron/CronExpressionEditorModal.test.ts +81 -0
  62. package/components/auth/login/saml.vue +86 -0
  63. package/components/form/LabeledSelect.vue +8 -8
  64. package/components/form/ProjectMemberEditor.vue +2 -0
  65. package/components/form/ResourceTabs/composable.ts +54 -0
  66. package/components/form/ResourceTabs/index.vue +10 -7
  67. package/components/form/Select.vue +13 -10
  68. package/components/form/__tests__/LabeledSelect.test.ts +133 -0
  69. package/components/form/__tests__/Select.test.ts +134 -0
  70. package/components/nav/Header.vue +6 -5
  71. package/composables/useExtensionManager.ts +17 -0
  72. package/config/home-links.js +12 -0
  73. package/config/labels-annotations.js +0 -1
  74. package/config/page-actions.js +0 -1
  75. package/config/product/explorer.js +3 -1
  76. package/config/product/fleet.js +2 -7
  77. package/config/product/manager.js +0 -5
  78. package/config/query-params.js +1 -0
  79. package/config/router/navigation-guards/clusters.js +2 -1
  80. package/config/router/navigation-guards/products.js +1 -1
  81. package/config/store.js +2 -0
  82. package/core/extension-manager-impl.js +518 -0
  83. package/core/plugins.js +35 -468
  84. package/core/types.ts +8 -2
  85. package/detail/__tests__/autoscaling.horizontalpodautoscaler.test.ts +1 -0
  86. package/detail/catalog.cattle.io.app.vue +7 -4
  87. package/detail/fleet.cattle.io.bundle.vue +1 -5
  88. package/detail/fleet.cattle.io.cluster.vue +3 -2
  89. package/detail/fleet.cattle.io.gitrepo.vue +76 -49
  90. package/detail/fleet.cattle.io.helmop.vue +78 -49
  91. package/dialog/AddonConfigConfirmationDialog.vue +1 -1
  92. package/dialog/GenericPrompt.vue +1 -1
  93. package/dialog/ImportDialog.vue +9 -2
  94. package/dialog/InstallExtensionDialog.vue +18 -10
  95. package/dialog/SloDialog.vue +1 -1
  96. package/edit/__tests__/fleet.cattle.io.gitrepo.test.ts +2 -1
  97. package/edit/__tests__/resources.cattle.io.restore.test.ts +106 -0
  98. package/edit/auth/oidc.vue +106 -6
  99. package/edit/auth/saml.vue +5 -5
  100. package/edit/cloudcredential.vue +31 -17
  101. package/edit/constraints.gatekeeper.sh.constraint/index.vue +10 -2
  102. package/edit/fleet.cattle.io.cluster.vue +19 -0
  103. package/edit/fleet.cattle.io.gitrepo.vue +23 -16
  104. package/edit/monitoring.coreos.com.alertmanagerconfig/index.vue +12 -11
  105. package/edit/monitoring.coreos.com.alertmanagerconfig/receiverConfig.vue +11 -1
  106. package/edit/provisioning.cattle.io.cluster/index.vue +14 -19
  107. package/edit/provisioning.cattle.io.cluster/rke2.vue +11 -3
  108. package/edit/provisioning.cattle.io.cluster/tabs/AddOnAdditionalManifest.vue +1 -0
  109. package/edit/provisioning.cattle.io.cluster/tabs/AddOnConfig.vue +1 -0
  110. package/edit/provisioning.cattle.io.cluster/tabs/Basics.vue +1 -0
  111. package/edit/provisioning.cattle.io.cluster/tabs/etcd/S3Config.vue +1 -0
  112. package/edit/provisioning.cattle.io.cluster/tabs/registries/index.vue +2 -0
  113. package/edit/provisioning.cattle.io.cluster/tabs/upgrade/DrainOptions.vue +6 -0
  114. package/edit/resources.cattle.io.restore.vue +5 -8
  115. package/initialize/install-plugins.js +1 -3
  116. package/list/__tests__/workload.test.ts +1 -0
  117. package/list/workload.vue +8 -1
  118. package/machine-config/components/GCEImage.vue +6 -5
  119. package/machine-config/google.vue +11 -6
  120. package/mixins/__tests__/auth-config.test.ts +4 -6
  121. package/mixins/__tests__/chart.test.ts +139 -1
  122. package/mixins/auth-config.js +33 -10
  123. package/mixins/chart.js +58 -18
  124. package/models/__tests__/namespace.test.ts +69 -0
  125. package/models/apps.statefulset.js +8 -10
  126. package/models/chart.js +5 -1
  127. package/models/fleet-application.js +16 -46
  128. package/models/fleet.cattle.io.bundle.js +1 -38
  129. package/models/fleet.cattle.io.gitrepo.js +4 -0
  130. package/models/fleet.cattle.io.helmop.js +4 -0
  131. package/models/management.cattle.io.cluster.js +1 -1
  132. package/models/management.cattle.io.project.js +12 -0
  133. package/models/namespace.js +30 -0
  134. package/models/workload.js +4 -1
  135. package/package.json +10 -10
  136. package/pages/auth/login.vue +8 -3
  137. package/pages/auth/logout.vue +6 -5
  138. package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +26 -11
  139. package/pages/c/_cluster/apps/charts/chart.vue +29 -20
  140. package/pages/c/_cluster/apps/charts/index.vue +1 -0
  141. package/pages/c/_cluster/apps/charts/install.vue +6 -5
  142. package/pages/c/_cluster/explorer/tools/__tests__/index.test.ts +102 -12
  143. package/pages/c/_cluster/explorer/tools/index.vue +145 -254
  144. package/pages/c/_cluster/manager/cloudCredential/index.vue +18 -1
  145. package/pages/c/_cluster/manager/drivers/kontainerDriver/index.vue +12 -2
  146. package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +1 -1
  147. package/pages/c/_cluster/uiplugins/__tests__/index.spec.ts +318 -0
  148. package/pages/c/_cluster/uiplugins/index.vue +221 -363
  149. package/pages/home.vue +1 -9
  150. package/plugins/axios.js +3 -2
  151. package/plugins/dashboard-store/resource-class.js +49 -0
  152. package/plugins/ember-cookie.js +7 -3
  153. package/plugins/steve/subscribe.js +4 -2
  154. package/public/index.html +2 -1
  155. package/rancher-components/Card/Card.vue +1 -1
  156. package/rancher-components/Form/Checkbox/Checkbox.vue +1 -1
  157. package/rancher-components/Form/Radio/RadioButton.vue +1 -1
  158. package/rancher-components/Form/Radio/RadioGroup.vue +1 -1
  159. package/rancher-components/LabeledTooltip/LabeledTooltip.vue +1 -11
  160. package/rancher-components/Pill/RcCounterBadge/RcCounterBadge.test.ts +53 -0
  161. package/rancher-components/Pill/RcCounterBadge/RcCounterBadge.vue +65 -0
  162. package/rancher-components/Pill/RcCounterBadge/index.ts +1 -0
  163. package/rancher-components/Pill/RcCounterBadge/types.ts +7 -0
  164. package/rancher-components/Pill/RcStatusBadge/RcStatusBadge.vue +1 -1
  165. package/rancher-components/Pill/RcStatusBadge/index.ts +1 -1
  166. package/rancher-components/Pill/RcStatusIndicator/RcStatusIndicator.vue +3 -3
  167. package/rancher-components/Pill/RcStatusIndicator/types.ts +1 -1
  168. package/rancher-components/Pill/RcTag/RcTag.test.ts +64 -0
  169. package/rancher-components/Pill/RcTag/RcTag.vue +94 -0
  170. package/rancher-components/Pill/RcTag/index.ts +1 -0
  171. package/rancher-components/Pill/RcTag/types.ts +9 -0
  172. package/rancher-components/Pill/types.ts +1 -0
  173. package/rancher-components/RcItemCard/RcItemCard.vue +1 -0
  174. package/rancher-components/RcItemCard/RcItemCardAction.vue +12 -0
  175. package/scripts/test-plugins-build.sh +0 -1
  176. package/store/__tests__/catalog.test.ts +63 -0
  177. package/store/__tests__/cookies.test.ts +72 -0
  178. package/store/auth.js +33 -10
  179. package/store/catalog.js +2 -2
  180. package/store/cookies.ts +30 -0
  181. package/store/prefs.js +10 -5
  182. package/store/type-map.js +3 -15
  183. package/types/extension-manager.ts +26 -0
  184. package/types/shell/index.d.ts +123 -27
  185. package/utils/__tests__/product.test.ts +129 -0
  186. package/utils/__tests__/resource.test.ts +87 -0
  187. package/utils/alertmanagerconfig.js +2 -2
  188. package/utils/auth.js +4 -77
  189. package/utils/product.ts +39 -0
  190. package/utils/resource.ts +35 -0
  191. package/utils/select.js +0 -24
  192. package/utils/validators/formRules/__tests__/index.test.ts +3 -0
  193. package/utils/validators/formRules/index.ts +2 -1
  194. package/vue.config.js +1 -1
  195. package/components/Resource/Detail/Metadata/Rectangle.vue +0 -34
  196. package/components/Resource/Detail/Metadata/__tests__/Rectangle.test.ts +0 -24
  197. package/components/ResourceDetail/Masthead/__tests__/legacy.test.ts +0 -65
  198. package/utils/cookie-universal.js +0 -10
  199. /package/components/{ForceDirectedTreeChart.vue → ForceDirectedTreeChart/index.vue} +0 -0
@@ -0,0 +1,82 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import CopyToClipboard from '@shell/components/Resource/Detail/CopyToClipboard.vue';
3
+ import { copyTextToClipboard } from '@shell/utils/clipboard';
4
+
5
+ // Mock the clipboard utility
6
+ jest.mock('@shell/utils/clipboard', () => ({ copyTextToClipboard: jest.fn() }));
7
+ jest.mock('vuex', () => ({ useStore: () => { } }));
8
+
9
+ describe('component: CopyToClipboard', () => {
10
+ beforeAll(() => {
11
+ jest.useFakeTimers();
12
+ });
13
+
14
+ afterAll(() => {
15
+ jest.useRealTimers();
16
+ });
17
+
18
+ afterEach(() => {
19
+ jest.clearAllMocks();
20
+ jest.clearAllTimers();
21
+ });
22
+
23
+ it('should render a button', () => {
24
+ const wrapper = mount(CopyToClipboard, { props: { value: 'test-value' } });
25
+
26
+ const button = wrapper.find('button');
27
+
28
+ expect(button.exists()).toBe(true);
29
+ expect(button.classes()).toContain('copy-to-clipboard');
30
+ });
31
+
32
+ it('should call copyTextToClipboard with the correct value on click', async() => {
33
+ const testValue = 'my-secret-text';
34
+ const wrapper = mount(CopyToClipboard, { props: { value: testValue } });
35
+
36
+ await wrapper.find('button').trigger('click');
37
+
38
+ expect(copyTextToClipboard).toHaveBeenCalledTimes(1);
39
+ expect(copyTextToClipboard).toHaveBeenCalledWith(testValue);
40
+ });
41
+
42
+ it('should add the "copied" class on click and remove it after a timeout', async() => {
43
+ const wrapper = mount(CopyToClipboard, { props: { value: 'test-value' } });
44
+
45
+ const button = wrapper.find('button');
46
+
47
+ await button.trigger('click');
48
+
49
+ expect(button.classes()).toContain('copied');
50
+
51
+ // Advance timers by 2 seconds
52
+ jest.advanceTimersByTime(2000);
53
+ await wrapper.vm.$nextTick();
54
+ await wrapper.vm.$nextTick();
55
+
56
+ expect(wrapper.find('button').classes()).not.toContain('copied');
57
+ });
58
+
59
+ it('should not reset the timeout if clicked multiple times', async() => {
60
+ const wrapper = mount(CopyToClipboard, { props: { value: 'test-value' } });
61
+
62
+ const button = wrapper.find('button');
63
+
64
+ // First click
65
+ await button.trigger('click');
66
+ expect(wrapper.find('button').classes()).toContain('copied');
67
+
68
+ // Advance time by 1 second
69
+ jest.advanceTimersByTime(1000);
70
+
71
+ // Second click
72
+ await button.trigger('click');
73
+ expect(wrapper.find('button').classes()).toContain('copied');
74
+
75
+ // The timeout should not have been reset. After another 1 second, the original timeout should fire.
76
+ jest.advanceTimersByTime(1000);
77
+ await wrapper.vm.$nextTick();
78
+ await wrapper.vm.$nextTick();
79
+
80
+ expect(wrapper.find('button').classes()).not.toContain('copied');
81
+ });
82
+ });
@@ -526,25 +526,6 @@ export default {
526
526
  :value="value.creationTimestamp"
527
527
  />
528
528
  </span>
529
- <span
530
- v-if="value.showCreatedBy"
531
- data-testid="masthead-subheader-createdBy"
532
- >
533
- {{ t("resourceDetail.masthead.createdBy") }}:
534
- <router-link
535
- v-if="value.createdBy.location"
536
- :to="value.createdBy.location"
537
- data-testid="masthead-subheader-createdBy-link"
538
- >
539
- {{ value.createdBy.displayName }}
540
- </router-link>
541
- <span
542
- v-else
543
- data-testid="masthead-subheader-createdBy_plain-text"
544
- >
545
- {{ value.createdBy.displayName }}
546
- </span>
547
- </span>
548
529
  </div>
549
530
  </div>
550
531
  <slot name="right">
@@ -4,7 +4,7 @@ import Loading from '@shell/components/Loading';
4
4
  import ResourceYaml from '@shell/components/ResourceYaml';
5
5
  import {
6
6
  _VIEW, _EDIT, _CLONE, _IMPORT, _STAGE, _CREATE,
7
- AS, _YAML, _DETAIL, _CONFIG, _GRAPH, PREVIEW, MODE,
7
+ AS, _YAML, _DETAIL, _CONFIG, PREVIEW, MODE,
8
8
  } from '@shell/config/query-params';
9
9
  import { SCHEMA } from '@shell/config/types';
10
10
  import { createYaml } from '@shell/utils/create-yaml';
@@ -12,7 +12,6 @@ import Masthead from '@shell/components/ResourceDetail/Masthead';
12
12
  import DetailTop from '@shell/components/DetailTop';
13
13
  import { clone, diff } from '@shell/utils/object';
14
14
  import IconMessage from '@shell/components/IconMessage';
15
- import ForceDirectedTreeChart from '@shell/components/ForceDirectedTreeChart';
16
15
  import { stringify } from '@shell/utils/error';
17
16
  import { Banner } from '@components/Banner';
18
17
 
@@ -45,7 +44,6 @@ export default {
45
44
  components: {
46
45
  Loading,
47
46
  DetailTop,
48
- ForceDirectedTreeChart,
49
47
  ResourceYaml,
50
48
  Masthead,
51
49
  IconMessage,
@@ -106,8 +104,6 @@ export default {
106
104
  // know about: view, edit, create (stage, import and clone become "create")
107
105
  const mode = ([_CLONE, _IMPORT, _STAGE].includes(realMode) ? _CREATE : realMode);
108
106
 
109
- const getGraphConfig = store.getters['type-map/hasGraph'](resourceType);
110
- const hasGraph = !!getGraphConfig;
111
107
  const hasCustomDetail = store.getters['type-map/hasCustomDetail'](resourceType, id);
112
108
  const hasCustomEdit = store.getters['type-map/hasCustomEdit'](resourceType, id);
113
109
 
@@ -120,8 +116,6 @@ export default {
120
116
 
121
117
  if ( mode === _VIEW && hasCustomDetail && (!requested || requested === _DETAIL) ) {
122
118
  as = _DETAIL;
123
- } else if ( mode === _VIEW && hasGraph && requested === _GRAPH) {
124
- as = _GRAPH;
125
119
  } else if ( hasCustomEdit && (!requested || requested === _CONFIG) ) {
126
120
  as = _CONFIG;
127
121
  } else {
@@ -213,10 +207,6 @@ export default {
213
207
  }
214
208
  }
215
209
 
216
- if ( as === _GRAPH ) {
217
- this.chartData = liveModel;
218
- }
219
-
220
210
  if ( [_CLONE, _IMPORT, _STAGE].includes(realMode) ) {
221
211
  model.cleanForNew();
222
212
  yaml = model.cleanYaml(yaml, realMode);
@@ -231,8 +221,6 @@ export default {
231
221
  }
232
222
 
233
223
  const out = {
234
- hasGraph,
235
- getGraphConfig,
236
224
  hasCustomDetail,
237
225
  hasCustomEdit,
238
226
  canViewYaml,
@@ -256,11 +244,9 @@ export default {
256
244
  },
257
245
  data() {
258
246
  return {
259
- chartData: null,
260
247
  resourceSubtype: null,
261
248
 
262
249
  // Set by fetch
263
- hasGraph: null,
264
250
  hasCustomDetail: null,
265
251
  hasCustomEdit: null,
266
252
  resourceType: null,
@@ -298,10 +284,6 @@ export default {
298
284
  return this.as === _DETAIL;
299
285
  },
300
286
 
301
- isGraph() {
302
- return this.as === _GRAPH;
303
- },
304
-
305
287
  offerPreview() {
306
288
  return this.as === _YAML && [_EDIT, _CLONE, _IMPORT, _STAGE].includes(this.mode);
307
289
  },
@@ -468,7 +450,6 @@ export default {
468
450
  :mode="mode"
469
451
  :real-mode="realMode"
470
452
  :as="as"
471
- :has-graph="hasGraph"
472
453
  :has-detail="hasCustomDetail"
473
454
  :has-edit="hasCustomEdit"
474
455
  :can-view-yaml="canViewYaml"
@@ -498,12 +479,6 @@ export default {
498
479
  />
499
480
  </div>
500
481
 
501
- <ForceDirectedTreeChart
502
- v-if="isGraph"
503
- :data="chartData"
504
- :fdc-config="getGraphConfig"
505
- />
506
-
507
482
  <ResourceYaml
508
483
  v-else-if="isYaml"
509
484
  ref="resourceyaml"
@@ -304,6 +304,7 @@ export default {
304
304
  },
305
305
 
306
306
  _headers() {
307
+ // :TableColumn[]
307
308
  let headers;
308
309
  const showNamespace = this.showNamespaceColumn;
309
310
 
@@ -339,6 +340,29 @@ export default {
339
340
 
340
341
  // adding extension defined cols to the correct header config
341
342
  extensionCols.forEach((col) => {
343
+ if (this.externalPaginationEnabled) {
344
+ // validate that the required settings are supplied to enable search and sort server-side
345
+ // these do not check other invalid scenarios like a path is a string but to a model property, or that the field supports sort/search via api (some basic non-breaking checks are done further on)
346
+ if (
347
+ col.search !== false && // search is explicitly disabled
348
+ (typeof col.search !== 'string' && !Array.isArray(col.search)) && // primary property path to search on
349
+ typeof col.value !== 'string' // secondary property path to search on
350
+ ) {
351
+ console.warn(`Unable to support server-side search for extension provided column "${ col.name || col.label || col.labelKey }" (column must provide \`search\` or \`value\` property containing a path to a property in the resource. search can be an array).`); // eslint-disable-line no-console
352
+
353
+ col.search = false;
354
+ }
355
+
356
+ if (
357
+ col.sort !== false && // sort is explicitly disabled
358
+ (typeof col.sort !== 'string' && !Array.isArray(col.sort)) // primary property path to sort on
359
+ ) {
360
+ console.warn(`Unable to support server-side sort for extension provided column "${ col.name || col.label || col.labelKey }" (column must provide \`sort\` property containing a path to a property, or array of paths, in the resource)`); // eslint-disable-line no-console
361
+
362
+ col.sort = false;
363
+ }
364
+ }
365
+
342
366
  // we need the 'value' prop to be populated in order for the rows to show the values
343
367
  if (!col.value && col.getValue) {
344
368
  col.value = col.getValue;
@@ -26,6 +26,7 @@ import ButtonMultiAction from '@shell/components/ButtonMultiAction.vue';
26
26
  import ActionMenu from '@shell/components/ActionMenuShell.vue';
27
27
  import { useRuntimeFlag } from '@shell/composables/useRuntimeFlag';
28
28
  import ActionDropdownShell from '@shell/components/ActionDropdownShell.vue';
29
+ import { useTabCountUpdater } from '@shell/components/form/ResourceTabs/composable';
29
30
 
30
31
  // Uncomment for table performance debugging
31
32
  // import tableDebug from './debug';
@@ -51,7 +52,7 @@ export default {
51
52
  'group-value-change',
52
53
  'selection',
53
54
  'rowClick',
54
- 'enter',
55
+ 'enter'
55
56
  ],
56
57
 
57
58
  components: {
@@ -432,6 +433,7 @@ export default {
432
433
  $main?.addEventListener('scroll', this._onScroll);
433
434
 
434
435
  this.debouncedPaginationChanged();
436
+ this.updateTabCount(this.totalRows);
435
437
  },
436
438
 
437
439
  beforeUnmount() {
@@ -445,6 +447,7 @@ export default {
445
447
  const $main = document.querySelector('main');
446
448
 
447
449
  $main?.removeEventListener('scroll', this._onScroll);
450
+ this.clearTabCount();
448
451
  },
449
452
 
450
453
  watch: {
@@ -559,10 +562,13 @@ export default {
559
562
 
560
563
  const store = useStore();
561
564
  const { featureDropdownMenu } = useRuntimeFlag(store);
565
+ const { updateTabCount, clearTabCount } = useTabCountUpdater();
562
566
 
563
567
  return {
564
568
  table,
565
569
  featureDropdownMenu,
570
+ updateTabCount,
571
+ clearTabCount
566
572
  };
567
573
  },
568
574
 
@@ -99,6 +99,9 @@ export default {
99
99
  this.debouncedPaginationChanged();
100
100
  },
101
101
 
102
+ totalRows() {
103
+ this.updateTabCount(this.totalRows);
104
+ }
102
105
  },
103
106
 
104
107
  methods: {
@@ -1,4 +1,6 @@
1
1
  <script>
2
+ import { useTabCountWatcher } from '@shell/components/form/ResourceTabs/composable';
3
+
2
4
  export default {
3
5
  inject: ['addTab', 'removeTab', 'sideTabs'],
4
6
 
@@ -43,6 +45,20 @@ export default {
43
45
  required: false,
44
46
  type: Number
45
47
  },
48
+ /**
49
+ * False to hide the count from being displayed in a tab.
50
+ * Number override/display the number as the count on the tab.
51
+ */
52
+ count: {
53
+ default: undefined,
54
+ type: [Number, Boolean]
55
+ }
56
+ },
57
+
58
+ setup(props) {
59
+ const { count, isCountVisible } = useTabCountWatcher();
60
+
61
+ return { inferredCount: count, isInferredCountVisible: isCountVisible };
46
62
  },
47
63
 
48
64
  data() {
@@ -50,7 +66,7 @@ export default {
50
66
  },
51
67
 
52
68
  computed: {
53
- labelDisplay() {
69
+ baseLabelDisplay() {
54
70
  if ( this.labelKey ) {
55
71
  return this.$store.getters['i18n/t'](this.labelKey);
56
72
  }
@@ -62,12 +78,38 @@ export default {
62
78
  return this.name;
63
79
  },
64
80
 
81
+ labelDisplay() {
82
+ const baseLabel = this.baseLabelDisplay;
83
+
84
+ if ( this.displayCount === false ) {
85
+ return baseLabel;
86
+ }
87
+
88
+ return `${ baseLabel } (${ this.displayCount })`;
89
+ },
90
+
65
91
  shouldShowHeader() {
66
92
  if ( this.showHeader !== null ) {
67
93
  return this.showHeader;
68
94
  }
69
95
 
70
96
  return this.sideTabs || false;
97
+ },
98
+
99
+ displayCount() {
100
+ if (this.count === false) {
101
+ return false;
102
+ }
103
+
104
+ if (typeof this.count === 'number') {
105
+ return this.count;
106
+ }
107
+
108
+ if (this.isInferredCountVisible) {
109
+ return this.inferredCount;
110
+ }
111
+
112
+ return false;
71
113
  }
72
114
  },
73
115
 
@@ -299,7 +299,9 @@ export default {
299
299
  @click.prevent="select(tab.name, $event)"
300
300
  @keyup.enter.space="select(tab.name, $event)"
301
301
  >
302
- <span>{{ tab.labelDisplay }}</span>
302
+ <span>
303
+ {{ tab.labelDisplay }}
304
+ </span>
303
305
  <span
304
306
  v-if="tab.badge"
305
307
  class="tab-badge"
@@ -0,0 +1,151 @@
1
+ /* eslint-disable jest/no-hooks */
2
+ import { mount, VueWrapper } from '@vue/test-utils';
3
+ import { nextTick } from 'vue';
4
+ import { createStore } from 'vuex';
5
+ import CronExpressionEditor from '@shell/components/Cron/CronExpressionEditor.vue';
6
+ import type { CronField } from '@shell/components/Cron/types';
7
+
8
+ const translations: Record<string, string> = {
9
+ 'component.cron.expressionEditor.label.minute': 'Minute',
10
+ 'component.cron.expressionEditor.label.hour': 'Hour',
11
+ 'component.cron.expressionEditor.label.dayOfMonth': 'Day of Month',
12
+ 'component.cron.expressionEditor.label.month': 'Month',
13
+ 'component.cron.expressionEditor.label.dayOfWeek': 'Day of Week',
14
+ 'component.cron.expressionEditor.invalidValue': 'Invalid value',
15
+ };
16
+
17
+ const store = createStore({});
18
+
19
+ interface CronExpressionEditorVm extends InstanceType<typeof CronExpressionEditor> {
20
+ cronValues: Record<CronField, string>;
21
+ handleInput: (field: CronField, value: string) => void;
22
+ isValid: boolean;
23
+ readableCron: string;
24
+ errors: Record<CronField, boolean>;
25
+ focusedField: Record<CronField, boolean>;
26
+ tooltipRefs: Record<CronField, unknown>;
27
+ popperInstances: Record<CronField, unknown>;
28
+ handleFocus: (field: CronField) => void;
29
+ handleBlur: (field: CronField) => void;
30
+ }
31
+
32
+ describe('cronExpressionEditor', () => {
33
+ let wrapper: VueWrapper<CronExpressionEditorVm>;
34
+
35
+ const factory = (props: Partial<CronExpressionEditorVm> = {}) => mount(CronExpressionEditor, {
36
+ global: {
37
+ plugins: [store],
38
+ stubs: {
39
+ CronTooltip: true,
40
+ LabeledInput: {
41
+ name: 'LabeledInput',
42
+ props: ['label', 'tooltip', 'type', 'value'],
43
+ template: `
44
+ <div>
45
+ <label>{{ label }}</label>
46
+ <input ref="value" :value="value" />
47
+ </div>
48
+ `
49
+ }
50
+ },
51
+ mocks: { t: (key: string) => translations[key] || key },
52
+ },
53
+ props: { cronExpression: '0 0 * * *', ...props },
54
+ }) as VueWrapper<CronExpressionEditorVm>;
55
+
56
+ afterEach(() => wrapper?.unmount());
57
+
58
+ const getEmitted = (event: string) => wrapper.emitted(event) as unknown[][] || [];
59
+
60
+ it('renders 5 input fields with correct labels', () => {
61
+ wrapper = factory();
62
+ const labels = wrapper.findAll('label').map((l) => l.text());
63
+
64
+ expect(labels).toStrictEqual(['Minute', 'Hour', 'Day of Month', 'Month', 'Day of Week']);
65
+ });
66
+
67
+ it('initializes cron values and emits initial events', () => {
68
+ wrapper = factory();
69
+ const vm = wrapper.vm;
70
+
71
+ expect(vm.cronValues).toStrictEqual({
72
+ minute: '0', hour: '0', dayOfMonth: '*', month: '*', dayOfWeek: '*'
73
+ });
74
+ expect(vm.isValid).toBe(true);
75
+ expect(vm.readableCron).toContain('12:00');
76
+
77
+ expect(getEmitted('update:cronExpression')[0][0]).toBe('0 0 * * *');
78
+ expect(typeof getEmitted('update:readableCron')[0][0]).toBe('string');
79
+ expect(getEmitted('update:isValid')[0][0]).toBe(true);
80
+ });
81
+
82
+ it('emits correct events when cron value changes', async() => {
83
+ wrapper = factory();
84
+ const vm = wrapper.vm;
85
+
86
+ vm.handleInput('minute', '5');
87
+ await nextTick();
88
+
89
+ expect(getEmitted('update:cronExpression')[1][0]).toBe('5 0 * * *');
90
+ expect(getEmitted('update:readableCron')[1][0]).toBe(vm.readableCron);
91
+ expect(getEmitted('update:isValid')[1][0]).toBe(true);
92
+ });
93
+
94
+ it('validates individual fields correctly', () => {
95
+ wrapper = factory();
96
+ const vm = wrapper.vm;
97
+
98
+ vm.handleInput('minute', '0');
99
+ expect(vm.errors.minute).toBe(false);
100
+
101
+ vm.handleInput('minute', '61');
102
+ expect(vm.errors.minute).toBe(true);
103
+ });
104
+
105
+ it('handles invalid cron expressions gracefully', async() => {
106
+ wrapper = factory({ cronExpression: '61 * * * *' });
107
+ const vm = wrapper.vm;
108
+
109
+ await nextTick();
110
+ expect(vm.isValid).toBe(false);
111
+ });
112
+
113
+ it('updates readableCron correctly for valid and invalid inputs', async() => {
114
+ wrapper = factory({ cronExpression: '0 12 * * *' });
115
+ const vm = wrapper.vm;
116
+
117
+ await nextTick();
118
+ expect(vm.readableCron).toContain('12:00');
119
+
120
+ vm.handleInput('hour', '25');
121
+ await nextTick();
122
+ expect(vm.isValid).toBe(false);
123
+ });
124
+
125
+ it('shows tooltip and manages popper on focus/blur', async() => {
126
+ wrapper = factory();
127
+ const vm = wrapper.vm;
128
+
129
+ await vm.handleFocus('minute');
130
+ expect(vm.focusedField.minute).toBe(true);
131
+ expect(vm.tooltipRefs.minute).not.toBeNull();
132
+ expect(vm.popperInstances.minute).not.toBeNull();
133
+
134
+ vm.handleBlur('minute');
135
+ await nextTick();
136
+ expect(vm.focusedField.minute).toBe(false);
137
+ expect(vm.popperInstances.minute).toBeNull();
138
+ });
139
+
140
+ it('displays error tooltip for invalid input', async() => {
141
+ wrapper = factory();
142
+ const vm = wrapper.vm;
143
+
144
+ vm.handleInput('minute', '61');
145
+ await nextTick();
146
+
147
+ const inputWrapper = wrapper.findComponent({ name: 'LabeledInput' });
148
+
149
+ expect(inputWrapper.props('tooltip')).toBe('Invalid value');
150
+ });
151
+ });
@@ -0,0 +1,81 @@
1
+ /* eslint-disable jest/no-hooks */
2
+ import { mount, VueWrapper } from '@vue/test-utils';
3
+ import { createStore } from 'vuex';
4
+ import CronExpressionEditorModal from '@shell/components/Cron/CronExpressionEditorModal.vue';
5
+
6
+ interface CronExpressionEditorModalVm extends InstanceType<typeof CronExpressionEditorModal> {
7
+ localCron: string;
8
+ confirmCron?: () => void;
9
+ closeModal?: () => void;
10
+ }
11
+
12
+ const store = createStore({});
13
+
14
+ describe('cronExpressionEditorModal', () => {
15
+ let modalsDiv: HTMLElement;
16
+ let wrapper: VueWrapper<CronExpressionEditorModalVm>;
17
+
18
+ const factory = (props: Partial<CronExpressionEditorModalVm> = {}) => mount(CronExpressionEditorModal, {
19
+ global: {
20
+ plugins: [store],
21
+ stubs: {
22
+ AppModal: true,
23
+ CronExpressionEditor: true,
24
+ },
25
+ },
26
+ props: {
27
+ cronExpression: '0 0 * * *',
28
+ show: true,
29
+ ...props,
30
+ },
31
+ }) as VueWrapper<CronExpressionEditorModalVm>;
32
+
33
+ const getEmitted = (event: string) => wrapper.emitted(event) as unknown[][] || [];
34
+
35
+ beforeEach(() => {
36
+ modalsDiv = document.createElement('div');
37
+ modalsDiv.id = 'modals';
38
+ document.body.appendChild(modalsDiv);
39
+ });
40
+
41
+ afterEach(() => {
42
+ wrapper?.unmount();
43
+ modalsDiv.remove();
44
+ });
45
+
46
+ it('renders modal with correct initial props', () => {
47
+ wrapper = factory();
48
+ expect(wrapper.props('cronExpression')).toBe('0 0 * * *');
49
+ expect(wrapper.props('show')).toBe(true);
50
+ });
51
+
52
+ it('updates localCron when cronExpression prop changes', async() => {
53
+ wrapper = factory();
54
+ await wrapper.setProps({ cronExpression: '*/5 * * * *' });
55
+ expect(wrapper.vm.localCron).toBe('*/5 * * * *');
56
+ });
57
+
58
+ it('emits update:cronExpression and update:show on confirm', async() => {
59
+ wrapper = factory();
60
+ await wrapper.vm.confirmCron?.();
61
+
62
+ const cronEmits = getEmitted('update:cronExpression');
63
+ const showEmits = getEmitted('update:show');
64
+
65
+ expect(cronEmits).toHaveLength(1);
66
+ expect(cronEmits[0][0]).toBe('0 0 * * *');
67
+
68
+ expect(showEmits).toHaveLength(1);
69
+ expect(showEmits[0][0]).toBe(false);
70
+ });
71
+
72
+ it('emits update:show on cancel', async() => {
73
+ wrapper = factory();
74
+ await wrapper.vm.closeModal?.();
75
+
76
+ const showEmits = getEmitted('update:show');
77
+
78
+ expect(showEmits).toHaveLength(1);
79
+ expect(showEmits[0][0]).toBe(false);
80
+ });
81
+ });