@rancher/shell 3.0.10 → 3.0.11

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 (64) hide show
  1. package/assets/translations/en-us.yaml +7 -5
  2. package/chart/__tests__/rancher-backup-index.test.ts +248 -0
  3. package/chart/rancher-backup/index.vue +41 -2
  4. package/components/BrandImage.vue +6 -5
  5. package/components/ConsumptionGauge.vue +12 -4
  6. package/components/DynamicContent/DynamicContentIcon.vue +3 -2
  7. package/components/ExplorerProjectsNamespaces.vue +1 -4
  8. package/components/LazyImage.vue +2 -1
  9. package/components/Resource/Detail/Card/Scaler.vue +4 -4
  10. package/components/Tabbed/index.vue +6 -0
  11. package/components/__tests__/ConsumptionGauge.test.ts +31 -0
  12. package/components/form/ProjectMemberEditor.vue +0 -10
  13. package/components/nav/TopLevelMenu.helper.ts +7 -79
  14. package/components/nav/__tests__/TopLevelMenu.helper.test.ts +2 -53
  15. package/config/private-label.js +2 -1
  16. package/config/product/apps.js +1 -0
  17. package/core/__tests__/extension-manager-impl.test.js +187 -2
  18. package/core/extension-manager-impl.js +4 -2
  19. package/core/plugin-helpers.ts +31 -0
  20. package/detail/__tests__/node.test.ts +83 -0
  21. package/detail/management.cattle.io.oidcclient.vue +2 -1
  22. package/detail/node.vue +1 -0
  23. package/edit/catalog.cattle.io.clusterrepo.vue +17 -3
  24. package/edit/cloudcredential.vue +2 -1
  25. package/edit/monitoring.coreos.com.alertmanagerconfig/receiverConfig.vue +11 -6
  26. package/edit/provisioning.cattle.io.cluster/index.vue +5 -4
  27. package/edit/provisioning.cattle.io.cluster/shared.ts +4 -2
  28. package/edit/secret/generic.vue +1 -0
  29. package/edit/secret/index.vue +2 -1
  30. package/edit/service.vue +2 -14
  31. package/list/management.cattle.io.feature.vue +7 -1
  32. package/list/provisioning.cattle.io.cluster.vue +0 -49
  33. package/mixins/brand.js +2 -1
  34. package/models/catalog.cattle.io.clusterrepo.js +9 -0
  35. package/models/cluster.x-k8s.io.machinedeployment.js +8 -3
  36. package/models/management.cattle.io.authconfig.js +2 -1
  37. package/models/management.cattle.io.cluster.js +4 -3
  38. package/models/monitoring.coreos.com.receiver.js +11 -6
  39. package/models/provisioning.cattle.io.cluster.js +2 -2
  40. package/package.json +5 -5
  41. package/pages/c/_cluster/apps/charts/index.vue +3 -8
  42. package/pages/c/_cluster/apps/charts/install.vue +8 -9
  43. package/pages/c/_cluster/istio/index.vue +4 -2
  44. package/pages/c/_cluster/longhorn/index.vue +2 -1
  45. package/pages/c/_cluster/monitoring/index.vue +2 -2
  46. package/pages/c/_cluster/neuvector/index.vue +2 -1
  47. package/pages/c/_cluster/settings/performance.vue +0 -5
  48. package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +2 -1
  49. package/pages/c/_cluster/uiplugins/index.vue +2 -1
  50. package/plugins/steve/steve-pagination-utils.ts +1 -2
  51. package/plugins/steve/subscribe.js +29 -4
  52. package/rancher-components/RcButton/RcButton.vue +3 -3
  53. package/rancher-components/RcButtonSplit/RcButtonSplit.test.ts +253 -0
  54. package/rancher-components/RcButtonSplit/RcButtonSplit.vue +158 -0
  55. package/rancher-components/RcButtonSplit/index.ts +1 -0
  56. package/scripts/test-plugins-build.sh +4 -4
  57. package/types/shell/index.d.ts +1 -0
  58. package/utils/__tests__/require-asset.test.ts +98 -0
  59. package/utils/async.ts +1 -5
  60. package/utils/brand.ts +3 -1
  61. package/utils/favicon.js +4 -3
  62. package/utils/require-asset.ts +95 -0
  63. package/vue.config.js +4 -3
  64. package/components/HarvesterServiceAddOnConfig.vue +0 -207
@@ -379,7 +379,7 @@ export default {
379
379
  /* Look for annotation to say this app is a legacy migrated app (we look in either place for now) */
380
380
  this.migratedApp = (this.existing?.spec?.chart?.metadata?.annotations?.[CATALOG_ANNOTATIONS.MIGRATED] === 'true');
381
381
 
382
- if (this.repo.isSuseAppCollection) {
382
+ if (this.repo?.isSuseAppCollection) {
383
383
  let defaultSelectedSecret = await this.$store.getters['cluster/byId'](SECRET, `cattle-system/${ this.repo.spec.clientSecret.name }`);
384
384
 
385
385
  if (!defaultSelectedSecret) {
@@ -823,7 +823,7 @@ export default {
823
823
  }
824
824
  }
825
825
 
826
- if (this.repo.isSuseAppCollection) {
826
+ if (this.repo?.isSuseAppCollection) {
827
827
  await this.initializeDataForNamespaceChanges();
828
828
  }
829
829
  },
@@ -938,7 +938,7 @@ export default {
938
938
 
939
939
  async initializeDataForNamespaceChanges() {
940
940
  // Skip the flow if the data still not fetched, it will trigger after fetching manually
941
- if (!this.appCoDataFetched) {
941
+ if (this.appCoDataFetched) {
942
942
  try {
943
943
  this.defaultImagePullSecret = await this.$store.dispatch('cluster/find', { type: SECRET, id: `${ this.targetNamespace }/${ this.repo.spec.clientSecret.name }-image-pull-secret` });
944
944
  } catch (e) {
@@ -1028,7 +1028,7 @@ export default {
1028
1028
  },
1029
1029
 
1030
1030
  async setImagePullSecretData() {
1031
- if (this.selectedSecret && this.repo.isSuseAppCollection && this.dontUseDefaultOption !== null) {
1031
+ if (this.selectedSecret && this.repo?.isSuseAppCollection && this.dontUseDefaultOption !== null) {
1032
1032
  if (!this.dontUseDefaultOption && this.defaultImagePullSecret) {
1033
1033
  // If the default option is used and the default secret already exists, use it
1034
1034
  this.selectedImagePullSecret = this.defaultImagePullSecret.name;
@@ -1159,7 +1159,7 @@ export default {
1159
1159
 
1160
1160
  // Create namespace if it doesn't exist (before hooks run)
1161
1161
  // And only if it is SUSE APP Collection, overall should just do the same flow
1162
- if (!isUpgrade && this.isNamespaceNew && this.repo.isSuseAppCollection) {
1162
+ if (!isUpgrade && this.isNamespaceNew && this.repo?.isSuseAppCollection) {
1163
1163
  await this.createNamespaceIfNeeded();
1164
1164
  }
1165
1165
 
@@ -1355,7 +1355,7 @@ export default {
1355
1355
  ...migratedAnnotations,
1356
1356
  [CATALOG_ANNOTATIONS.SOURCE_REPO_TYPE]: this.chart.repoType,
1357
1357
  [CATALOG_ANNOTATIONS.SOURCE_REPO_NAME]: this.chart.repoName,
1358
- ...(this.repo.isSuseAppCollection ? { [CATALOG_ANNOTATIONS.SUSE_APP_COLLECTION]: 'true' } : {}),
1358
+ ...(this.repo?.isSuseAppCollection ? { [CATALOG_ANNOTATIONS.SUSE_APP_COLLECTION]: 'true' } : {}),
1359
1359
  },
1360
1360
  values,
1361
1361
  };
@@ -1497,7 +1497,7 @@ export default {
1497
1497
  },
1498
1498
 
1499
1499
  async createImagePullSecret() {
1500
- if (!this.repo.isSuseAppCollection) {
1500
+ if (!this.repo?.isSuseAppCollection) {
1501
1501
  return;
1502
1502
  }
1503
1503
 
@@ -1655,7 +1655,6 @@ export default {
1655
1655
  </div>
1656
1656
  </div>
1657
1657
  <NameNsDescription
1658
- v-if="showNameEditor"
1659
1658
  v-model:value="value"
1660
1659
  :description-hidden="true"
1661
1660
  :mode="mode"
@@ -1686,7 +1685,7 @@ export default {
1686
1685
  </template>
1687
1686
  </NameNsDescription>
1688
1687
  <div
1689
- v-if="repo.isSuseAppCollection"
1688
+ v-if="repo?.isSuseAppCollection"
1690
1689
  class="mb-20"
1691
1690
  >
1692
1691
  <Banner
@@ -2,6 +2,8 @@
2
2
  import { mapGetters } from 'vuex';
3
3
  import { SERVICE } from '@shell/config/types';
4
4
  import Loading from '@shell/components/Loading';
5
+ import kialiSvg from '~shell/assets/images/vendor/kiali.svg';
6
+ import jaegerSvg from '~shell/assets/images/vendor/jaeger.svg';
5
7
  export default {
6
8
  components: { Loading },
7
9
 
@@ -23,7 +25,7 @@ export default {
23
25
 
24
26
  kialiLogo() {
25
27
  // @TODO move to theme css
26
- return require(`~shell/assets/images/vendor/kiali.svg`);
28
+ return kialiSvg;
27
29
  },
28
30
 
29
31
  kialiUrl() {
@@ -31,7 +33,7 @@ export default {
31
33
  },
32
34
 
33
35
  jaegerLogo() {
34
- return require(`~shell/assets/images/vendor/jaeger.svg`);
36
+ return jaegerSvg;
35
37
  },
36
38
 
37
39
  jaegerUrl() {
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
3
3
  import { SERVICE } from '@shell/config/types';
4
4
  import IconMessage from '@shell/components/IconMessage';
5
5
  import LazyImage from '@shell/components/LazyImage';
6
+ import longhornSvg from '~shell/assets/images/vendor/longhorn.svg';
6
7
  import Loading from '@shell/components/Loading';
7
8
 
8
9
  export default {
@@ -24,7 +25,7 @@ export default {
24
25
 
25
26
  data() {
26
27
  return {
27
- longhornImgSrc: require('~shell/assets/images/vendor/longhorn.svg'),
28
+ longhornImgSrc: longhornSvg,
28
29
  uiServices: null
29
30
  };
30
31
  },
@@ -9,6 +9,8 @@ import LazyImage from '@shell/components/LazyImage';
9
9
  import SimpleBox from '@shell/components/SimpleBox';
10
10
  import { canViewAlertManagerLink, canViewGrafanaLink, canViewPrometheusLink } from '@shell/utils/monitoring';
11
11
  import Loading from '@shell/components/Loading';
12
+ import grafanaSrc from '~shell/assets/images/vendor/grafana.svg';
13
+ import prometheusSrc from '~shell/assets/images/vendor/prometheus.svg';
12
14
 
13
15
  export default {
14
16
  components: {
@@ -23,8 +25,6 @@ export default {
23
25
  },
24
26
 
25
27
  data() {
26
- const grafanaSrc = require('~shell/assets/images/vendor/grafana.svg');
27
- const prometheusSrc = require('~shell/assets/images/vendor/prometheus.svg');
28
28
  const currentCluster = this.$store.getters['currentCluster'];
29
29
 
30
30
  return {
@@ -4,6 +4,7 @@ import { NEU_VECTOR_NAMESPACE } from '@shell/config/product/neuvector';
4
4
 
5
5
  import LazyImage from '@shell/components/LazyImage';
6
6
  import Loading from '@shell/components/Loading';
7
+ import neuvectorSvg from '~shell/assets/images/vendor/neuvector.svg';
7
8
 
8
9
  export default {
9
10
  components: { LazyImage, Loading },
@@ -11,7 +12,7 @@ export default {
11
12
  data() {
12
13
  return {
13
14
  externalLinks: [],
14
- neuvectorImgSrc: require('~shell/assets/images/vendor/neuvector.svg'),
15
+ neuvectorImgSrc: neuvectorSvg,
15
16
  };
16
17
  },
17
18
 
@@ -237,11 +237,6 @@ export default {
237
237
  {{ t('performance.serverPagination.label') }}
238
238
  </h2>
239
239
  <p>{{ t('performance.serverPagination.description') }}</p>
240
- <Banner
241
- color="warning"
242
- >
243
- <div v-clean-html="t(`performance.serverPagination.featureFlag`, { ffUrl }, true)" />
244
- </Banner>
245
240
  <Collapse
246
241
  :title="t('performance.serverPagination.applicable')"
247
242
  :open="steveCacheEnabled && ssPApplicableTypesOpen"
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
3
3
  import ChartReadme from '@shell/components/ChartReadme';
4
4
  import LazyImage from '@shell/components/LazyImage';
5
5
  import { MANAGEMENT } from '@shell/config/types';
6
+ import genericPluginSvg from '~shell/assets/images/generic-plugin.svg';
6
7
  import { SETTING } from '@shell/config/settings';
7
8
  import { useWatcherBasedSetupFocusTrapWithDestroyIncluded } from '@shell/composables/focusTrap';
8
9
  import { getPluginChartVersionLabel, getPluginChartVersion } from '@shell/utils/uiplugins';
@@ -36,7 +37,7 @@ export default {
36
37
  infoVersion: undefined,
37
38
  versionInfo: undefined,
38
39
  versionError: undefined,
39
- defaultIcon: require('~shell/assets/images/generic-plugin.svg'),
40
+ defaultIcon: genericPluginSvg,
40
41
  headerBannerSize: 0,
41
42
  isActive: false
42
43
  };
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
3
3
  import day from 'dayjs';
4
4
  import { mapPref, PLUGIN_DEVELOPER } from '@shell/store/prefs';
5
5
  import { sortBy } from '@shell/utils/sort';
6
+ import genericPluginSvg from '~shell/assets/images/generic-plugin.svg';
6
7
  import { allHash } from '@shell/utils/promise';
7
8
  import { CATALOG, UI_PLUGIN, MANAGEMENT, ZERO_TIME } from '@shell/config/types';
8
9
  import { SETTING } from '@shell/config/settings';
@@ -73,7 +74,7 @@ export default {
73
74
  menuTargetEvent: null,
74
75
  menuOpen: false,
75
76
  hasFeatureFlag: true,
76
- defaultIcon: require('~shell/assets/images/generic-plugin.svg'),
77
+ defaultIcon: genericPluginSvg,
77
78
  reloadRequired: false,
78
79
  rancherVersion: null
79
80
  };
@@ -766,8 +766,7 @@ export const PAGINATION_SETTINGS_STORE_DEFAULTS: PaginationSettingsStores = {
766
766
  { resource: CAPI.RANCHER_CLUSTER, context: ['side-bar'] },
767
767
  { resource: MANAGEMENT.CLUSTER, context: ['side-bar'] },
768
768
  { resource: CATALOG.APP, context: ['branding'] },
769
- SECRET,
770
- CAPI.MACHINE_SET
769
+ SECRET
771
770
  ],
772
771
  generic: false,
773
772
  }
@@ -131,11 +131,17 @@ const isWaitingForDestroy = (storeName, store) => {
131
131
  };
132
132
 
133
133
  const waitForSettingsSchema = (storeName, store) => {
134
- return waitFor(() => isWaitingForDestroy(storeName, store) || !!store.getters['management/byId'](SCHEMA, MANAGEMENT.SETTING));
134
+ return waitFor(
135
+ () => isWaitingForDestroy(storeName, store) || !!store.getters['management/byId'](SCHEMA, MANAGEMENT.SETTING),
136
+ 'management settings schema to be available'
137
+ );
135
138
  };
136
139
 
137
140
  const waitForSettings = (storeName, store) => {
138
- return waitFor(() => isWaitingForDestroy(storeName, store) || !!store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.UI_PERFORMANCE));
141
+ return waitFor(
142
+ () => isWaitingForDestroy(storeName, store) || !!store.getters['management/byId'](MANAGEMENT.SETTING, SETTING.UI_PERFORMANCE),
143
+ 'UI performance settings to be available'
144
+ );
139
145
  };
140
146
 
141
147
  const isAdvancedWorker = (ctx) => {
@@ -195,8 +201,20 @@ export async function createWorker(store, ctx) {
195
201
  };
196
202
  }
197
203
 
198
- await waitForSettingsSchema(storeName, store);
199
- await waitForSettings(storeName, store);
204
+ try {
205
+ await waitForSettingsSchema(storeName, store);
206
+ await waitForSettings(storeName, store);
207
+ } catch (e) {
208
+ // Clean up the mock worker and abort so callers are not permanently blocked.
209
+ if (store.$workers[storeName]?.destroy) {
210
+ store.$workers[storeName].destroy();
211
+ } else {
212
+ delete store.$workers[storeName];
213
+ }
214
+
215
+ return;
216
+ }
217
+
200
218
  if (store.$workers[storeName].waitingForDestroy()) {
201
219
  store.$workers[storeName].destroy();
202
220
 
@@ -382,6 +400,13 @@ const sharedActions = {
382
400
  if (!this.$workers[getters.storeName]) {
383
401
  await createWorker(this, ctx);
384
402
  }
403
+
404
+ // createWorker cleans up and returns early when schema/settings are unavailable.
405
+ // Guard against calling postMessage on a non-existent worker.
406
+ if (!this.$workers[getters.storeName]) {
407
+ return;
408
+ }
409
+
385
410
  const options = { parseJSON: false };
386
411
  const csrf = rootGetters['cookies/get']({ key: CSRF, options });
387
412
 
@@ -253,7 +253,7 @@ button {
253
253
  &.btn-small {
254
254
  //:not(.btn-sm) is being used to make the style more specific to override global styles. We may want to get rid of those styles at some point.
255
255
  &, &:not(.btn-sm) {
256
- line-height: 15px;
256
+ line-height: 140%;
257
257
  font-size: 12px;
258
258
  min-height: 24px;
259
259
 
@@ -265,7 +265,7 @@ button {
265
265
  &.btn-medium {
266
266
  //:not(.btn-sm) is being used to make the style more specific to override global styles. We may want to get rid of those styles at some point.
267
267
  &, &:not(.btn-sm) {
268
- line-height: 18px;
268
+ line-height: 140%;
269
269
  font-size: 14px;
270
270
  min-height: 32px;
271
271
 
@@ -277,7 +277,7 @@ button {
277
277
  &.btn-large {
278
278
  // This is the default size brought by the global button styling
279
279
  &, &:not(.btn-sm) {
280
- line-height: 20px;
280
+ line-height: 140%;
281
281
  font-size: 16px;
282
282
  min-height: 40px;
283
283
 
@@ -0,0 +1,253 @@
1
+ import { mount } from '@vue/test-utils';
2
+ import { defineComponent } from 'vue';
3
+ import RcButtonSplit from './RcButtonSplit.vue';
4
+ import { ButtonVariant, ButtonSize } from '@components/RcButton/types';
5
+
6
+ // v-dropdown is provided by floating-vue and must be mocked in unit tests.
7
+ // The default slot is the trigger anchor; the popper slot is the dropdown content.
8
+ const vDropdownMock = defineComponent({
9
+ template: `
10
+ <div>
11
+ <slot />
12
+ <slot name="popper" />
13
+ </div>
14
+ `,
15
+ });
16
+
17
+ const globalConfig = { global: { components: { 'v-dropdown': vDropdownMock } } };
18
+
19
+ // RcButton and RcDropdownTrigger both use single-root attribute inheritance, so
20
+ // the class added to the component falls through to the rendered <button> element.
21
+ // Selectors like '.rc-button-split-action' refer directly to the <button> element.
22
+
23
+ describe('rcButtonSplit.vue', () => {
24
+ it('renders the main action button', () => {
25
+ const wrapper = mount(RcButtonSplit, globalConfig);
26
+
27
+ expect(wrapper.find('.rc-button-split-action').exists()).toBe(true);
28
+ });
29
+
30
+ it('renders the dropdown trigger', () => {
31
+ const wrapper = mount(RcButtonSplit, globalConfig);
32
+
33
+ expect(wrapper.find('.rc-button-split-trigger').exists()).toBe(true);
34
+ });
35
+
36
+ it('emits click when the main action button is clicked', async() => {
37
+ const wrapper = mount(RcButtonSplit, globalConfig);
38
+
39
+ await wrapper.find('.rc-button-split-action').trigger('click');
40
+
41
+ expect(wrapper.emitted('click')).toHaveLength(1);
42
+ expect(wrapper.emitted('click')![0][0]).toBeInstanceOf(MouseEvent);
43
+ });
44
+
45
+ it('does not emit click when the dropdown trigger is clicked', async() => {
46
+ const wrapper = mount(RcButtonSplit, globalConfig);
47
+
48
+ await wrapper.find('.rc-button-split-trigger').trigger('click');
49
+
50
+ expect(wrapper.emitted('click')).toBeUndefined();
51
+ });
52
+
53
+ describe('variant prop', () => {
54
+ it.each([
55
+ ['primary', 'variant-primary'],
56
+ ['secondary', 'variant-secondary'],
57
+ ['tertiary', 'variant-tertiary'],
58
+ ] as [ButtonVariant, string][])('applies %s variant class to the action button', (variant, className) => {
59
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { variant } });
60
+
61
+ expect(wrapper.find('.rc-button-split-action').classes()).toContain(className);
62
+ });
63
+
64
+ it.each([
65
+ ['primary', 'variant-primary'],
66
+ ['secondary', 'variant-secondary'],
67
+ ['tertiary', 'variant-tertiary'],
68
+ ] as [ButtonVariant, string][])('applies %s variant class to the dropdown trigger button', (variant, className) => {
69
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { variant } });
70
+
71
+ expect(wrapper.find('.rc-button-split-trigger').classes()).toContain(className);
72
+ });
73
+ });
74
+
75
+ describe('size prop', () => {
76
+ it.each([
77
+ ['small', 'btn-small'],
78
+ ['medium', 'btn-medium'],
79
+ ['large', 'btn-large'],
80
+ ] as [ButtonSize, string][])('applies %s size class to the action button', (size, className) => {
81
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { size } });
82
+
83
+ expect(wrapper.find('.rc-button-split-action').classes()).toContain(className);
84
+ });
85
+
86
+ it.each([
87
+ ['small', 'btn-small'],
88
+ ['medium', 'btn-medium'],
89
+ ['large', 'btn-large'],
90
+ ] as [ButtonSize, string][])('applies %s size class to the dropdown trigger button', (size, className) => {
91
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { size } });
92
+
93
+ expect(wrapper.find('.rc-button-split-trigger').classes()).toContain(className);
94
+ });
95
+ });
96
+
97
+ describe('slots', () => {
98
+ it('renders default slot content in the main button', () => {
99
+ const wrapper = mount(RcButtonSplit, {
100
+ ...globalConfig,
101
+ slots: { default: 'Save' },
102
+ });
103
+
104
+ expect(wrapper.find('.rc-button-split-action').text()).toContain('Save');
105
+ });
106
+
107
+ it('renders before slot content in the main button', () => {
108
+ const wrapper = mount(RcButtonSplit, {
109
+ ...globalConfig,
110
+ slots: {
111
+ default: 'Save',
112
+ before: '<span class="before-content">Before</span>',
113
+ },
114
+ });
115
+
116
+ expect(wrapper.find('.rc-button-split-action .before-content').exists()).toBe(true);
117
+ });
118
+
119
+ it('renders after slot content in the main button', () => {
120
+ const wrapper = mount(RcButtonSplit, {
121
+ ...globalConfig,
122
+ slots: {
123
+ default: 'Save',
124
+ after: '<span class="after-content">After</span>',
125
+ },
126
+ });
127
+
128
+ expect(wrapper.find('.rc-button-split-action .after-content').exists()).toBe(true);
129
+ });
130
+
131
+ it('renders dropdownCollection slot content', () => {
132
+ const wrapper = mount(RcButtonSplit, {
133
+ ...globalConfig,
134
+ slots: { dropdownCollection: '<div class="dropdown-item-test">Item 1</div>' },
135
+ });
136
+
137
+ expect(wrapper.find('.dropdown-item-test').exists()).toBe(true);
138
+ expect(wrapper.find('.dropdown-item-test').text()).toStrictEqual('Item 1');
139
+ });
140
+ });
141
+
142
+ it('dropdown trigger has aria-haspopup="menu" attribute', () => {
143
+ const wrapper = mount(RcButtonSplit, globalConfig);
144
+
145
+ expect(wrapper.find('.rc-button-split-trigger').attributes('aria-haspopup')).toBe('menu');
146
+ });
147
+
148
+ it('emits update:open when dropdown open state changes', async() => {
149
+ const wrapper = mount(RcButtonSplit, globalConfig);
150
+
151
+ await wrapper.findComponent({ name: 'RcDropdown' }).vm.$emit('update:open', true);
152
+
153
+ expect(wrapper.emitted('update:open')).toHaveLength(1);
154
+ expect(wrapper.emitted('update:open')![0]).toStrictEqual([true]);
155
+ });
156
+
157
+ it('applies default variant "primary" when no variant is provided', () => {
158
+ const wrapper = mount(RcButtonSplit, globalConfig);
159
+
160
+ expect(wrapper.find('.rc-button-split-action').classes()).toContain('variant-primary');
161
+ });
162
+
163
+ it('applies default size "medium" when no size is provided', () => {
164
+ const wrapper = mount(RcButtonSplit, globalConfig);
165
+
166
+ expect(wrapper.find('.rc-button-split-action').classes()).toContain('btn-medium');
167
+ });
168
+
169
+ describe('aria label props', () => {
170
+ it('ariaLabel is applied to the action button', () => {
171
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { ariaLabel: 'Save document' } });
172
+
173
+ expect(wrapper.find('.rc-button-split-action').attributes('aria-label')).toBe('Save document');
174
+ });
175
+
176
+ it('ariaLabel is absent from the trigger button', () => {
177
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { ariaLabel: 'Save document' } });
178
+
179
+ expect(wrapper.find('.rc-button-split-trigger').attributes('aria-label')).toBeUndefined();
180
+ });
181
+
182
+ it('ariaLabelTrigger is applied to the trigger button', () => {
183
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { ariaLabelTrigger: 'More save options' } });
184
+
185
+ expect(wrapper.find('.rc-button-split-trigger').attributes('aria-label')).toBe('More save options');
186
+ });
187
+
188
+ it('ariaLabelTrigger is absent from the action button', () => {
189
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { ariaLabelTrigger: 'More save options' } });
190
+
191
+ expect(wrapper.find('.rc-button-split-action').attributes('aria-label')).toBeUndefined();
192
+ });
193
+
194
+ it('ariaLabelDropdown is forwarded to RcDropdown', () => {
195
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { ariaLabelDropdown: 'Save actions' } });
196
+
197
+ expect(wrapper.findComponent({ name: 'RcDropdown' }).props('ariaLabel')).toBe('Save actions');
198
+ });
199
+ });
200
+
201
+ describe('items prop', () => {
202
+ const items = [
203
+ { id: 'draft', label: 'Save as Draft' },
204
+ { id: 'template', label: 'Save as Template' },
205
+ { id: 'discard', label: 'Discard Changes' },
206
+ ];
207
+
208
+ it('renders an RcDropdownItem for each entry in items', () => {
209
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { items } });
210
+
211
+ const dropdownItems = wrapper.findAllComponents({ name: 'RcDropdownItem' });
212
+
213
+ expect(dropdownItems).toHaveLength(3);
214
+ });
215
+
216
+ it('renders each item\'s label text', () => {
217
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { items } });
218
+
219
+ const dropdownItems = wrapper.findAllComponents({ name: 'RcDropdownItem' });
220
+
221
+ expect(dropdownItems[0].text()).toContain('Save as Draft');
222
+ expect(dropdownItems[1].text()).toContain('Save as Template');
223
+ expect(dropdownItems[2].text()).toContain('Discard Changes');
224
+ });
225
+
226
+ it('emits select with the item id when a prop item is clicked', async() => {
227
+ const wrapper = mount(RcButtonSplit, { ...globalConfig, props: { items } });
228
+
229
+ await wrapper.findAllComponents({ name: 'RcDropdownItem' })[0].trigger('click');
230
+
231
+ expect(wrapper.emitted('select')).toHaveLength(1);
232
+ expect(wrapper.emitted('select')![0]).toStrictEqual(['draft']);
233
+ });
234
+
235
+ it('does not emit select when no items prop is provided', async() => {
236
+ const wrapper = mount(RcButtonSplit, globalConfig);
237
+
238
+ expect(wrapper.emitted('select')).toBeUndefined();
239
+ });
240
+
241
+ it('renders both prop items and dropdownCollection slot content when both are supplied', () => {
242
+ const wrapper = mount(RcButtonSplit, {
243
+ ...globalConfig,
244
+ props: { items: [{ id: 'draft', label: 'Save as Draft' }] },
245
+ slots: { dropdownCollection: '<div class="slot-item">Slot Item</div>' },
246
+ });
247
+
248
+ expect(wrapper.findAllComponents({ name: 'RcDropdownItem' })).toHaveLength(1);
249
+ expect(wrapper.find('.slot-item').exists()).toBe(true);
250
+ expect(wrapper.find('.slot-item').text()).toStrictEqual('Slot Item');
251
+ });
252
+ });
253
+ });