@rancher/shell 3.0.9 → 3.0.10
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.
- package/assets/styles/base/_color.scss +4 -0
- package/assets/styles/themes/_light.scss +6 -6
- package/assets/styles/themes/_modern.scss +14 -6
- package/assets/translations/en-us.yaml +2 -5
- package/components/CopyToClipboard.vue +28 -0
- package/components/CopyToClipboardText.vue +4 -0
- package/components/CruResource.vue +1 -0
- package/components/GlobalRoleBindings.vue +1 -5
- package/components/ResourceDetail/index.vue +0 -21
- package/components/__tests__/CruResource.test.ts +35 -1
- package/composables/useIsNewDetailPageEnabled.test.ts +98 -0
- package/composables/useIsNewDetailPageEnabled.ts +12 -0
- package/config/product/explorer.js +11 -1
- package/config/table-headers.js +0 -9
- package/config/types.js +0 -1
- package/edit/auth/github-app-steps.vue +2 -0
- package/edit/auth/github-steps.vue +2 -0
- package/edit/management.cattle.io.user.vue +60 -35
- package/edit/token.vue +29 -68
- package/models/token.js +0 -4
- package/package.json +8 -8
- package/pages/account/index.vue +67 -96
- package/pages/c/_cluster/apps/charts/AppChartCardFooter.vue +66 -9
- package/pages/c/_cluster/explorer/index.vue +2 -19
- package/pkg/auto-import.js +41 -0
- package/plugins/dashboard-store/resource-class.js +2 -2
- package/plugins/steve/__tests__/steve-class.test.ts +1 -1
- package/plugins/steve/steve-class.js +3 -3
- package/plugins/steve/steve-pagination-utils.ts +2 -4
- package/rancher-components/Pill/RcCounterBadge/RcCounterBadge.vue +7 -7
- package/rancher-components/Pill/RcStatusBadge/RcStatusBadge.vue +5 -2
- package/rancher-components/RcIcon/types.ts +2 -2
- package/rancher-components/RcSection/RcSection.test.ts +323 -0
- package/rancher-components/RcSection/RcSection.vue +252 -0
- package/rancher-components/RcSection/RcSectionActions.test.ts +212 -0
- package/rancher-components/RcSection/RcSectionActions.vue +85 -0
- package/rancher-components/RcSection/RcSectionBadges.test.ts +149 -0
- package/rancher-components/RcSection/RcSectionBadges.vue +29 -0
- package/rancher-components/RcSection/index.ts +12 -0
- package/rancher-components/RcSection/types.ts +86 -0
- package/scripts/test-plugins-build.sh +5 -4
- package/types/shell/index.d.ts +92 -108
- package/utils/style.ts +17 -0
- package/utils/units.js +14 -5
- package/models/ext.cattle.io.token.js +0 -48
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A section container used for grouping and organizing content, with an
|
|
4
|
+
* optional header that supports title, badges, actions, and expandability.
|
|
5
|
+
*
|
|
6
|
+
* Example:
|
|
7
|
+
*
|
|
8
|
+
* <RcSection title="Section title" type="secondary" mode="with-header" :expandable="false" background="secondary">
|
|
9
|
+
* <p>Section content here</p>
|
|
10
|
+
* </RcSection>
|
|
11
|
+
*
|
|
12
|
+
* <RcSection title="Section title" type="secondary" mode="with-header" expandable v-model:expanded="expanded" background="secondary">
|
|
13
|
+
* <template #counter>
|
|
14
|
+
* <RcCounterBadge :count="99" type="inactive" />
|
|
15
|
+
* </template>
|
|
16
|
+
* <template #errors>
|
|
17
|
+
* <RcIcon v-clean-tooltip="'3 validation errors'" type="error" size="large" status="error" />
|
|
18
|
+
* </template>
|
|
19
|
+
* <template #badges>
|
|
20
|
+
* <RcSectionBadges :badges="[
|
|
21
|
+
* { label: 'Status', status: 'success', tooltip: 'All systems operational' },
|
|
22
|
+
* { label: 'Status', status: 'warning', tooltip: 'Degraded performance' },
|
|
23
|
+
* { label: 'Status', status: 'error', tooltip: 'Service unavailable' },
|
|
24
|
+
* ]" />
|
|
25
|
+
* </template>
|
|
26
|
+
* <template #actions>
|
|
27
|
+
* <RcSectionActions :actions="[
|
|
28
|
+
* { label: 'Action', icon: 'chevron-left', action: () => {} },
|
|
29
|
+
* { icon: 'copy', ariaLabel: 'Copy', action: () => {} },
|
|
30
|
+
* { icon: 'trash', label: 'Delete', action: () => {} },
|
|
31
|
+
* ]" />
|
|
32
|
+
* </template>
|
|
33
|
+
* <p>Section content here</p>
|
|
34
|
+
* </RcSection>
|
|
35
|
+
*/
|
|
36
|
+
import { computed, inject, provide, type Ref } from 'vue';
|
|
37
|
+
import RcButton from '@components/RcButton/RcButton.vue';
|
|
38
|
+
import RcIcon from '@components/RcIcon/RcIcon.vue';
|
|
39
|
+
import type { RcSectionProps, SectionBackground } from './types';
|
|
40
|
+
|
|
41
|
+
const RC_SECTION_BG_KEY = 'rc-section-background';
|
|
42
|
+
|
|
43
|
+
const props = withDefaults(defineProps<RcSectionProps>(), { title: '' });
|
|
44
|
+
|
|
45
|
+
const parentBackground = inject<Ref<SectionBackground> | null>(RC_SECTION_BG_KEY, null);
|
|
46
|
+
|
|
47
|
+
const resolvedBackground = computed<SectionBackground>(() => {
|
|
48
|
+
if (props.background) {
|
|
49
|
+
return props.background;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const parent = parentBackground?.value ?? null;
|
|
53
|
+
|
|
54
|
+
return parent === 'primary' ? 'secondary' : 'primary';
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
provide(RC_SECTION_BG_KEY, resolvedBackground);
|
|
58
|
+
|
|
59
|
+
const expanded = defineModel<boolean>('expanded', { default: true });
|
|
60
|
+
|
|
61
|
+
const hasHeader = computed(() => {
|
|
62
|
+
return props.mode === 'with-header';
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const sectionClass = computed(() => ({
|
|
66
|
+
'rc-section': true,
|
|
67
|
+
'type-primary': props.type === 'primary',
|
|
68
|
+
'type-secondary': props.type === 'secondary',
|
|
69
|
+
'bg-primary': resolvedBackground.value === 'primary',
|
|
70
|
+
'bg-secondary': resolvedBackground.value === 'secondary',
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
const contentClass = computed(() => ({
|
|
74
|
+
'section-content': true,
|
|
75
|
+
'no-header': !hasHeader.value,
|
|
76
|
+
'expandable-content': props.expandable,
|
|
77
|
+
}));
|
|
78
|
+
|
|
79
|
+
function toggle() {
|
|
80
|
+
if (props.expandable) {
|
|
81
|
+
expanded.value = !expanded.value;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<template>
|
|
87
|
+
<div :class="sectionClass">
|
|
88
|
+
<div
|
|
89
|
+
v-if="hasHeader"
|
|
90
|
+
class="section-header"
|
|
91
|
+
:class="{ expandable: props.expandable, collapsed: !expanded }"
|
|
92
|
+
@click="toggle"
|
|
93
|
+
>
|
|
94
|
+
<div class="left-wrapper">
|
|
95
|
+
<RcButton
|
|
96
|
+
v-if="props.expandable"
|
|
97
|
+
class="toggle-button"
|
|
98
|
+
variant="ghost"
|
|
99
|
+
:aria-expanded="expanded"
|
|
100
|
+
:aria-label="expanded ? 'Collapse section' : 'Expand section'"
|
|
101
|
+
@click.stop="toggle"
|
|
102
|
+
>
|
|
103
|
+
<RcIcon
|
|
104
|
+
:type="expanded ? 'chevron-down' : 'chevron-right'"
|
|
105
|
+
size="medium"
|
|
106
|
+
/>
|
|
107
|
+
</RcButton>
|
|
108
|
+
<div class="title">
|
|
109
|
+
<slot name="title">
|
|
110
|
+
{{ props.title }}
|
|
111
|
+
</slot>
|
|
112
|
+
<slot name="counter" />
|
|
113
|
+
<slot name="errors" />
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
<div
|
|
117
|
+
v-if="$slots.badges || $slots.actions"
|
|
118
|
+
class="right-wrapper"
|
|
119
|
+
>
|
|
120
|
+
<div
|
|
121
|
+
v-if="$slots.badges"
|
|
122
|
+
class="status-badges"
|
|
123
|
+
>
|
|
124
|
+
<slot name="badges" />
|
|
125
|
+
</div>
|
|
126
|
+
<div
|
|
127
|
+
v-if="$slots.actions"
|
|
128
|
+
class="actions"
|
|
129
|
+
@click.stop
|
|
130
|
+
>
|
|
131
|
+
<slot name="actions" />
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div
|
|
136
|
+
v-if="expanded"
|
|
137
|
+
:class="contentClass"
|
|
138
|
+
>
|
|
139
|
+
<slot />
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
</template>
|
|
143
|
+
|
|
144
|
+
<style lang="scss" scoped>
|
|
145
|
+
.rc-section {
|
|
146
|
+
display: flex;
|
|
147
|
+
flex-direction: column;
|
|
148
|
+
gap: 8px;
|
|
149
|
+
|
|
150
|
+
&.type-primary {
|
|
151
|
+
.title {
|
|
152
|
+
font-weight: 700;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
&.type-secondary {
|
|
157
|
+
padding: 0 16px;
|
|
158
|
+
border-radius: 8px;
|
|
159
|
+
|
|
160
|
+
.title {
|
|
161
|
+
font-weight: 600;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
&.bg-primary {
|
|
166
|
+
background-color: var(--rc-section-background-primary);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
&.bg-secondary {
|
|
170
|
+
background-color: var(--rc-section-background-secondary);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.section-header {
|
|
175
|
+
display: flex;
|
|
176
|
+
flex-direction: row;
|
|
177
|
+
align-items: center;
|
|
178
|
+
gap: 24px;
|
|
179
|
+
height: 56px;
|
|
180
|
+
|
|
181
|
+
&.expandable {
|
|
182
|
+
cursor: pointer;
|
|
183
|
+
user-select: none;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.left-wrapper {
|
|
188
|
+
display: flex;
|
|
189
|
+
flex-direction: row;
|
|
190
|
+
align-items: center;
|
|
191
|
+
gap: 12px;
|
|
192
|
+
|
|
193
|
+
.toggle-button + .title {
|
|
194
|
+
margin-left: -4px;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
.title {
|
|
199
|
+
display: inline-flex;
|
|
200
|
+
gap: 12px;
|
|
201
|
+
font-size: 18px;
|
|
202
|
+
line-height: 1.2;
|
|
203
|
+
color: var(--body-text, inherit);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
button.btn-medium.toggle-button {
|
|
207
|
+
flex-shrink: 0;
|
|
208
|
+
font-size: 16px;
|
|
209
|
+
color: var(--body-text, inherit);
|
|
210
|
+
padding: 0;
|
|
211
|
+
min-height: initial;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.right-wrapper {
|
|
215
|
+
display: flex;
|
|
216
|
+
flex-direction: row;
|
|
217
|
+
justify-content: flex-end;
|
|
218
|
+
align-items: center;
|
|
219
|
+
gap: 24px;
|
|
220
|
+
margin-left: auto;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
.status-badges {
|
|
224
|
+
display: flex;
|
|
225
|
+
flex-direction: row;
|
|
226
|
+
align-items: center;
|
|
227
|
+
gap: 12px;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
.actions {
|
|
231
|
+
display: flex;
|
|
232
|
+
flex-direction: row;
|
|
233
|
+
align-items: center;
|
|
234
|
+
gap: 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.section-content {
|
|
238
|
+
display: flex;
|
|
239
|
+
flex-direction: column;
|
|
240
|
+
gap: 24px;
|
|
241
|
+
padding: 0 0 16px;
|
|
242
|
+
color: var(--body-text);
|
|
243
|
+
|
|
244
|
+
&.expandable-content {
|
|
245
|
+
padding: 0 0 16px 24px;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
&.no-header {
|
|
249
|
+
padding: 16px 0;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
</style>
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils';
|
|
2
|
+
import RcSectionActions from './RcSectionActions.vue';
|
|
3
|
+
import type { ActionConfig } from './types';
|
|
4
|
+
|
|
5
|
+
// Stubs for child components to isolate unit tests
|
|
6
|
+
const RcButtonStub = {
|
|
7
|
+
name: 'RcButton',
|
|
8
|
+
props: ['variant', 'size', 'leftIcon', 'ariaLabel'],
|
|
9
|
+
emits: ['click'],
|
|
10
|
+
template: '<button class="rc-button" :data-variant="variant" :data-left-icon="leftIcon" :aria-label="ariaLabel" @click="$emit(\'click\', $event)"><slot /></button>',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const RcDropdownStub = {
|
|
14
|
+
name: 'RcDropdown',
|
|
15
|
+
template: '<div class="rc-dropdown"><slot /><slot name="dropdownCollection" /></div>',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const RcDropdownTriggerStub = {
|
|
19
|
+
name: 'RcDropdownTrigger',
|
|
20
|
+
template: '<button class="rc-dropdown-trigger"><slot /></button>',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const RcDropdownItemStub = {
|
|
24
|
+
name: 'RcDropdownItem',
|
|
25
|
+
props: ['ariaLabel'],
|
|
26
|
+
emits: ['click'],
|
|
27
|
+
template: '<div class="rc-dropdown-item" :aria-label="ariaLabel" @click="$emit(\'click\', $event)"><slot name="before" /><slot /></div>',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const RcIconStub = {
|
|
31
|
+
name: 'RcIcon',
|
|
32
|
+
props: ['type', 'size'],
|
|
33
|
+
template: '<span class="rc-icon" :data-type="type" />',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const globalStubs = {
|
|
37
|
+
RcButton: RcButtonStub,
|
|
38
|
+
RcDropdown: RcDropdownStub,
|
|
39
|
+
RcDropdownTrigger: RcDropdownTriggerStub,
|
|
40
|
+
RcDropdownItem: RcDropdownItemStub,
|
|
41
|
+
RcIcon: RcIconStub,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
describe('component: RcSectionActions', () => {
|
|
45
|
+
function createWrapper(actions: ActionConfig[]) {
|
|
46
|
+
return mount(RcSectionActions, {
|
|
47
|
+
props: { actions },
|
|
48
|
+
global: { stubs: globalStubs },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('with fewer than 3 actions', () => {
|
|
53
|
+
it('should render all actions as primary buttons when 1 action is provided', () => {
|
|
54
|
+
const action = jest.fn();
|
|
55
|
+
const wrapper = createWrapper([{ label: 'Edit', action }]);
|
|
56
|
+
|
|
57
|
+
const buttons = wrapper.findAll('.rc-button');
|
|
58
|
+
|
|
59
|
+
expect(buttons).toHaveLength(1);
|
|
60
|
+
expect(buttons[0].text()).toContain('Edit');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should render all actions as primary buttons when 2 actions are provided', () => {
|
|
64
|
+
const wrapper = createWrapper([
|
|
65
|
+
{ label: 'Edit', action: jest.fn() },
|
|
66
|
+
{ label: 'Delete', action: jest.fn() },
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
expect(wrapper.findAll('.rc-button')).toHaveLength(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should not render the overflow dropdown when fewer than 3 actions', () => {
|
|
73
|
+
const wrapper = createWrapper([
|
|
74
|
+
{ label: 'Edit', action: jest.fn() },
|
|
75
|
+
{ label: 'Delete', action: jest.fn() },
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
expect(wrapper.find('.rc-dropdown').exists()).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('with 3 or more actions', () => {
|
|
83
|
+
it('should render only the first 2 actions as primary buttons', () => {
|
|
84
|
+
const wrapper = createWrapper([
|
|
85
|
+
{ label: 'A', action: jest.fn() },
|
|
86
|
+
{ label: 'B', action: jest.fn() },
|
|
87
|
+
{ label: 'C', action: jest.fn() },
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
const buttons = wrapper.findAll('.rc-button');
|
|
91
|
+
|
|
92
|
+
expect(buttons).toHaveLength(2);
|
|
93
|
+
expect(buttons[0].text()).toContain('A');
|
|
94
|
+
expect(buttons[1].text()).toContain('B');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should render the overflow dropdown', () => {
|
|
98
|
+
const wrapper = createWrapper([
|
|
99
|
+
{ label: 'A', action: jest.fn() },
|
|
100
|
+
{ label: 'B', action: jest.fn() },
|
|
101
|
+
{ label: 'C', action: jest.fn() },
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
expect(wrapper.find('.rc-dropdown').exists()).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should place remaining actions in the overflow dropdown', () => {
|
|
108
|
+
const wrapper = createWrapper([
|
|
109
|
+
{ label: 'A', action: jest.fn() },
|
|
110
|
+
{ label: 'B', action: jest.fn() },
|
|
111
|
+
{ label: 'C', action: jest.fn() },
|
|
112
|
+
{ label: 'D', action: jest.fn() },
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const dropdownItems = wrapper.findAll('.rc-dropdown-item');
|
|
116
|
+
|
|
117
|
+
expect(dropdownItems).toHaveLength(2);
|
|
118
|
+
expect(dropdownItems[0].text()).toContain('C');
|
|
119
|
+
expect(dropdownItems[1].text()).toContain('D');
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe('action callbacks', () => {
|
|
124
|
+
it('should invoke the action callback when a primary button is clicked', async() => {
|
|
125
|
+
const action = jest.fn();
|
|
126
|
+
const wrapper = createWrapper([{ label: 'Edit', action }]);
|
|
127
|
+
|
|
128
|
+
await wrapper.find('.rc-button').trigger('click');
|
|
129
|
+
|
|
130
|
+
expect(action).toHaveBeenCalledTimes(1);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should invoke the correct action callback for overflow items', async() => {
|
|
134
|
+
const actionC = jest.fn();
|
|
135
|
+
const wrapper = createWrapper([
|
|
136
|
+
{ label: 'A', action: jest.fn() },
|
|
137
|
+
{ label: 'B', action: jest.fn() },
|
|
138
|
+
{ label: 'C', action: actionC },
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
await wrapper.find('.rc-dropdown-item').trigger('click');
|
|
142
|
+
|
|
143
|
+
expect(actionC).toHaveBeenCalledTimes(1);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe('variant resolution', () => {
|
|
148
|
+
it('should use "link" variant for actions with a label', () => {
|
|
149
|
+
const wrapper = createWrapper([{ label: 'Edit', action: jest.fn() }]);
|
|
150
|
+
|
|
151
|
+
expect(wrapper.find('.rc-button').attributes('data-variant')).toBe('link');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should use "link" variant for icon-only actions', () => {
|
|
155
|
+
const wrapper = createWrapper([{ icon: 'copy', action: jest.fn() }]);
|
|
156
|
+
|
|
157
|
+
expect(wrapper.find('.rc-button').attributes('data-variant')).toBe('link');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should render icon inside button for icon-only actions', () => {
|
|
161
|
+
const wrapper = createWrapper([{ icon: 'copy', action: jest.fn() }]);
|
|
162
|
+
|
|
163
|
+
expect(wrapper.find('.rc-button .rc-icon').exists()).toBe(true);
|
|
164
|
+
expect(wrapper.find('.rc-button .rc-icon').attributes('data-type')).toBe('copy');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('should use left-icon for labeled actions with an icon', () => {
|
|
168
|
+
const wrapper = createWrapper([{
|
|
169
|
+
label: 'Edit', icon: 'edit', action: jest.fn()
|
|
170
|
+
}]);
|
|
171
|
+
|
|
172
|
+
expect(wrapper.find('.rc-button').attributes('data-left-icon')).toBe('edit');
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe('ariaLabel', () => {
|
|
177
|
+
it('should set aria-label on a primary button when ariaLabel is provided', () => {
|
|
178
|
+
const wrapper = createWrapper([{
|
|
179
|
+
icon: 'copy', ariaLabel: 'Copy item', action: jest.fn()
|
|
180
|
+
}]);
|
|
181
|
+
|
|
182
|
+
expect(wrapper.find('.rc-button').attributes('aria-label')).toBe('Copy item');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should not set aria-label on a primary button when ariaLabel is omitted', () => {
|
|
186
|
+
const wrapper = createWrapper([{ label: 'Edit', action: jest.fn() }]);
|
|
187
|
+
|
|
188
|
+
expect(wrapper.find('.rc-button').attributes('aria-label')).toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should set aria-label on overflow dropdown items when ariaLabel is provided', () => {
|
|
192
|
+
const wrapper = createWrapper([
|
|
193
|
+
{ label: 'A', action: jest.fn() },
|
|
194
|
+
{ label: 'B', action: jest.fn() },
|
|
195
|
+
{
|
|
196
|
+
label: 'C', ariaLabel: 'Do C action', action: jest.fn()
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
expect(wrapper.find('.rc-dropdown-item').attributes('aria-label')).toBe('Do C action');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('empty actions', () => {
|
|
205
|
+
it('should render nothing when actions array is empty', () => {
|
|
206
|
+
const wrapper = createWrapper([]);
|
|
207
|
+
|
|
208
|
+
expect(wrapper.findAll('.rc-button')).toHaveLength(0);
|
|
209
|
+
expect(wrapper.find('.rc-dropdown').exists()).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from 'vue';
|
|
3
|
+
import RcButton from '@components/RcButton/RcButton.vue';
|
|
4
|
+
import RcDropdown from '@components/RcDropdown/RcDropdown.vue';
|
|
5
|
+
import RcDropdownTrigger from '@components/RcDropdown/RcDropdownTrigger.vue';
|
|
6
|
+
import RcDropdownItem from '@components/RcDropdown/RcDropdownItem.vue';
|
|
7
|
+
import RcIcon from '@components/RcIcon/RcIcon.vue';
|
|
8
|
+
|
|
9
|
+
import type { RcSectionActionsProps } from './types';
|
|
10
|
+
|
|
11
|
+
const props = defineProps<RcSectionActionsProps>();
|
|
12
|
+
|
|
13
|
+
const primaryActions = computed(() => (props.actions.length < 3 ? props.actions : props.actions.slice(0, 2)));
|
|
14
|
+
|
|
15
|
+
const overflowActions = computed(() => (props.actions.length < 3 ? [] : props.actions.slice(2)));
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<RcButton
|
|
20
|
+
v-for="(action, index) in primaryActions"
|
|
21
|
+
:key="index"
|
|
22
|
+
:class="{ 'icon-action': !action.label }"
|
|
23
|
+
variant="link"
|
|
24
|
+
size="medium"
|
|
25
|
+
:left-icon="action.label && action.icon ? (action.icon as any) : undefined"
|
|
26
|
+
:aria-label="action.ariaLabel"
|
|
27
|
+
@click.stop="action.action"
|
|
28
|
+
>
|
|
29
|
+
<RcIcon
|
|
30
|
+
v-if="!action.label && action.icon"
|
|
31
|
+
:type="action.icon as any"
|
|
32
|
+
size="medium"
|
|
33
|
+
/>
|
|
34
|
+
<template v-if="action.label">
|
|
35
|
+
{{ action.label }}
|
|
36
|
+
</template>
|
|
37
|
+
</RcButton>
|
|
38
|
+
|
|
39
|
+
<RcDropdown
|
|
40
|
+
v-if="overflowActions.length"
|
|
41
|
+
placement="bottom-end"
|
|
42
|
+
@click.stop
|
|
43
|
+
>
|
|
44
|
+
<RcDropdownTrigger
|
|
45
|
+
class="icon-action"
|
|
46
|
+
variant="link"
|
|
47
|
+
size="medium"
|
|
48
|
+
aria-label="More actions"
|
|
49
|
+
>
|
|
50
|
+
<RcIcon
|
|
51
|
+
type="actions"
|
|
52
|
+
size="medium"
|
|
53
|
+
/>
|
|
54
|
+
</RcDropdownTrigger>
|
|
55
|
+
|
|
56
|
+
<template #dropdownCollection>
|
|
57
|
+
<RcDropdownItem
|
|
58
|
+
v-for="(action, index) in overflowActions"
|
|
59
|
+
:key="index"
|
|
60
|
+
:aria-label="action.ariaLabel"
|
|
61
|
+
@click.stop="action.action"
|
|
62
|
+
>
|
|
63
|
+
<template
|
|
64
|
+
v-if="action.icon"
|
|
65
|
+
#before
|
|
66
|
+
>
|
|
67
|
+
<RcIcon
|
|
68
|
+
:type="action.icon as any"
|
|
69
|
+
size="small"
|
|
70
|
+
/>
|
|
71
|
+
</template>
|
|
72
|
+
{{ action.label }}
|
|
73
|
+
</RcDropdownItem>
|
|
74
|
+
</template>
|
|
75
|
+
</RcDropdown>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<style lang="scss" scoped>
|
|
79
|
+
|
|
80
|
+
.rc-button.btn-medium.variant-link {
|
|
81
|
+
&, &:hover {
|
|
82
|
+
color: var(--rc-section-action-color);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
</style>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils';
|
|
2
|
+
import RcSectionBadges from './RcSectionBadges.vue';
|
|
3
|
+
|
|
4
|
+
// Stub RcStatusBadge to avoid pulling in its full dependency tree
|
|
5
|
+
const RcStatusBadgeStub = {
|
|
6
|
+
name: 'RcStatusBadge',
|
|
7
|
+
props: ['status'],
|
|
8
|
+
template: '<span class="rc-status-badge" :data-status="status"><slot /></span>',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe('component: RcSectionBadges', () => {
|
|
12
|
+
function createWrapper(badges: { label: string; status: string; tooltip?: string }[]) {
|
|
13
|
+
return mount(RcSectionBadges, {
|
|
14
|
+
props: { badges },
|
|
15
|
+
global: {
|
|
16
|
+
stubs: { RcStatusBadge: RcStatusBadgeStub },
|
|
17
|
+
directives: { 'clean-tooltip': () => {} },
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
it('should render all badges when count is within the limit', () => {
|
|
23
|
+
const badges = [
|
|
24
|
+
{ label: 'Active', status: 'success' },
|
|
25
|
+
{ label: 'Pending', status: 'warning' },
|
|
26
|
+
];
|
|
27
|
+
const wrapper = createWrapper(badges);
|
|
28
|
+
const rendered = wrapper.findAll('.rc-status-badge');
|
|
29
|
+
|
|
30
|
+
expect(rendered).toHaveLength(2);
|
|
31
|
+
expect(rendered[0].text()).toContain('Active');
|
|
32
|
+
expect(rendered[1].text()).toContain('Pending');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should render exactly 3 badges when 3 are provided', () => {
|
|
36
|
+
const badges = [
|
|
37
|
+
{ label: 'A', status: 'success' },
|
|
38
|
+
{ label: 'B', status: 'warning' },
|
|
39
|
+
{ label: 'C', status: 'error' },
|
|
40
|
+
];
|
|
41
|
+
const wrapper = createWrapper(badges);
|
|
42
|
+
|
|
43
|
+
expect(wrapper.findAll('.rc-status-badge')).toHaveLength(3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should render a maximum of 3 badges when more than 3 are provided', () => {
|
|
47
|
+
const badges = [
|
|
48
|
+
{ label: 'A', status: 'success' },
|
|
49
|
+
{ label: 'B', status: 'warning' },
|
|
50
|
+
{ label: 'C', status: 'error' },
|
|
51
|
+
{ label: 'D', status: 'info' },
|
|
52
|
+
];
|
|
53
|
+
const wrapper = createWrapper(badges);
|
|
54
|
+
const rendered = wrapper.findAll('.rc-status-badge');
|
|
55
|
+
|
|
56
|
+
expect(rendered).toHaveLength(3);
|
|
57
|
+
expect(rendered[0].text()).toContain('A');
|
|
58
|
+
expect(rendered[1].text()).toContain('B');
|
|
59
|
+
expect(rendered[2].text()).toContain('C');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('should pass the correct status prop to each badge', () => {
|
|
63
|
+
const badges = [
|
|
64
|
+
{ label: 'A', status: 'success' },
|
|
65
|
+
{ label: 'B', status: 'error' },
|
|
66
|
+
];
|
|
67
|
+
const wrapper = createWrapper(badges);
|
|
68
|
+
const rendered = wrapper.findAll('.rc-status-badge');
|
|
69
|
+
|
|
70
|
+
expect(rendered[0].attributes('data-status')).toBe('success');
|
|
71
|
+
expect(rendered[1].attributes('data-status')).toBe('error');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should render nothing when badges array is empty', () => {
|
|
75
|
+
const wrapper = createWrapper([]);
|
|
76
|
+
|
|
77
|
+
expect(wrapper.findAll('.rc-status-badge')).toHaveLength(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('should render a single badge', () => {
|
|
81
|
+
const wrapper = createWrapper([{ label: 'Only', status: 'info' }]);
|
|
82
|
+
|
|
83
|
+
expect(wrapper.findAll('.rc-status-badge')).toHaveLength(1);
|
|
84
|
+
expect(wrapper.find('.rc-status-badge').text()).toContain('Only');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('tooltip', () => {
|
|
88
|
+
it('should pass tooltip value to v-clean-tooltip directive', () => {
|
|
89
|
+
const badges = [
|
|
90
|
+
{
|
|
91
|
+
label: 'Active', status: 'success', tooltip: 'All systems operational'
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
const wrapper = createWrapper(badges);
|
|
95
|
+
|
|
96
|
+
// The directive is registered; if the template used an unknown directive it would throw.
|
|
97
|
+
// Verify the badge still renders correctly when tooltip is provided.
|
|
98
|
+
expect(wrapper.findAll('.rc-status-badge')).toHaveLength(1);
|
|
99
|
+
expect(wrapper.find('.rc-status-badge').text()).toContain('Active');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should render badges without tooltip when tooltip is omitted', () => {
|
|
103
|
+
const badges = [
|
|
104
|
+
{ label: 'Pending', status: 'warning' },
|
|
105
|
+
];
|
|
106
|
+
const wrapper = createWrapper(badges);
|
|
107
|
+
|
|
108
|
+
expect(wrapper.findAll('.rc-status-badge')).toHaveLength(1);
|
|
109
|
+
expect(wrapper.find('.rc-status-badge').text()).toContain('Pending');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('console warning', () => {
|
|
114
|
+
it('should warn when more than 3 badges are provided', () => {
|
|
115
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
116
|
+
const badges = [
|
|
117
|
+
{ label: 'A', status: 'success' },
|
|
118
|
+
{ label: 'B', status: 'warning' },
|
|
119
|
+
{ label: 'C', status: 'error' },
|
|
120
|
+
{ label: 'D', status: 'info' },
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
createWrapper(badges);
|
|
124
|
+
|
|
125
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
126
|
+
expect.stringContaining('[RcSectionBadges]: Received 4 badges but only 3 are allowed')
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
warnSpy.mockRestore();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('should not warn when 3 or fewer badges are provided', () => {
|
|
133
|
+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
|
134
|
+
const badges = [
|
|
135
|
+
{ label: 'A', status: 'success' },
|
|
136
|
+
{ label: 'B', status: 'warning' },
|
|
137
|
+
{ label: 'C', status: 'error' },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
createWrapper(badges);
|
|
141
|
+
|
|
142
|
+
expect(warnSpy).not.toHaveBeenCalledWith(
|
|
143
|
+
expect.stringContaining('[RcSectionBadges]')
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
warnSpy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
});
|