@rancher/shell 3.0.8 → 3.0.9-rc.2
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/apis/intf/modal.ts +38 -0
- package/apis/intf/slide-in.ts +3 -1
- package/apis/shell/__tests__/slide-in.test.ts +36 -0
- package/apis/shell/slide-in.ts +5 -1
- package/assets/styles/base/_color.scss +1 -0
- package/assets/styles/base/_typography.scss +14 -5
- package/assets/styles/themes/_light.scss +1 -1
- package/assets/styles/themes/_modern.scss +1 -1
- package/assets/translations/en-us.yaml +94 -33
- package/assets/translations/zh-hans.yaml +0 -2
- package/components/ActionMenuShell.vue +4 -4
- package/components/CodeMirror.vue +4 -3
- package/components/DetailText.vue +54 -7
- package/components/Drawer/Chrome.vue +11 -4
- package/components/Drawer/DrawerCard.vue +19 -0
- package/components/Drawer/ResourceDetailDrawer/ConfigTab.vue +3 -11
- package/components/Drawer/ResourceDetailDrawer/__tests__/ConfigTab.test.ts +2 -2
- package/components/Drawer/ResourceDetailDrawer/index.vue +3 -20
- package/components/Drawer/types.ts +1 -0
- package/components/DynamicContent/DynamicContentCloseButton.vue +2 -2
- package/components/LocaleSelector.vue +1 -1
- package/components/Markdown.vue +1 -1
- package/components/PopoverCard.vue +3 -3
- package/components/Resource/Detail/Card/ExtrasCard.vue +39 -0
- package/components/Resource/Detail/Card/StateCard/__tests__/composables.test.ts +142 -0
- package/components/Resource/Detail/Card/StateCard/composables.ts +41 -11
- package/components/Resource/Detail/Card/StateCard/index.vue +3 -9
- package/components/Resource/Detail/Card/StateCard/types.ts +6 -0
- package/components/Resource/Detail/Card/{PodsCard → StatusCard}/index.vue +11 -10
- package/components/Resource/Detail/Card/__tests__/PodsCard.test.ts +24 -25
- package/components/Resource/Detail/Cards.vue +27 -0
- package/components/Resource/Detail/Masthead/__tests__/index.test.ts +70 -0
- package/components/Resource/Detail/Masthead/index.vue +5 -0
- package/components/Resource/Detail/Metadata/KeyValueRow.vue +4 -2
- package/components/Resource/Detail/ResourcePopover/ResourcePopoverCard.vue +2 -2
- package/components/Resource/Detail/ResourceRow.types.ts +14 -0
- package/components/Resource/Detail/ResourceRow.vue +23 -35
- package/components/Resource/Detail/StatusRow.vue +5 -2
- package/components/Resource/Detail/TitleBar/__tests__/composables.test.ts +38 -7
- package/components/Resource/Detail/TitleBar/__tests__/index.test.ts +106 -2
- package/components/Resource/Detail/TitleBar/composables.ts +2 -1
- package/components/Resource/Detail/TitleBar/index.vue +41 -6
- package/components/ResourceDetail/Masthead/__tests__/index.test.ts +49 -1
- package/components/ResourceDetail/Masthead/__tests__/latest.test.ts +85 -0
- package/components/ResourceDetail/Masthead/index.vue +1 -0
- package/components/ResourceDetail/Masthead/latest.vue +8 -1
- package/components/ResourceDetail/Masthead/legacy.vue +1 -1
- package/components/Setting.vue +1 -1
- package/components/SortableTable/index.vue +25 -0
- package/components/SortableTable/selection.js +25 -12
- package/components/SortableTable/sorting.js +1 -1
- package/components/Tabbed/Tab.vue +1 -0
- package/components/Tabbed/index.vue +29 -6
- package/components/Window/ContainerShell.vue +10 -13
- package/components/fleet/FleetClusterTargets/TargetsList.vue +47 -29
- package/components/fleet/FleetClusterTargets/index.vue +82 -29
- package/components/fleet/FleetClusters.vue +26 -12
- package/components/fleet/FleetGitRepoPaths.vue +2 -2
- package/components/fleet/FleetResources.vue +14 -0
- package/components/fleet/FleetValuesFrom.vue +2 -2
- package/components/fleet/__tests__/FleetClusterTargets.test.ts +531 -0
- package/components/fleet/__tests__/FleetClusters.test.ts +576 -0
- package/components/fleet/dashboard/ResourceDetails.vue +96 -123
- package/components/form/Conditions.vue +1 -15
- package/components/form/HookOption.vue +5 -0
- package/components/form/LabeledSelect.vue +1 -1
- package/components/form/LifecycleHooks.vue +2 -6
- package/components/form/ResourceLabeledSelect.vue +12 -1
- package/components/form/SeccompProfile.vue +113 -0
- package/components/form/Security.vue +244 -133
- package/components/form/__tests__/LabeledSelect.test.ts +1 -1
- package/components/form/__tests__/SeccompProfile.test.js +124 -0
- package/components/form/__tests__/Security.test.ts +125 -37
- package/components/formatter/Autoscaler.vue +2 -2
- package/components/formatter/FleetSummaryGraph.vue +4 -1
- package/components/nav/Group.vue +5 -0
- package/components/nav/Header.vue +3 -3
- package/components/nav/HeaderPageActionMenu.vue +1 -1
- package/components/nav/NamespaceFilter.vue +6 -6
- package/components/nav/NotificationCenter/index.vue +1 -1
- package/components/nav/TopLevelMenu.helper.ts +41 -16
- package/components/nav/TopLevelMenu.vue +45 -25
- package/components/nav/WorkspaceSwitcher.vue +1 -1
- package/components/nav/__tests__/TopLevelMenu.helper.test.ts +277 -0
- package/components/nav/__tests__/TopLevelMenu.test.ts +160 -4
- package/components/templates/default.vue +0 -3
- package/components/templates/home.vue +0 -3
- package/components/templates/plain.vue +0 -3
- package/composables/useClickOutside.ts +1 -1
- package/config/product/explorer.js +1 -2
- package/config/types.js +41 -8
- package/detail/__tests__/workload.test.ts +8 -16
- package/detail/catalog.cattle.io.app.vue +6 -0
- package/detail/fleet.cattle.io.cluster.vue +6 -0
- package/detail/workload/index.vue +7 -109
- package/edit/__tests__/projectsecret.test.ts +42 -0
- package/edit/auth/__tests__/oidc.test.ts +50 -0
- package/edit/auth/oidc.vue +68 -44
- package/edit/autoscaling.horizontalpodautoscaler/index.vue +140 -59
- package/edit/autoscaling.horizontalpodautoscaler/metrics-row.vue +41 -5
- package/edit/projectsecret.vue +29 -0
- package/edit/provisioning.cattle.io.cluster/__tests__/Basics.test.ts +89 -200
- package/edit/provisioning.cattle.io.cluster/__tests__/Networking.test.ts +58 -17
- package/edit/provisioning.cattle.io.cluster/rke2.vue +11 -0
- package/edit/provisioning.cattle.io.cluster/tabs/Basics.vue +3 -63
- package/edit/provisioning.cattle.io.cluster/tabs/networking/index.vue +82 -14
- package/edit/workload/__tests__/index.test.ts +122 -85
- package/edit/workload/index.vue +48 -29
- package/edit/workload/mixins/workload.js +85 -32
- package/list/catalog.cattle.io.clusterrepo.vue +1 -1
- package/list/projectsecret.vue +2 -2
- package/machine-config/__tests__/vmwarevsphere.test.ts +64 -0
- package/machine-config/amazonec2.vue +2 -2
- package/machine-config/vmwarevsphere.vue +58 -4
- package/mixins/__tests__/brand.spec.ts +18 -13
- package/mixins/__tests__/chart.test.ts +63 -0
- package/mixins/chart.js +56 -51
- package/models/__tests__/catalog.cattle.io.app.test.ts +33 -0
- package/models/__tests__/workload.test.ts +333 -0
- package/models/catalog.cattle.io.app.js +8 -0
- package/models/pod.js +14 -0
- package/models/secret.js +1 -1
- package/models/workload.js +93 -27
- package/package.json +4 -4
- package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +91 -0
- package/pages/c/_cluster/apps/charts/install.vue +4 -4
- package/pages/c/_cluster/explorer/EventsTable.vue +2 -2
- package/pages/c/_cluster/fleet/index.vue +18 -12
- package/pages/c/_cluster/manager/hostedprovider/index.vue +1 -19
- package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +1 -1
- package/pages/c/_cluster/uiplugins/index.vue +1 -1
- package/plugins/dashboard-store/__tests__/resource-class.test.ts +234 -0
- package/plugins/dashboard-store/actions.js +9 -8
- package/plugins/dashboard-store/resource-class.js +97 -1
- package/plugins/steve/__tests__/revision.test.ts +84 -0
- package/plugins/steve/__tests__/steve-pagination-utils.test.ts +30 -0
- package/plugins/steve/__tests__/subscribe.spec.ts +134 -0
- package/plugins/steve/mutations.js +9 -0
- package/plugins/steve/revision.ts +26 -0
- package/plugins/steve/steve-pagination-utils.ts +6 -5
- package/plugins/steve/subscribe.js +211 -51
- package/plugins/subscribe-events.ts +2 -2
- package/rancher-components/Form/Checkbox/Checkbox.vue +13 -0
- package/rancher-components/LabeledTooltip/LabeledTooltip.vue +1 -1
- package/rancher-components/Pill/RcCounterBadge/RcCounterBadge.vue +1 -1
- package/rancher-components/Pill/RcStatusBadge/RcStatusBadge.vue +3 -1
- package/rancher-components/Pill/RcStatusIndicator/RcStatusIndicator.vue +3 -1
- package/rancher-components/Pill/RcTag/RcTag.vue +1 -1
- package/rancher-components/Pill/index.ts +4 -0
- package/rancher-components/RcButton/RcButton.test.ts +53 -9
- package/rancher-components/RcButton/RcButton.vue +217 -25
- package/rancher-components/RcButton/types.ts +27 -1
- package/rancher-components/RcDropdown/RcDropdownMenu.vue +4 -4
- package/rancher-components/RcDropdown/types.ts +3 -3
- package/rancher-components/RcIcon/RcIcon.test.ts +42 -0
- package/rancher-components/RcIcon/RcIcon.vue +9 -6
- package/rancher-components/RcIcon/types.ts +13 -9
- package/rancher-components/utils/status.test.ts +10 -15
- package/rancher-components/utils/status.ts +5 -6
- package/store/aws.js +18 -12
- package/store/index.js +4 -8
- package/store/type-map.utils.ts +1 -1
- package/types/kube/kube-api.ts +29 -3
- package/types/rancher/steve.api.ts +40 -0
- package/types/shell/index.d.ts +99 -0
- package/types/store/dashboard-store.types.ts +29 -7
- package/types/store/pagination.types.ts +1 -0
- package/types/store/subscribe-events.types.ts +1 -0
- package/utils/__tests__/azure.test.ts +56 -0
- package/utils/__tests__/back-off.test.ts +364 -245
- package/utils/__tests__/error.test.ts +44 -0
- package/utils/__tests__/fleet.test.ts +8 -1
- package/utils/__tests__/pagination-wrapper.test.ts +167 -0
- package/utils/__tests__/version.test.ts +55 -1
- package/utils/azure.js +12 -0
- package/utils/back-off.ts +302 -69
- package/utils/cspAdaptor.ts +32 -14
- package/utils/dynamic-content/__tests__/index.test.ts +1 -1
- package/utils/dynamic-content/__tests__/new-release.test.ts +48 -7
- package/utils/dynamic-content/__tests__/support-notice.test.ts +1 -4
- package/utils/dynamic-content/index.ts +1 -6
- package/utils/dynamic-content/new-release.ts +5 -3
- package/utils/dynamic-content/types.d.ts +0 -1
- package/utils/error.js +9 -0
- package/utils/fleet.ts +2 -2
- package/utils/inactivity.ts +2 -3
- package/utils/pagination-wrapper.ts +101 -17
- package/utils/validators/formRules/index.ts +3 -0
- package/utils/version.js +38 -0
- package/components/auth/AzureWarning.vue +0 -77
- /package/components/Resource/Detail/{Card/PodsCard/Bubble.vue → Bubble.vue} +0 -0
- /package/components/Resource/Detail/Card/{PodsCard → StatusCard}/composable.ts +0 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import TitleBar, { TitleBarProps } from '@shell/components/Resource/Detail/TitleBar/index.vue';
|
|
3
3
|
import Metadata, { MetadataProps } from '@shell/components/Resource/Detail/Metadata/index.vue';
|
|
4
|
+
import Cards from '@shell/components/Resource/Detail/Cards.vue';
|
|
4
5
|
|
|
5
6
|
export interface MastheadProps {
|
|
6
7
|
titleBarProps: TitleBarProps;
|
|
@@ -25,6 +26,10 @@ const props = defineProps<MastheadProps>();
|
|
|
25
26
|
<Metadata
|
|
26
27
|
v-bind="props.metadataProps"
|
|
27
28
|
/>
|
|
29
|
+
<Cards
|
|
30
|
+
class="mb-20"
|
|
31
|
+
:resource="props.titleBarProps.resource"
|
|
32
|
+
/>
|
|
28
33
|
</div>
|
|
29
34
|
</template>
|
|
30
35
|
|
|
@@ -43,7 +43,7 @@ const previewId = randomStr();
|
|
|
43
43
|
>
|
|
44
44
|
<RcButton
|
|
45
45
|
ref="button"
|
|
46
|
-
ghost
|
|
46
|
+
variant="ghost"
|
|
47
47
|
aria-haspopup="dialog"
|
|
48
48
|
:aria-expanded="showPreview"
|
|
49
49
|
:aria-controls="previewId"
|
|
@@ -103,9 +103,11 @@ const previewId = randomStr();
|
|
|
103
103
|
line-height: normal;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
& .btn {
|
|
106
|
+
& .btn.btn-medium.rc-button.variant-ghost {
|
|
107
107
|
line-height: initial;
|
|
108
108
|
min-height: initial;
|
|
109
|
+
|
|
110
|
+
padding: 0;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
&.active {
|
|
@@ -81,7 +81,7 @@ const getGlanceItemValueId = (glanceItem: any): string => `value-${ glanceItem.l
|
|
|
81
81
|
}
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
.v-popper, .btn.
|
|
84
|
+
.v-popper, .btn.variant-link.rc-button {
|
|
85
85
|
height: 24px;
|
|
86
86
|
min-height: initial;
|
|
87
87
|
padding: 0;
|
|
@@ -91,7 +91,7 @@ const getGlanceItemValueId = (glanceItem: any): string => `value-${ glanceItem.l
|
|
|
91
91
|
padding: 0;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
.btn.
|
|
94
|
+
.btn.variant-link.rc-button.variant-ghost {
|
|
95
95
|
color: #141419;
|
|
96
96
|
padding: 0 12px;
|
|
97
97
|
i {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { StateColor } from '@shell/utils/style';
|
|
2
|
+
import { RouteLocationRaw } from 'vue-router';
|
|
3
|
+
|
|
4
|
+
export interface Count {
|
|
5
|
+
label: string;
|
|
6
|
+
count: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Props {
|
|
10
|
+
label: string;
|
|
11
|
+
to?: RouteLocationRaw;
|
|
12
|
+
color?: StateColor;
|
|
13
|
+
counts?: Count[];
|
|
14
|
+
}
|
|
@@ -1,41 +1,11 @@
|
|
|
1
|
-
<script lang="ts">
|
|
1
|
+
<script setup lang="ts">
|
|
2
2
|
import SubtleLink from '@shell/components/SubtleLink.vue';
|
|
3
3
|
import StateDot from '@shell/components/StateDot/index.vue';
|
|
4
|
-
import {
|
|
5
|
-
import { sortBy, sumBy } from 'lodash';
|
|
6
|
-
import { RouteLocationRaw } from 'vue-router';
|
|
4
|
+
import { sumBy } from 'lodash';
|
|
7
5
|
import { computed } from 'vue';
|
|
8
6
|
import { useI18n } from '@shell/composables/useI18n';
|
|
9
7
|
import { useStore } from 'vuex';
|
|
10
|
-
|
|
11
|
-
export interface Count {
|
|
12
|
-
label: string;
|
|
13
|
-
count: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface Props {
|
|
17
|
-
label: string;
|
|
18
|
-
to?: RouteLocationRaw;
|
|
19
|
-
color?: StateColor;
|
|
20
|
-
counts?: Count[];
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function extractCounts(labels: string[]): Count[] {
|
|
24
|
-
const accumulator: { [k: string]: number} = {};
|
|
25
|
-
|
|
26
|
-
labels.forEach((l: string) => {
|
|
27
|
-
accumulator[l] = accumulator[l] || 0;
|
|
28
|
-
accumulator[l]++;
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
const counts: Count[] = Object.entries(accumulator).map(([label, count]) => ({ label, count }));
|
|
32
|
-
|
|
33
|
-
return sortBy(counts, 'label');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
</script>
|
|
37
|
-
|
|
38
|
-
<script setup lang="ts">
|
|
8
|
+
import { Props } from './ResourceRow.types';
|
|
39
9
|
const {
|
|
40
10
|
label, to, counts, color
|
|
41
11
|
} = defineProps<Props>();
|
|
@@ -102,7 +72,7 @@ const displayCounts = computed(() => {
|
|
|
102
72
|
:key="count.label"
|
|
103
73
|
class="count"
|
|
104
74
|
>
|
|
105
|
-
{{ count.count }} {{ count.label }}
|
|
75
|
+
<span class="count-value">{{ count.count }}</span> <span class="count-label">{{ count.label }}</span><span class="and"> + </span>
|
|
106
76
|
</span>
|
|
107
77
|
</div>
|
|
108
78
|
</div>
|
|
@@ -118,13 +88,31 @@ const displayCounts = computed(() => {
|
|
|
118
88
|
.right {
|
|
119
89
|
flex-grow: 1;
|
|
120
90
|
text-align: right;
|
|
91
|
+
overflow: hidden;
|
|
121
92
|
}
|
|
122
93
|
|
|
123
94
|
.counts {
|
|
124
|
-
display:
|
|
95
|
+
display: flex;
|
|
125
96
|
flex-direction: row;
|
|
126
97
|
justify-content: flex-end;
|
|
127
98
|
align-items: center;
|
|
99
|
+
max-width: 100%;
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
|
|
102
|
+
.count {
|
|
103
|
+
display: flex;
|
|
104
|
+
justify-content: flex-end;
|
|
105
|
+
min-width: 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.count:not(.count + .count) {
|
|
109
|
+
max-width: calc(100% - 90px);
|
|
110
|
+
|
|
111
|
+
.count-label {
|
|
112
|
+
overflow: hidden;
|
|
113
|
+
text-overflow: ellipsis;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
128
116
|
}
|
|
129
117
|
|
|
130
118
|
.count:last-of-type .and {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import
|
|
2
|
+
import { RcCounterBadge } from '@components/Pill';
|
|
3
3
|
import { StateColor, stateColorCssVar } from '@shell/utils/style';
|
|
4
4
|
|
|
5
5
|
export interface Props {
|
|
@@ -24,7 +24,10 @@ const {
|
|
|
24
24
|
{{ label }}
|
|
25
25
|
</div>
|
|
26
26
|
<div class="count">
|
|
27
|
-
<
|
|
27
|
+
<RcCounterBadge
|
|
28
|
+
:count="count"
|
|
29
|
+
type="inactive"
|
|
30
|
+
/>
|
|
28
31
|
</div>
|
|
29
32
|
<div class="percent text-muted">
|
|
30
33
|
{{ percent.toFixed(1) }}%
|
|
@@ -16,13 +16,14 @@ jest.mock('vue-router', () => ({ useRoute: () => mockRoute }));
|
|
|
16
16
|
|
|
17
17
|
describe('composables: TitleBar', () => {
|
|
18
18
|
const resource = {
|
|
19
|
-
nameDisplay:
|
|
20
|
-
namespace:
|
|
21
|
-
type:
|
|
22
|
-
stateBackground:
|
|
23
|
-
stateDisplay:
|
|
24
|
-
description:
|
|
25
|
-
showConfiguration:
|
|
19
|
+
nameDisplay: 'RESOURCE_NAME',
|
|
20
|
+
namespace: 'RESOURCE_NAMESPACE',
|
|
21
|
+
type: 'RESOURCE_TYPE',
|
|
22
|
+
stateBackground: 'RESOURCE_STATE_BACKGROUND',
|
|
23
|
+
stateDisplay: 'RESOURCE_STATE_DISPLAY',
|
|
24
|
+
description: 'RESOURCE_DESCRIPTION',
|
|
25
|
+
showConfiguration: jest.fn(),
|
|
26
|
+
detailPageAdditionalActions: undefined as any,
|
|
26
27
|
};
|
|
27
28
|
const labelFor = 'LABEL_FOR';
|
|
28
29
|
const schema = { type: 'SCHEMA' };
|
|
@@ -54,4 +55,34 @@ describe('composables: TitleBar', () => {
|
|
|
54
55
|
props.value.onShowConfiguration?.('callback');
|
|
55
56
|
expect(resource.showConfiguration).toHaveBeenCalledTimes(1);
|
|
56
57
|
});
|
|
58
|
+
|
|
59
|
+
it('should include additionalActions from resource.detailPageAdditionalActions', async() => {
|
|
60
|
+
const additionalActions = [
|
|
61
|
+
{
|
|
62
|
+
label: 'Action 1', variant: 'secondary', onClick: jest.fn()
|
|
63
|
+
}
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
resource.detailPageAdditionalActions = additionalActions;
|
|
67
|
+
|
|
68
|
+
mockStore.getters['currentStore'].mockImplementation(() => 'cluster');
|
|
69
|
+
mockStore.getters['cluster/schemaFor'].mockImplementation(() => schema);
|
|
70
|
+
mockStore.getters['type-map/labelFor'].mockImplementation(() => labelFor);
|
|
71
|
+
|
|
72
|
+
const props = useDefaultTitleBarProps(resource, ref(undefined));
|
|
73
|
+
|
|
74
|
+
expect(props.value.additionalActions).toStrictEqual(additionalActions);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should have undefined additionalActions when resource does not define detailPageAdditionalActions', async() => {
|
|
78
|
+
resource.detailPageAdditionalActions = undefined;
|
|
79
|
+
|
|
80
|
+
mockStore.getters['currentStore'].mockImplementation(() => 'cluster');
|
|
81
|
+
mockStore.getters['cluster/schemaFor'].mockImplementation(() => schema);
|
|
82
|
+
mockStore.getters['type-map/labelFor'].mockImplementation(() => labelFor);
|
|
83
|
+
|
|
84
|
+
const props = useDefaultTitleBarProps(resource, ref(undefined));
|
|
85
|
+
|
|
86
|
+
expect(props.value.additionalActions).toBeUndefined();
|
|
87
|
+
});
|
|
57
88
|
});
|
|
@@ -2,6 +2,7 @@ import { mount, RouterLinkStub } from '@vue/test-utils';
|
|
|
2
2
|
import TitleBar from '@shell/components/Resource/Detail/TitleBar/index.vue';
|
|
3
3
|
import ActionMenu from '@shell/components/ActionMenuShell.vue';
|
|
4
4
|
import { createStore } from 'vuex';
|
|
5
|
+
import { defineComponent, h } from 'vue';
|
|
5
6
|
|
|
6
7
|
describe('component: TitleBar/index', () => {
|
|
7
8
|
const resourceTypeLabel = 'RESOURCE_TYPE_LABEL';
|
|
@@ -77,7 +78,7 @@ describe('component: TitleBar/index', () => {
|
|
|
77
78
|
const button = wrapper.find('.top > .actions > .show-configuration');
|
|
78
79
|
const buttonComponent = button.getComponent<any>('rc-button-stub');
|
|
79
80
|
|
|
80
|
-
expect(buttonComponent.props('
|
|
81
|
+
expect(buttonComponent.props('variant')).toStrictEqual('primary');
|
|
81
82
|
button.trigger('click');
|
|
82
83
|
|
|
83
84
|
expect(wrapper.emitted()).toHaveProperty('show-configuration');
|
|
@@ -107,7 +108,7 @@ describe('component: TitleBar/index', () => {
|
|
|
107
108
|
const actions = wrapper.find('.top > .actions');
|
|
108
109
|
const actionMenuComponent = actions.getComponent<any>('action-menu-stub');
|
|
109
110
|
|
|
110
|
-
expect(actionMenuComponent.props('
|
|
111
|
+
expect(actionMenuComponent.props('buttonVariant')).toStrictEqual('multiAction');
|
|
111
112
|
expect(actionMenuComponent.props('resource')).toStrictEqual(actionMenuResource);
|
|
112
113
|
});
|
|
113
114
|
|
|
@@ -137,4 +138,107 @@ describe('component: TitleBar/index', () => {
|
|
|
137
138
|
expect(wrapper.find('.bottom.description').exists()).toBeTruthy();
|
|
138
139
|
expect(wrapper.find('.bottom.description').element.innerHTML).toStrictEqual(description);
|
|
139
140
|
});
|
|
141
|
+
|
|
142
|
+
describe('additionalActions', () => {
|
|
143
|
+
it('should not render additional action buttons when additionalActions is not provided', async() => {
|
|
144
|
+
const wrapper = mount(TitleBar, {
|
|
145
|
+
props: { resourceTypeLabel, resourceName },
|
|
146
|
+
global: { stubs: { 'router-link': RouterLinkStub, RcButton: true }, provide: { store } }
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const actionButtons = wrapper.findAll('.top > .actions > rc-button-stub');
|
|
150
|
+
|
|
151
|
+
expect(actionButtons).toHaveLength(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should render buttons when additionalActions is an array of button props', async() => {
|
|
155
|
+
const onClick1 = jest.fn();
|
|
156
|
+
const onClick2 = jest.fn();
|
|
157
|
+
const additionalActions = [
|
|
158
|
+
{
|
|
159
|
+
label: 'Action 1', variant: 'secondary', onClick: onClick1
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
label: 'Action 2', variant: 'primary', size: 'large', onClick: onClick2
|
|
163
|
+
}
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const wrapper = mount(TitleBar, {
|
|
167
|
+
props: {
|
|
168
|
+
resourceTypeLabel, resourceName, additionalActions
|
|
169
|
+
},
|
|
170
|
+
global: { stubs: { 'router-link': RouterLinkStub, RcButton: true }, provide: { store } }
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const actionButtons = wrapper.findAll('.top > .actions > rc-button-stub');
|
|
174
|
+
|
|
175
|
+
expect(actionButtons).toHaveLength(2);
|
|
176
|
+
|
|
177
|
+
const button1 = actionButtons[0].getComponent<any>('rc-button-stub');
|
|
178
|
+
const button2 = actionButtons[1].getComponent<any>('rc-button-stub');
|
|
179
|
+
|
|
180
|
+
expect(button1.props('variant')).toStrictEqual('secondary');
|
|
181
|
+
expect(button2.props('variant')).toStrictEqual('primary');
|
|
182
|
+
expect(button2.props('size')).toStrictEqual('large');
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('should call onClick handler when additional action button is clicked', async() => {
|
|
186
|
+
const onClick = jest.fn();
|
|
187
|
+
const additionalActions = [
|
|
188
|
+
{
|
|
189
|
+
label: 'Action 1', variant: 'secondary', onClick
|
|
190
|
+
}
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
const wrapper = mount(TitleBar, {
|
|
194
|
+
props: {
|
|
195
|
+
resourceTypeLabel, resourceName, additionalActions
|
|
196
|
+
},
|
|
197
|
+
global: { stubs: { 'router-link': RouterLinkStub, RcButton: true }, provide: { store } }
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const actionButton = wrapper.find('.top > .actions > rc-button-stub');
|
|
201
|
+
|
|
202
|
+
await actionButton.trigger('click');
|
|
203
|
+
|
|
204
|
+
expect(onClick).toHaveBeenCalledTimes(1);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('should render a custom component when additionalActions is a Vue component', async() => {
|
|
208
|
+
const CustomComponent = defineComponent({
|
|
209
|
+
name: 'CustomActions',
|
|
210
|
+
render: () => h('div', { class: 'custom-actions' }, 'Custom Actions')
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
const wrapper = mount(TitleBar, {
|
|
214
|
+
props: {
|
|
215
|
+
resourceTypeLabel, resourceName, additionalActions: CustomComponent
|
|
216
|
+
},
|
|
217
|
+
global: { stubs: { 'router-link': RouterLinkStub }, provide: { store } }
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(wrapper.find('.custom-actions').exists()).toBeTruthy();
|
|
221
|
+
expect(wrapper.find('.custom-actions').text()).toBe('Custom Actions');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should use slot content when additional-actions slot is provided', async() => {
|
|
225
|
+
const additionalActions = [
|
|
226
|
+
{
|
|
227
|
+
label: 'Action 1', variant: 'secondary', onClick: jest.fn()
|
|
228
|
+
}
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
const wrapper = mount(TitleBar, {
|
|
232
|
+
props: {
|
|
233
|
+
resourceTypeLabel, resourceName, additionalActions
|
|
234
|
+
},
|
|
235
|
+
slots: { 'additional-actions': '<button class="slot-button">Slot Button</button>' },
|
|
236
|
+
global: { stubs: { 'router-link': RouterLinkStub }, provide: { store } }
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// Slot content should override the additionalActions prop
|
|
240
|
+
expect(wrapper.find('.slot-button').exists()).toBeTruthy();
|
|
241
|
+
expect(wrapper.find('.slot-button').text()).toBe('Slot Button');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
140
244
|
});
|
|
@@ -36,7 +36,8 @@ export const useDefaultTitleBarProps = (resource: any, resourceSubtype?: Ref<str
|
|
|
36
36
|
color: resourceValue.stateBackground,
|
|
37
37
|
label: resourceValue.stateDisplay
|
|
38
38
|
},
|
|
39
|
-
description:
|
|
39
|
+
description: resourceValue.description,
|
|
40
|
+
additionalActions: resourceValue.detailPageAdditionalActions,
|
|
40
41
|
onShowConfiguration
|
|
41
42
|
};
|
|
42
43
|
});
|
|
@@ -8,16 +8,23 @@ import { useStore } from 'vuex';
|
|
|
8
8
|
import { useI18n } from '@shell/composables/useI18n';
|
|
9
9
|
import RcButton from '@components/RcButton/RcButton.vue';
|
|
10
10
|
import TabTitle from '@shell/components/TabTitle';
|
|
11
|
-
import { computed, ref, watch } from 'vue';
|
|
11
|
+
import { computed, ref, VueElement, watch } from 'vue';
|
|
12
12
|
import { _CONFIG, AS } from '@shell/config/query-params';
|
|
13
13
|
import { ExtensionPoint, PanelLocation } from '@shell/core/types';
|
|
14
14
|
import ExtensionPanel from '@shell/components/ExtensionPanel.vue';
|
|
15
|
+
import { ButtonVariantNewProps, ButtonSizeNewProps } from '~/pkg/rancher-components/src/components/RcButton/types';
|
|
16
|
+
import { isArray } from 'lodash';
|
|
15
17
|
|
|
16
18
|
export interface Badge {
|
|
17
19
|
color: 'bg-success' | 'bg-error' | 'bg-warning' | 'bg-info';
|
|
18
20
|
label: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
export interface AdditionalActionButton extends ButtonVariantNewProps, ButtonSizeNewProps {
|
|
24
|
+
label: string;
|
|
25
|
+
onClick: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
export interface TitleBarProps {
|
|
22
29
|
resource: any;
|
|
23
30
|
resourceTypeLabel: string;
|
|
@@ -27,6 +34,8 @@ export interface TitleBarProps {
|
|
|
27
34
|
description?: string;
|
|
28
35
|
badge?: Badge;
|
|
29
36
|
|
|
37
|
+
additionalActions?: VueElement | AdditionalActionButton[];
|
|
38
|
+
|
|
30
39
|
// This should be replaced with a list of menu items we want to render.
|
|
31
40
|
// I don't have the time right now to swap this out though.
|
|
32
41
|
actionMenuResource?: any;
|
|
@@ -36,7 +45,7 @@ export interface TitleBarProps {
|
|
|
36
45
|
|
|
37
46
|
<script setup lang="ts">
|
|
38
47
|
const {
|
|
39
|
-
resource, resourceTypeLabel, resourceTo, resourceName, description, badge, onShowConfiguration,
|
|
48
|
+
additionalActions, resource, resourceTypeLabel, resourceTo, resourceName, description, badge, onShowConfiguration,
|
|
40
49
|
} = defineProps<TitleBarProps>();
|
|
41
50
|
|
|
42
51
|
const store = useStore();
|
|
@@ -55,6 +64,8 @@ watch(
|
|
|
55
64
|
router.push({ query: { [AS]: currentView.value } });
|
|
56
65
|
}
|
|
57
66
|
);
|
|
67
|
+
|
|
68
|
+
const showAdditionalActionButtons = computed(() => isArray(additionalActions));
|
|
58
69
|
</script>
|
|
59
70
|
|
|
60
71
|
<template>
|
|
@@ -89,12 +100,31 @@ watch(
|
|
|
89
100
|
/>
|
|
90
101
|
</Title>
|
|
91
102
|
<div class="actions">
|
|
92
|
-
<slot name="additional-actions"
|
|
103
|
+
<slot name="additional-actions">
|
|
104
|
+
<template v-if="additionalActions">
|
|
105
|
+
<template v-if="showAdditionalActionButtons">
|
|
106
|
+
<RcButton
|
|
107
|
+
v-for="(actionButtonProps, i) in (additionalActions as AdditionalActionButton[])"
|
|
108
|
+
:key="`action-button-${i}`"
|
|
109
|
+
:variant="actionButtonProps.variant"
|
|
110
|
+
:size="actionButtonProps.size"
|
|
111
|
+
@click="actionButtonProps.onClick"
|
|
112
|
+
>
|
|
113
|
+
{{ actionButtonProps.label }}
|
|
114
|
+
</RcButton>
|
|
115
|
+
</template>
|
|
116
|
+
<component
|
|
117
|
+
:is="additionalActions"
|
|
118
|
+
v-else
|
|
119
|
+
/>
|
|
120
|
+
</template>
|
|
121
|
+
</slot>
|
|
93
122
|
<RcButton
|
|
94
123
|
v-if="onShowConfiguration"
|
|
95
124
|
:data-testid="showConfigurationDataTestId"
|
|
96
125
|
class="show-configuration"
|
|
97
|
-
|
|
126
|
+
variant="primary"
|
|
127
|
+
size="large"
|
|
98
128
|
:aria-label="i18n.t('component.resource.detail.titleBar.ariaLabel.showConfiguration', { resource: resourceName })"
|
|
99
129
|
@click="() => emit('show-configuration', showConfigurationReturnFocusSelector)"
|
|
100
130
|
>
|
|
@@ -106,7 +136,7 @@ watch(
|
|
|
106
136
|
</RcButton>
|
|
107
137
|
<ActionMenu
|
|
108
138
|
v-if="actionMenuResource"
|
|
109
|
-
button-
|
|
139
|
+
button-variant="multiAction"
|
|
110
140
|
:resource="actionMenuResource"
|
|
111
141
|
data-testid="masthead-action-menu"
|
|
112
142
|
:button-aria-label="i18n.t('component.resource.detail.titleBar.ariaLabel.actionMenu', { resource: resourceName })"
|
|
@@ -143,7 +173,12 @@ watch(
|
|
|
143
173
|
margin-right: 10px;
|
|
144
174
|
}
|
|
145
175
|
|
|
146
|
-
.
|
|
176
|
+
.actions {
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.show-configuration, &:deep() .actions button {
|
|
147
182
|
margin-left: 16px;
|
|
148
183
|
}
|
|
149
184
|
|
|
@@ -8,7 +8,7 @@ jest.mock('@shell/composables/useIsNewDetailPageEnabled');
|
|
|
8
8
|
jest.mock('@shell/components/ResourceDetail/Masthead/latest.vue', () => ({
|
|
9
9
|
name: 'Latest',
|
|
10
10
|
template: `<div>Latest</div>`,
|
|
11
|
-
props: ['value', 'resourceSubtype']
|
|
11
|
+
props: ['value', 'resourceSubtype', 'isCustomDetailOrEdit']
|
|
12
12
|
}));
|
|
13
13
|
jest.mock('@shell/components/ResourceDetail/Masthead/legacy.vue', () => ({
|
|
14
14
|
name: 'Legacy',
|
|
@@ -67,4 +67,52 @@ describe('component: Masthead/index', () => {
|
|
|
67
67
|
|
|
68
68
|
expect(component).toBeDefined();
|
|
69
69
|
});
|
|
70
|
+
|
|
71
|
+
it('should pass isCustomDetailOrEdit as true when hasDetail is true', () => {
|
|
72
|
+
useIsNewDetailPageEnabledSpy.mockReturnValue(computed(() => true));
|
|
73
|
+
const props = {
|
|
74
|
+
value: { type: 'VALUE' },
|
|
75
|
+
mode: _VIEW,
|
|
76
|
+
hasDetail: true,
|
|
77
|
+
hasEdit: false
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const wrapper = mount(Index, { props });
|
|
81
|
+
|
|
82
|
+
const component = wrapper.getComponent({ name: 'Latest' });
|
|
83
|
+
|
|
84
|
+
expect(component.props('isCustomDetailOrEdit')).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should pass isCustomDetailOrEdit as true when hasEdit is true', () => {
|
|
88
|
+
useIsNewDetailPageEnabledSpy.mockReturnValue(computed(() => true));
|
|
89
|
+
const props = {
|
|
90
|
+
value: { type: 'VALUE' },
|
|
91
|
+
mode: _VIEW,
|
|
92
|
+
hasDetail: false,
|
|
93
|
+
hasEdit: true
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const wrapper = mount(Index, { props });
|
|
97
|
+
|
|
98
|
+
const component = wrapper.getComponent({ name: 'Latest' });
|
|
99
|
+
|
|
100
|
+
expect(component.props('isCustomDetailOrEdit')).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should pass isCustomDetailOrEdit as false when both hasDetail and hasEdit are false', () => {
|
|
104
|
+
useIsNewDetailPageEnabledSpy.mockReturnValue(computed(() => true));
|
|
105
|
+
const props = {
|
|
106
|
+
value: { type: 'VALUE' },
|
|
107
|
+
mode: _VIEW,
|
|
108
|
+
hasDetail: false,
|
|
109
|
+
hasEdit: false
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const wrapper = mount(Index, { props });
|
|
113
|
+
|
|
114
|
+
const component = wrapper.getComponent({ name: 'Latest' });
|
|
115
|
+
|
|
116
|
+
expect(component.props('isCustomDetailOrEdit')).toBe(false);
|
|
117
|
+
});
|
|
70
118
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { mount } from '@vue/test-utils';
|
|
2
|
+
import Latest from '@shell/components/ResourceDetail/Masthead/latest.vue';
|
|
3
|
+
|
|
4
|
+
jest.mock('@shell/components/Resource/Detail/TitleBar/index.vue', () => ({
|
|
5
|
+
name: 'TitleBar',
|
|
6
|
+
template: `<div data-testid="title-bar">TitleBar</div>`
|
|
7
|
+
}));
|
|
8
|
+
jest.mock('@shell/components/Resource/Detail/TitleBar/composables', () => ({ useDefaultTitleBarProps: jest.fn(() => ({})) }));
|
|
9
|
+
jest.mock('@shell/components/Resource/Detail/Metadata/index.vue', () => ({
|
|
10
|
+
name: 'Metadata',
|
|
11
|
+
template: `<div data-testid="metadata">Metadata</div>`
|
|
12
|
+
}));
|
|
13
|
+
jest.mock('@shell/components/Resource/Detail/Metadata/composables', () => ({ useDefaultMetadataForLegacyPagesProps: jest.fn(() => ({})) }));
|
|
14
|
+
jest.mock('@shell/components/Resource/Detail/composables', () => ({ useResourceDetailBannerProps: jest.fn(() => null) }));
|
|
15
|
+
jest.mock('@shell/components/Resource/Detail/Cards.vue', () => ({
|
|
16
|
+
name: 'Cards',
|
|
17
|
+
template: `<div data-testid="cards">Cards</div>`,
|
|
18
|
+
props: ['resource']
|
|
19
|
+
}));
|
|
20
|
+
jest.mock('@components/Banner', () => ({
|
|
21
|
+
Banner: {
|
|
22
|
+
name: 'Banner',
|
|
23
|
+
template: `<div data-testid="banner">Banner</div>`
|
|
24
|
+
}
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
const defaultMocks = {
|
|
28
|
+
directives: { 'ui-context': () => {} },
|
|
29
|
+
global: {
|
|
30
|
+
mocks: {
|
|
31
|
+
$store: {
|
|
32
|
+
getters: { 'i18n/t': jest.fn() },
|
|
33
|
+
dispatch: jest.fn()
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
describe('component: Masthead/latest', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
jest.clearAllMocks();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should render Cards when isCustomDetailOrEdit is true', () => {
|
|
45
|
+
const props = {
|
|
46
|
+
value: { name: 'test-resource' },
|
|
47
|
+
isCustomDetailOrEdit: true
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const wrapper = mount(Latest, { props, ...defaultMocks });
|
|
51
|
+
|
|
52
|
+
expect(wrapper.find('[data-testid="cards"]').exists()).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should not render Cards when isCustomDetailOrEdit is false', () => {
|
|
56
|
+
const props = {
|
|
57
|
+
value: { name: 'test-resource' },
|
|
58
|
+
isCustomDetailOrEdit: false
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const wrapper = mount(Latest, { props, ...defaultMocks });
|
|
62
|
+
|
|
63
|
+
expect(wrapper.find('[data-testid="cards"]').exists()).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should not render Cards when isCustomDetailOrEdit is not provided (defaults to false)', () => {
|
|
67
|
+
const props = { value: { name: 'test-resource' } };
|
|
68
|
+
|
|
69
|
+
const wrapper = mount(Latest, { props, ...defaultMocks });
|
|
70
|
+
|
|
71
|
+
expect(wrapper.find('[data-testid="cards"]').exists()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should always render TitleBar and Metadata', () => {
|
|
75
|
+
const props = {
|
|
76
|
+
value: { name: 'test-resource' },
|
|
77
|
+
isCustomDetailOrEdit: false
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const wrapper = mount(Latest, { props, ...defaultMocks });
|
|
81
|
+
|
|
82
|
+
expect(wrapper.find('[data-testid="title-bar"]').exists()).toBe(true);
|
|
83
|
+
expect(wrapper.find('[data-testid="metadata"]').exists()).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -48,6 +48,7 @@ const showLatestMasthead = computed(() => isNewDetailPageEnabled.value && isView
|
|
|
48
48
|
v-if="showLatestMasthead"
|
|
49
49
|
:value="props.value"
|
|
50
50
|
:resourceSubtype="props.resourceSubtype"
|
|
51
|
+
:isCustomDetailOrEdit="props.hasDetail || props.hasEdit"
|
|
51
52
|
/>
|
|
52
53
|
<Legacy
|
|
53
54
|
v-else
|
|
@@ -7,11 +7,13 @@ import Metadata from '@shell/components/Resource/Detail/Metadata/index.vue';
|
|
|
7
7
|
import { useDefaultMetadataForLegacyPagesProps } from '@shell/components/Resource/Detail/Metadata/composables';
|
|
8
8
|
import { useResourceDetailBannerProps } from '@shell/components/Resource/Detail/composables';
|
|
9
9
|
import { computed } from 'vue';
|
|
10
|
+
import Cards from '@shell/components/Resource/Detail/Cards.vue';
|
|
10
11
|
|
|
11
12
|
// We are disabling eslint for this script to allow the use of the Props interface
|
|
12
13
|
export interface Props {
|
|
13
14
|
value?: Object;
|
|
14
15
|
resourceSubtype?: string;
|
|
16
|
+
isCustomDetailOrEdit?: boolean;
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
</script>
|
|
@@ -19,7 +21,7 @@ export interface Props {
|
|
|
19
21
|
<script lang="ts" setup>
|
|
20
22
|
import { useStore } from 'vuex';
|
|
21
23
|
|
|
22
|
-
const props = withDefaults(defineProps<Props>(), { value: () => ({}), resourceSubtype: undefined });
|
|
24
|
+
const props = withDefaults(defineProps<Props>(), { value: () => ({}), resourceSubtype: undefined, isCustomDetailOrEdit: false });
|
|
23
25
|
|
|
24
26
|
const uiCtxResource = computed(() => {
|
|
25
27
|
const {
|
|
@@ -64,6 +66,11 @@ const store = useStore();
|
|
|
64
66
|
v-bind="metadataProps"
|
|
65
67
|
class="mmt-4"
|
|
66
68
|
/>
|
|
69
|
+
<Cards
|
|
70
|
+
v-if="props.isCustomDetailOrEdit"
|
|
71
|
+
class="mb-20"
|
|
72
|
+
:resource="props.value"
|
|
73
|
+
/>
|
|
67
74
|
</div>
|
|
68
75
|
</template>
|
|
69
76
|
|