Merge pull request #14538 from codyrancher/detail-page-config-map

Adding the new configmap detail page
This commit is contained in:
codyrancher 2025-06-20 11:03:35 -07:00 committed by GitHub
commit 26859160ff
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1150 additions and 317 deletions

View File

@ -165,6 +165,7 @@
--tabbed-container-bg : #{mix($medium, $dark, 20%)}; --tabbed-container-bg : #{mix($medium, $dark, 20%)};
--yaml-editor-bg : #{$darkest}; --yaml-editor-bg : #{$darkest};
--drawer-body-bg : #{$darkest};
--diff-border : var(--border); --diff-border : var(--border);
--diff-header-bg : var(--nav-bg); --diff-header-bg : var(--nav-bg);

View File

@ -483,6 +483,7 @@ BODY, .theme-light {
--tabbed-container-bg : #{mix($light, $lighter, 15%)}; --tabbed-container-bg : #{mix($light, $lighter, 15%)};
--yaml-editor-bg : #{$lighter}; --yaml-editor-bg : #{$lighter};
--drawer-body-bg : #{$lighter};
--diff-border : var(--border); --diff-border : var(--border);
--diff-header-bg : var(--nav-bg); --diff-header-bg : var(--nav-bg);

View File

@ -8505,6 +8505,22 @@ errors:
withUrl: '{msg}: {url}' withUrl: '{msg}: {url}'
withoutUrl: '{msg}' withoutUrl: '{msg}'
component: component:
drawer:
chrome:
ariaLabel:
close: Close {target} drawer
close: Close
resourceDetailDrawer:
title: "{resourceName} ({resourceType}) - Configuration"
editConfig: Edit Config
editYaml: Edit YAML
ariaLabel:
editConfig: Edit Config
editYaml: Edit YAML
yamlTab:
title: YAML
configTab:
title: Config
resource: resource:
detail: detail:
card: card:

View File

@ -0,0 +1,114 @@
<script lang="ts">
import { useI18n } from '@shell/composables/useI18n';
import { useStore } from 'vuex';
import { computed } from 'vue';
export interface Props {
ariaTarget: string;
}
</script>
<script setup lang="ts">
const props = defineProps<Props>();
const emit = defineEmits(['close']);
const store = useStore();
const i18n = useI18n(store);
const ariaLabel = computed(() => i18n.t('component.drawer.chrome.ariaLabel.close', { target: props.ariaTarget }));
</script>
<template>
<div class="chrome">
<div class="header pp-4">
<slot name="header">
<div class="title">
<slot name="title" />
</div>
<div class="actions">
<button
class="btn role-link"
:aria-label="ariaLabel"
@click="emit('close')"
>
<i class="icon icon-close" />
</button>
</div>
</slot>
</div>
<div class="body pp-4">
<slot name="body" />
</div>
<div class="footer pp-4">
<slot name="footer">
<div class="actions">
<button
class="btn role-secondary"
:aria-label="ariaLabel"
@click="emit('close')"
>
{{ i18n.t('component.drawer.chrome.close') }}
</button>
<slot name="additional-actions" />
</div>
</slot>
</div>
</div>
</template>
<style lang="scss" scoped>
.chrome {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
z-index: 1000;
display: flex;
flex-direction: column;
& >.header {
display: flex;
flex-direction: row;
align-items: center;
background-color: var(--body-bg);
border-bottom: 1px solid var(--border);
height: var(--header-height);
& > .title {
flex: 1;
font-size: 16px;
}
& > .actions {
button {
display: inline-block;
$size: 24px;
width: $size;
height: $size;
color: var(--body-text);
}
}
}
.body {
background-color: var(--drawer-body-bg);
flex: 1;
overflow-y: scroll;
}
.footer {
display: flex;
flex-direction: row;
justify-content: flex-end;
background-color: var(--body-bg);
border-top: 1px solid var(--border);
height: 72px;
.actions > * {
margin-left: 16px;
}
}
}
</style>

View File

@ -0,0 +1,49 @@
<script lang="ts">
import { useI18n } from '@shell/composables/useI18n';
import { _VIEW } from '@shell/config/query-params';
import { useStore } from 'vuex';
import Tab from '@shell/components/Tabbed/Tab.vue';
export interface Props {
resource: any;
component: any;
}
</script>
<script setup lang="ts">
const props = defineProps<Props>();
const store = useStore();
const i18n = useI18n(store);
</script>
<template>
<Tab
class="config-tab"
name="config-tab"
:label="i18n.t('component.drawer.resourceDetailDrawer.configTab.title')"
>
<div class="container">
<component
:is="props.component"
v-model:value="props.resource"
:mode="_VIEW"
:initial-value="props.resource"
:use-tabbed-hash="false /* Have to disable hashing on child components or it modifies the url and closes the drawer */"
/>
</div>
</Tab>
</template>
<style lang="scss" scoped>
.config-tab {
.container {
background-color: var(--body-bg);
border-radius: var(--border-radius-md);
padding: 16px;
max-width: 100%;
width: 100%;
}
:deep() .cru-resource-footer {
display: none;
}
}
</style>

View File

@ -0,0 +1,48 @@
<script lang="ts">
import ResourceYaml from '@shell/components/ResourceYaml.vue';
import { useI18n } from '@shell/composables/useI18n';
import { _VIEW } from '@shell/config/query-params';
import { useStore } from 'vuex';
import Tab from '@shell/components/Tabbed/Tab.vue';
import { useTemplateRef } from 'vue';
export interface Props {
resource: any;
yaml: string;
}
</script>
<script setup lang="ts">
const props = defineProps<Props>();
const store = useStore();
const i18n = useI18n(store);
const yamlComponent: any = useTemplateRef('yaml');
</script>
<template>
<Tab
class="yaml-tab"
name="yaml-tab"
:label="i18n.t('component.drawer.resourceDetailDrawer.yamlTab.title')"
@active="() => yamlComponent?.refresh()"
>
<ResourceYaml
ref="yaml"
:value="props.resource"
:yaml="props.yaml"
:mode="_VIEW"
/>
</Tab>
</template>
<style lang="scss" scoped>
.yaml-tab {
:deep() .yaml-editor .codemirror-container {
background-color: var(--body-bg);
border-radius: var(--border-radius-md);
padding: 16px;
.CodeMirror, .CodeMirror-gutter {
background-color: var(--body-bg);
}
}
}
</style>

View File

@ -0,0 +1,54 @@
import { mount } from '@vue/test-utils';
import ConfigTab from '@shell/components/Drawer/ResourceDetailDrawer/ConfigTab.vue';
import { createStore } from 'vuex';
import { defineComponent, markRaw } from 'vue';
import Tab from '@shell/components/Tabbed/Tab.vue';
import { _VIEW } from '@shell/config/query-params';
const DynamicComponent = defineComponent({
template: '<div>DynamicComponent</div>',
props: {
value: { type: Object, required: true },
mode: { type: String, required: true },
initialValue: { type: Object, required: true },
useTabbedHash: { type: Boolean, required: true }
}
});
describe('component: ResourceDetailDrawer/ConfigTab', () => {
const resource = { resource: 'RESOURCE' };
const global = {
provide: {
addTab: jest.fn(), removeTab: jest.fn(), sideTabs: false, store: createStore({})
},
directives: { 'clean-tooltip': jest.fn() }
};
it('should render container with config-tab class and correct label and name', async() => {
const wrapper = mount(ConfigTab, {
props: { resource, component: markRaw(DynamicComponent) },
global
});
const component = wrapper.getComponent(Tab);
expect(wrapper.classes().includes('config-tab')).toBeTruthy();
expect(component.props('label')).toStrictEqual('component.drawer.resourceDetailDrawer.configTab.title');
expect(component.props('name')).toStrictEqual('config-tab');
});
it('should render a dynamic component within the .container and pass the correct props', () => {
const wrapper = mount(ConfigTab, {
props: { resource, component: markRaw(DynamicComponent) },
global
});
const component = wrapper.find('.container').getComponent(DynamicComponent);
expect(component.props('value')).toStrictEqual(resource);
expect(component.props('mode')).toStrictEqual(_VIEW);
expect(component.props('initialValue')).toStrictEqual(resource);
expect(component.props('useTabbedHash')).toStrictEqual(false);
});
});

View File

@ -0,0 +1,80 @@
import { mount } from '@vue/test-utils';
import YamlTab from '@shell/components/Drawer/ResourceDetailDrawer/YamlTab.vue';
import { createStore } from 'vuex';
import Tab from '@shell/components/Tabbed/Tab.vue';
import { _VIEW } from '@shell/config/query-params';
import ResourceYaml from '@shell/components/ResourceYaml.vue';
import { nextTick } from 'vue';
jest.mock('@shell/components/ResourceYaml.vue', () => ({
template: `<div>ResourceYaml</div>`,
props: {
value: {
type: Object,
required: true
},
yaml: {
type: String,
required: true
},
mode: {
type: String,
required: true
},
},
methods: { refresh: jest.fn() }
}));
describe('component: ResourceDetailDrawer/ConfigTab', () => {
const resource = { resource: 'RESOURCE' };
const yaml = 'YAML';
const global = {
provide: {
addTab: jest.fn(), removeTab: jest.fn(), sideTabs: false, store: createStore({})
},
directives: { 'clean-tooltip': jest.fn() }
};
it('should render container with yaml-tab class and correct label and name', async() => {
const wrapper = mount(YamlTab, {
props: { resource, yaml },
global
});
const component = wrapper.getComponent(Tab);
expect(wrapper.classes().includes('yaml-tab')).toBeTruthy();
expect(component.props('label')).toStrictEqual('component.drawer.resourceDetailDrawer.yamlTab.title');
expect(component.props('name')).toStrictEqual('yaml-tab');
});
it('should render a ResourceYaml component and pass the correct props', () => {
const wrapper = mount(YamlTab, {
props: { resource, yaml },
global
});
const component = wrapper.getComponent(ResourceYaml);
expect(component.props('value')).toStrictEqual(resource);
expect(component.props('mode')).toStrictEqual(_VIEW);
expect(component.props('yaml')).toStrictEqual(yaml);
});
it('should refresh yaml editor when tab is activated', async() => {
const wrapper = mount(YamlTab, {
props: { resource, yaml },
global
});
const tabComponent = wrapper.getComponent(Tab);
expect(ResourceYaml.methods?.refresh).toHaveBeenCalledTimes(0);
tabComponent.vm.$emit('active');
await nextTick();
expect(ResourceYaml.methods?.refresh).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,81 @@
import { useDefaultConfigTabProps, useDefaultYamlTabProps, useResourceDetailDrawer } from '@shell/components/Drawer/ResourceDetailDrawer/composables';
import * as helpers from '@shell/components/Drawer/ResourceDetailDrawer/helpers';
import * as vuex from 'vuex';
import * as drawer from '@shell/composables/drawer';
jest.mock('@shell/components/Drawer/ResourceDetailDrawer/helpers');
jest.mock('vuex');
jest.mock('@shell/composables/drawer');
jest.mock('@shell/components/Drawer/ResourceDetailDrawer/index.vue', () => ({ name: 'ResourceDetailDrawer' } as any));
describe('composables: ResourceDetailDrawer', () => {
const resource = { type: 'RESOURCE' };
const yaml = 'YAML';
describe('useDefaultYamlTabProps', () => {
it('should return the appropriate values based on input', async() => {
const getYamlSpy = jest.spyOn(helpers, 'getYaml').mockImplementation(() => Promise.resolve(yaml));
const props = await useDefaultYamlTabProps(resource);
expect(getYamlSpy).toHaveBeenCalledWith(resource);
expect(props.yaml).toStrictEqual(yaml);
expect(props.resource).toStrictEqual(resource);
});
});
describe('useDefaultConfigTabProps', () => {
const hasCustomEdit = jest.fn();
const importEdit = jest.fn();
const editComponent = { component: 'EDIT_COMPONENT' };
const store: any = {
getters: {
'type-map/hasCustomEdit': hasCustomEdit,
'type-map/importEdit': importEdit
}
};
it('should return undefined if it does not have a customEdit', async() => {
jest.spyOn(vuex, 'useStore').mockImplementation(() => store);
const hasCustomEditSpy = hasCustomEdit.mockImplementation(() => false);
const props = useDefaultConfigTabProps(resource);
expect(hasCustomEditSpy).toHaveBeenCalledWith(resource.type);
expect(props).toBeUndefined();
});
it('should return props if it has a customEdit', async() => {
jest.spyOn(vuex, 'useStore').mockImplementation(() => store);
const hasCustomEditSpy = hasCustomEdit.mockImplementation(() => true);
const importEditSpy = importEdit.mockImplementation(() => editComponent);
const props = useDefaultConfigTabProps(resource);
expect(hasCustomEditSpy).toHaveBeenCalledWith(resource.type);
expect(importEditSpy).toHaveBeenCalledWith(resource.type);
expect(props?.component).toStrictEqual(editComponent);
expect(props?.resource).toStrictEqual(resource);
});
});
describe('useResourceDetailDrawer', () => {
it('should create a wrapper that passes that appropriate properties to the drawer methods', () => {
const openSpy = jest.fn();
const closeSpy = jest.fn();
const useDrawerSpy = jest.spyOn(drawer, 'useDrawer').mockImplementation(() => ({ open: openSpy, close: closeSpy }));
const resourceDetailDrawer = useResourceDetailDrawer();
resourceDetailDrawer.openResourceDetailDrawer(resource);
expect(useDrawerSpy).toHaveBeenCalledTimes(1);
expect(openSpy).toHaveBeenCalledWith({ name: 'ResourceDetailDrawer' }, {
resource,
onClose: closeSpy,
width: '73%',
// We want this to be full viewport height top to bottom
height: '100vh',
top: '0',
'z-index': 101 // We want this to be above the main side menu
});
});
});
});

View File

@ -0,0 +1,42 @@
import { getYaml } from '@shell/components/Drawer/ResourceDetailDrawer/helpers';
describe('helpers: ResourceDetailDrawer', () => {
describe('getYaml', () => {
const resource = {
hasLink: jest.fn(),
followLink: jest.fn(),
cleanForDownload: jest.fn()
};
const yaml = 'YAML';
beforeEach(() => {
jest.clearAllMocks();
});
it('should skip following a link if it does not have a view link', async() => {
resource.hasLink.mockImplementation(() => false);
resource.cleanForDownload.mockImplementation(() => yaml);
const response = await getYaml(resource);
expect(resource.hasLink).toHaveBeenCalledWith('view');
expect(resource.followLink).toHaveBeenCalledTimes(0);
expect(resource.cleanForDownload).toHaveBeenCalledWith(undefined);
expect(response).toStrictEqual(yaml);
});
it('should follow link if it has a view link', async() => {
resource.hasLink.mockImplementation(() => true);
resource.followLink.mockImplementation(() => Promise.resolve({ data: yaml }));
resource.cleanForDownload.mockImplementation(() => yaml);
const response = await getYaml(resource);
expect(resource.hasLink).toHaveBeenCalledWith('view');
expect(resource.followLink).toHaveBeenCalledWith('view', { headers: { accept: 'application/yaml' } });
expect(resource.cleanForDownload).toHaveBeenCalledWith(yaml);
expect(response).toStrictEqual(yaml);
});
});
});

View File

@ -0,0 +1,46 @@
import ResourceDetailDrawer from '@shell/components/Drawer/ResourceDetailDrawer/index.vue';
import { Props as YamlTabProps } from '@shell/components/Drawer/ResourceDetailDrawer/YamlTab.vue';
import { Props as ConfigTabProps } from '@shell/components/Drawer/ResourceDetailDrawer/ConfigTab.vue';
import { useStore } from 'vuex';
import { useDrawer } from '@shell/composables/drawer';
import { getYaml } from '@shell/components/Drawer/ResourceDetailDrawer/helpers';
export function useResourceDetailDrawer() {
const { open, close } = useDrawer();
const openResourceDetailDrawer = (resource: any) => {
open(ResourceDetailDrawer, {
resource,
onClose: close,
width: '73%',
// We want this to be full viewport height top to bottom
height: '100vh',
top: '0',
'z-index': 101 // We want this to be above the main side menu
});
};
return { openResourceDetailDrawer };
}
export async function useDefaultYamlTabProps(resource: any): Promise<YamlTabProps> {
const yaml = await getYaml(resource);
return {
resource,
yaml
};
}
export function useDefaultConfigTabProps(resource: any): ConfigTabProps | undefined {
const store = useStore();
if (!store.getters['type-map/hasCustomEdit'](resource.type)) {
return;
}
return {
resource,
component: store.getters['type-map/importEdit'](resource.type)
};
}

View File

@ -0,0 +1,10 @@
export async function getYaml(resource: any): Promise<string> {
let yaml;
const opt = { headers: { accept: 'application/yaml' } };
if (resource.hasLink('view')) {
yaml = (await resource.followLink('view', opt)).data;
}
return resource.cleanForDownload(yaml);
}

View File

@ -0,0 +1,110 @@
<script lang="ts">
import Drawer from '@shell/components/Drawer/Chrome.vue';
import { useI18n } from '@shell/composables/useI18n';
import { useStore } from 'vuex';
import Tabbed from '@shell/components/Tabbed/index.vue';
import YamlTab, { Props as YamlProps } from '@shell/components/Drawer/ResourceDetailDrawer/YamlTab.vue';
import { useDefaultConfigTabProps, useDefaultYamlTabProps } from '@shell/components/Drawer/ResourceDetailDrawer/composables';
import ConfigTab from '@shell/components/Drawer/ResourceDetailDrawer/ConfigTab.vue';
import { computed, ref } from 'vue';
import RcButton from '@components/RcButton/RcButton.vue';
import StateDot from '@shell/components/StateDot/index.vue';
export interface Props {
resource: any;
onClose?: () => void;
}
</script>
<script setup lang="ts">
const props = defineProps<Props>();
const emit = defineEmits(['close']);
const store = useStore();
const i18n = useI18n(store);
const yamlTabProps = ref<YamlProps | null>(null);
const configTabProps = useDefaultConfigTabProps(props.resource);
useDefaultYamlTabProps(props.resource).then((props) => {
yamlTabProps.value = props;
});
const title = computed(() => {
const resourceType = store.getters['type-map/labelFor']({ id: props.resource.type });
const resourceName = props.resource.nameDisplay;
return i18n.t('component.drawer.resourceDetailDrawer.title', { resourceType, resourceName });
});
const activeTab = ref<string>(configTabProps ? 'config-tab' : 'yaml-tab');
const action = computed(() => {
const isConfig = activeTab.value === 'config-tab';
const ariaLabel = isConfig ? i18n.t('component.drawer.resourceDetailDrawer.ariaLabel.editConfig') : i18n.t('component.drawer.resourceDetailDrawer.ariaLabel.editYaml');
const label = isConfig ? i18n.t('component.drawer.resourceDetailDrawer.ariaLabel.editConfig') : i18n.t('component.drawer.resourceDetailDrawer.ariaLabel.editYaml');
const action = isConfig ? () => props.resource.goToEdit() : () => props.resource.goToEditYaml();
return {
ariaLabel,
label,
action
};
});
</script>
<template>
<Drawer
class="resource-detail-drawer"
:ariaTarget="title"
@close="emit('close')"
>
<template #title>
<StateDot
:color="resource.stateSimpleColor"
class="mmr-3"
/>
{{ title }}
</template>
<template #body>
<Tabbed
class="tabbed"
:useHash="false"
@changed="({selectedName}) => {activeTab = selectedName;}"
>
<ConfigTab
v-if="configTabProps"
v-bind="configTabProps"
/>
<YamlTab
v-if="yamlTabProps"
v-bind="yamlTabProps"
/>
</Tabbed>
</template>
<template #additional-actions>
<RcButton
:primary="true"
:aria-label="action.ariaLabel"
@click="action.action"
>
{{ action.label }}
</RcButton>
</template>
</Drawer>
</template>
<style lang="scss" scoped>
.resource-detail-drawer {
:deep() .tabbed {
& > .tabs {
border: none;
}
& > .tab-container {
border: none;
border-top: 1px solid var(--border);
padding: 0;
padding-top: 24px;
}
}
}
</style>

View File

@ -0,0 +1,46 @@
<script lang="ts">
import { computed } from 'vue';
export interface Props {
items: string[];
}
</script>
<script setup lang="ts">
const { items } = defineProps<Props>();
const first = computed(() => items[0]);
const tooltipValue = computed(() => {
let rows = '';
items.forEach((item) => {
rows += `&#8226; ${ item }<br>`;
});
return rows;
});
</script>
<template>
<div class="additional">
<div class="initial">
{{ first }}
</div>
<div
v-if="items.length > 1"
v-clean-tooltip.bottom="tooltipValue"
class="more text-muted"
>
{{ t('generic.plusMore', {n: items.length-1}) }}
</div>
</div>
</template>
<style lang="scss" scoped>
.more {
margin-top: 4px;
cursor: help;
font-size: 0.8em;
}
</style>

View File

@ -3,7 +3,7 @@ import Annotations from '@shell/components/Resource/Detail/Metadata/Annotations/
import { createStore } from 'vuex'; import { createStore } from 'vuex';
describe('component: Metadata/Annotations', () => { describe('component: Metadata/Annotations', () => {
it('shoulder render KeyValue with the appropriate props', async() => { it('should render KeyValue with the appropriate props', async() => {
const annotations = [{ key: 'key', value: 'value' }]; const annotations = [{ key: 'key', value: 'value' }];
const wrapper = mount(Annotations, { const wrapper = mount(Annotations, {
props: { annotations }, props: { annotations },

View File

@ -7,12 +7,15 @@ export type Annotation = Row;
export interface AnnotationsProps { export interface AnnotationsProps {
annotations: Annotation[]; annotations: Annotation[];
onShowConfiguration?: () => void;
} }
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
const { annotations } = defineProps<AnnotationsProps>(); const { annotations } = defineProps<AnnotationsProps>();
const emit = defineEmits(['show-configuration']);
const store = useStore(); const store = useStore();
const i18n = useI18n(store); const i18n = useI18n(store);
</script> </script>
@ -22,5 +25,7 @@ const i18n = useI18n(store);
:propertyName="i18n.t('component.resource.detail.metadata.annotations.title')" :propertyName="i18n.t('component.resource.detail.metadata.annotations.title')"
:rows="annotations" :rows="annotations"
:outline="true" :outline="true"
@show-configuration="() => emit('show-configuration')"
/> />
</template> </template>

View File

@ -1,223 +1,9 @@
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { computed, ComputedRef, markRaw, toValue } from 'vue'; import { computed, ComputedRef, markRaw, toValue } from 'vue';
import LiveDate from '@shell/components/formatter/LiveDate.vue'; import LiveDate from '@shell/components/formatter/LiveDate.vue';
import LinkName from '@shell/components/formatter/LinkName.vue';
import Additional from '@shell/components/Resource/Detail/Additional.vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import CopyToClipboardText from '@shell/components/CopyToClipboardText.vue';
import IconText from '@shell/components/formatter/IconText.vue';
import { NODE } from '@shell/config/types';
import day from 'dayjs';
import { Row } from '@shell/components/Resource/Detail/Metadata/IdentifyingInformation/index.vue'; import { Row } from '@shell/components/Resource/Detail/Metadata/IdentifyingInformation/index.vue';
export const useContainerRuntime = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('node.detail.detailTop.containerRuntime'),
valueOverride: {
component: markRaw(IconText),
props: { value: resourceValue.containerRuntimeVersion, iconClass: resourceValue.containerRuntimeIcon }
},
value: resourceValue.containerRuntimeVersion
}));
};
export const useExternalIp = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('node.detail.detailTop.externalIP'),
valueOverride: {
component: markRaw(CopyToClipboardText),
props: { text: resourceValue.externalIp }
},
value: resourceValue.externalIp
}));
};
export const usePodIp = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('workload.detailTop.podIP'),
value: resourceValue.status.podIP
}));
};
export const useWorkload = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('workload.detailTop.workload'),
valueOverride: {
component: markRaw(LinkName),
props: {
type: resourceValue.workloadRef.type,
value: resourceValue.workloadRef.name,
namespace: resourceValue.workloadRef.namespace
}
},
value: resourceValue.workloadRef.name
}));
};
export const useNode = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('workload.detailTop.node'),
valueOverride: {
component: markRaw(LinkName),
props: { type: NODE, value: resourceValue.spec.nodeName }
},
value: resourceValue.spec.nodeName
}));
};
export const useStarted = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('workload.detailTop.started'),
valueOverride: {
component: markRaw(LiveDate),
props: { addSuffix: true, value: resourceValue.status.startTime }
},
value: resourceValue.spec.nodeName
}));
};
export const useDuration = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
const FACTORS = [60, 60, 24];
const LABELS = ['sec', 'min', 'hour', 'day'];
const value = computed(() => {
const { completionTime, startTime } = resourceValue.status;
const end = day(completionTime);
const start = day(startTime);
let diff = end.diff(start) / 1000;
let label: any;
let i = 0;
while (diff >= FACTORS[i] && i < FACTORS.length) {
diff /= FACTORS[i];
i++;
}
if (diff < 5) {
label = Math.floor(diff * 10) / 10;
} else {
label = Math.floor(diff);
}
label += ` ${ i18n.t(`unit.${ LABELS[i] }`, { count: label }) } `;
label = label.trim();
return label;
});
return computed(() => ({
label: i18n.t('workload.detailTop.duration'),
valueOverride: {
component: markRaw(LiveDate),
props: { value: value.value }
},
value: value.value
}));
};
export const useImage = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('component.resource.detail.metadata.identifyingInformation.image'),
value: resourceValue.imageNames,
valueOverride: {
component: markRaw(Additional),
props: { items: resourceValue.imageNames }
},
}));
};
export const useReady = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('component.resource.detail.metadata.identifyingInformation.ready'),
value: resourceValue.ready,
}));
};
export const useRestarts = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('workload.detailTop.podRestarts'),
value: resourceValue.restartCount
}));
};
export const useInternalIp = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('node.detail.detailTop.internalIP'),
valueOverride: {
component: markRaw(CopyToClipboardText),
props: { text: resourceValue.internalIp }
},
value: resourceValue.internalIp
}));
};
export const useVersion = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('node.detail.detailTop.version'),
value: resourceValue.version
}));
};
export const useOs = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('node.detail.detailTop.os'),
value: resourceValue.status.nodeInfo.osImage
}));
};
export const useLiveDate = (resource: any): ComputedRef<Row> => { export const useLiveDate = (resource: any): ComputedRef<Row> => {
const store = useStore(); const store = useStore();
const i18n = useI18n(store); const i18n = useI18n(store);
@ -233,18 +19,6 @@ export const useLiveDate = (resource: any): ComputedRef<Row> => {
})); }));
}; };
export const useProject = (resource: any): ComputedRef<Row> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => ({
label: i18n.t('component.resource.detail.metadata.identifyingInformation.project'),
value: resourceValue.project.nameDisplay,
to: '#'
}));
};
export const useDefaultIdentifyingInformation = (resource: any): ComputedRef<Row[]> => { export const useDefaultIdentifyingInformation = (resource: any): ComputedRef<Row[]> => {
const store = useStore(); const store = useStore();
const i18n = useI18n(store); const i18n = useI18n(store);
@ -259,23 +33,3 @@ export const useDefaultIdentifyingInformation = (resource: any): ComputedRef<Row
liveDate.value liveDate.value
]); ]);
}; };
export const useDefaultWorkloadIdentifyingInformation = (resource: any): ComputedRef<Row[]> => {
const store = useStore();
const i18n = useI18n(store);
const resourceValue = toValue(resource);
return computed(() => [
useImage(resource).value,
useReady(resource).value,
{
label: i18n.t('component.resource.detail.metadata.identifyingInformation.up-to-date'),
value: resourceValue.upToDate,
},
{
label: i18n.t('component.resource.detail.metadata.identifyingInformation.available'),
value: resourceValue.available,
},
]);
};

View File

@ -50,12 +50,16 @@ const { rows } = defineProps<MetadataProps>();
:class="['status', row.status]" :class="['status', row.status]"
/> />
<router-link <router-link
v-if="row.to" v-if="row.value && row.to"
:to="row.to" :to="row.to"
> >
{{ row.value }} {{ row.value }}
</router-link> </router-link>
<span v-else>{{ row.value }}</span> <span v-else-if="row.value">{{ row.value }}</span>
<span
v-else
class="text-muted"
>&mdash;</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -30,6 +30,7 @@ const {
const store = useStore(); const store = useStore();
const i18n = useI18n(store); const i18n = useI18n(store);
const emit = defineEmits(['show-configuration']);
// Account for the show all button // Account for the show all button
const visibleRowsLength = computed(() => (rows.value.length > maxRows.value ? maxRows.value - 1 : rows.value.length)); const visibleRowsLength = computed(() => (rows.value.length > maxRows.value ? maxRows.value - 1 : rows.value.length));
@ -50,18 +51,19 @@ const displayValue = (row: Row) => `${ row.key }: ${ row.value }`;
</div> </div>
<div <div
v-if="visibleRows.length === 0" v-if="visibleRows.length === 0"
class="empty mmt-2" class="empty mmt-2 text-muted"
> >
<div class="no-rows"> <div class="no-rows">
{{ i18n.t('component.resource.detail.metadata.keyValue.noRows', {propertyName: lowercasePropertyName}) }} {{ i18n.t('component.resource.detail.metadata.keyValue.noRows', {propertyName: lowercasePropertyName}) }}
</div> </div>
<div class="show-configuration mmt-1"> <div class="show-configuration mmt-1">
<router-link <a
class="secondary" class="secondary text-muted"
to="#" href="#"
@click="(ev: MouseEvent) => {ev.preventDefault(); emit('show-configuration');}"
> >
{{ i18n.t('component.resource.detail.metadata.keyValue.showConfiguration') }} {{ i18n.t('component.resource.detail.metadata.keyValue.showConfiguration') }}
</router-link> </a>
</div> </div>
</div> </div>
<div <div
@ -76,13 +78,14 @@ const displayValue = (row: Row) => `${ row.key }: ${ row.value }`;
{{ displayValue(row) }} {{ displayValue(row) }}
</Rectangle> </Rectangle>
</div> </div>
<router-link <a
v-if="showShowAllButton" v-if="showShowAllButton"
to="#" href="#"
class="show-all" class="show-all secondary"
@click="(ev: MouseEvent) => {ev.preventDefault(); emit('show-configuration');}"
> >
{{ showAllLabel }} {{ showAllLabel }}
</router-link> </a>
</div> </div>
</template> </template>
@ -101,6 +104,8 @@ const displayValue = (row: Row) => `${ row.key }: ${ row.value }`;
} }
.row { .row {
width: 100%;
&:not(:first-of-type) { &:not(:first-of-type) {
margin-top: 4px; margin-top: 4px;
} }
@ -109,13 +114,6 @@ const displayValue = (row: Row) => `${ row.key }: ${ row.value }`;
margin-top: 8px; margin-top: 8px;
} }
} }
.row {
margin-top: 8px;
&:not(:first-of-type) {
margin-top: 4px;
}
}
.show-all { .show-all {
margin-top: 8px; margin-top: 8px;
} }

View File

@ -3,7 +3,7 @@ import Labels from '@shell/components/Resource/Detail/Metadata/Labels/index.vue'
import { createStore } from 'vuex'; import { createStore } from 'vuex';
describe('component: Metadata/Labels', () => { describe('component: Metadata/Labels', () => {
it('shoulder render KeyValue with the appropriate props', async() => { it('should render KeyValue with the appropriate props', async() => {
const labels = [{ key: 'key', value: 'value' }]; const labels = [{ key: 'key', value: 'value' }];
const wrapper = mount(Labels, { const wrapper = mount(Labels, {
props: { labels }, props: { labels },

View File

@ -7,6 +7,8 @@ import { useStore } from 'vuex';
export type Label = Row; export type Label = Row;
export interface LabelsProps { export interface LabelsProps {
labels: Label[]; labels: Label[];
onShowConfiguration?: () => void;
} }
</script> </script>
@ -14,6 +16,7 @@ export interface LabelsProps {
<script setup lang="ts"> <script setup lang="ts">
const props = defineProps<LabelsProps>(); const props = defineProps<LabelsProps>();
const { labels } = toRefs(props); const { labels } = toRefs(props);
const emit = defineEmits(['show-configuration']);
const store = useStore(); const store = useStore();
const i18n = useI18n(store); const i18n = useI18n(store);
@ -23,5 +26,6 @@ const i18n = useI18n(store);
<KeyValue <KeyValue
:propertyName="i18n.t('component.resource.detail.metadata.labels.title')" :propertyName="i18n.t('component.resource.detail.metadata.labels.title')"
:rows="labels" :rows="labels"
@show-configuration="() => emit('show-configuration')"
/> />
</template> </template>

View File

@ -52,7 +52,7 @@ describe('component: Metadata/IdentifyingInformation', () => {
}); });
expect(wrapper.find('.empty .no-rows').element.innerHTML.trim()).toStrictEqual(`component.resource.detail.metadata.keyValue.noRows-{"propertyName":"${ propertyName.toLowerCase() }"}`); expect(wrapper.find('.empty .no-rows').element.innerHTML.trim()).toStrictEqual(`component.resource.detail.metadata.keyValue.noRows-{"propertyName":"${ propertyName.toLowerCase() }"}`);
expect(wrapper.find('.empty .show-configuration').findComponent(RouterLinkStub).element.innerHTML).toStrictEqual('component.resource.detail.metadata.keyValue.showConfiguration'); expect(wrapper.find('.empty .show-configuration a').text()).toStrictEqual('component.resource.detail.metadata.keyValue.showConfiguration');
}); });
it('should render show all button if rows length exceeds max', async() => { it('should render show all button if rows length exceeds max', async() => {

View File

@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import Rectangle from '@shell/components/Resource/Detail/Metadata/Rectangle.vue'; import Rectangle from '@shell/components/Resource/Detail/Metadata/Rectangle.vue';
describe('component: Rectangle', () => { describe('component: Rectangle', () => {
it('shoulder render container with class title and missing outline when passed outline:false', async() => { it('should render container with class title and missing outline when passed outline:false', async() => {
const wrapper = mount(Rectangle, { props: { outline: false } }); const wrapper = mount(Rectangle, { props: { outline: false } });
expect(wrapper.find('.rectangle').exists()).toBeTruthy(); expect(wrapper.find('.rectangle').exists()).toBeTruthy();

View File

@ -3,27 +3,36 @@ import { useDefaultIdentifyingInformation } from '@shell/components/Resource/Det
import { useDefaultLabels } from '@shell/components/Resource/Detail/Metadata/Labels/composable'; import { useDefaultLabels } from '@shell/components/Resource/Detail/Metadata/Labels/composable';
import { useDefaultAnnotations } from '@shell/components/Resource/Detail/Metadata/Annotations/composable'; import { useDefaultAnnotations } from '@shell/components/Resource/Detail/Metadata/Annotations/composable';
import { computed, toValue, Ref } from 'vue'; import { computed, toValue, Ref } from 'vue';
import { useResourceDetailDrawer } from '@shell/components/Drawer/ResourceDetailDrawer/composables';
export const useBasicMetadata = (resource: any) => { export const useBasicMetadata = (resource: any) => {
const labels = useDefaultLabels(resource); const labels = useDefaultLabels(resource);
const annotations = useDefaultAnnotations(resource); const annotations = useDefaultAnnotations(resource);
const { openResourceDetailDrawer } = useResourceDetailDrawer();
return { return computed(() => {
labels, return {
annotations labels: labels.value,
}; annotations: annotations.value,
onShowConfiguration: () => openResourceDetailDrawer(resource)
};
});
}; };
export const useDefaultMetadata = (resource: any, additionalIdentifyingInformation?: (IdentifyingInformationRow[] | Ref<IdentifyingInformationRow[]>)) => { export const useDefaultMetadataProps = (resource: any, additionalIdentifyingInformation?: (IdentifyingInformationRow[] | Ref<IdentifyingInformationRow[]>)) => {
const defaultIdentifyingInformation = useDefaultIdentifyingInformation(resource); const defaultIdentifyingInformation = useDefaultIdentifyingInformation(resource);
const additionalIdentifyingInformationValue = toValue(additionalIdentifyingInformation); const additionalIdentifyingInformationValue = toValue(additionalIdentifyingInformation);
const identifyingInformation = computed(() => [...defaultIdentifyingInformation.value, ...(additionalIdentifyingInformationValue || [])]); const identifyingInformation = computed(() => [...defaultIdentifyingInformation.value, ...(additionalIdentifyingInformationValue || [])]);
const { labels, annotations } = useBasicMetadata(resource); const basicMetaData = useBasicMetadata(resource);
const { openResourceDetailDrawer } = useResourceDetailDrawer();
return { return computed(() => {
identifyingInformation, return {
labels, identifyingInformation: identifyingInformation.value,
annotations labels: basicMetaData.value.labels,
}; annotations: basicMetaData.value.annotations,
onShowConfiguration: () => openResourceDetailDrawer(resource)
};
});
}; };

View File

@ -11,10 +11,12 @@ import { useStore } from 'vuex';
export interface MetadataProps { export interface MetadataProps {
identifyingInformation: IdentifyingInformationRow[], identifyingInformation: IdentifyingInformationRow[],
labels: Label[], labels: Label[],
annotations: Annotation[] annotations: Annotation[],
onShowConfiguration?: () => void;
} }
const { identifyingInformation, labels, annotations } = defineProps<MetadataProps>(); const { identifyingInformation, labels, annotations } = defineProps<MetadataProps>();
const emit = defineEmits(['show-configuration']);
const store = useStore(); const store = useStore();
const i18n = useI18n(store); const i18n = useI18n(store);
@ -37,6 +39,7 @@ const showBothEmpty = computed(() => labels.length === 0 && annotations.length =
<KeyValue <KeyValue
:rows="[]" :rows="[]"
:propertyName="i18n.t('component.resource.detail.metadata.labelsAndAnnotations')" :propertyName="i18n.t('component.resource.detail.metadata.labelsAndAnnotations')"
@show-configuration="() => emit('show-configuration')"
/> />
</div> </div>
<!-- I'm not using v-else here so I can maintain the spacing correctly with the other columns in other rows. --> <!-- I'm not using v-else here so I can maintain the spacing correctly with the other columns in other rows. -->
@ -44,13 +47,19 @@ const showBothEmpty = computed(() => labels.length === 0 && annotations.length =
v-if="!showBothEmpty" v-if="!showBothEmpty"
class="labels" class="labels"
> >
<Labels :labels="labels" /> <Labels
:labels="labels"
@show-configuration="() => emit('show-configuration')"
/>
</div> </div>
<div <div
v-if="!showBothEmpty" v-if="!showBothEmpty"
class="annotations" class="annotations"
> >
<Annotations :annotations="annotations" /> <Annotations
:annotations="annotations"
@show-configuration="() => emit('show-configuration')"
/>
</div> </div>
</SpacedRow> </SpacedRow>
</template> </template>
@ -60,7 +69,5 @@ const showBothEmpty = computed(() => labels.length === 0 && annotations.length =
.labels-and-annotations-empty { .labels-and-annotations-empty {
grid-column: span 2; grid-column: span 2;
} }
border-bottom: 1px solid var(--border);
} }
</style> </style>

View File

@ -20,3 +20,18 @@
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
.resource-detail-page {
padding: 24px;
:deep() .tabs.horizontal {
border: none;
}
:deep() .tabs.horizontal + .tab-container {
border: none;
border-top: 1px solid var(--border);
padding: 0;
padding-top: 24px;
}
}
</style>

View File

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import SubtleLink from '@shell/components/SubtleLink.vue'; import SubtleLink from '@shell/components/SubtleLink.vue';
import { StateColor, stateColorCssVar } from '@shell/utils/style'; import StateDot from '@shell/components/StateDot/index.vue';
import { StateColor } from '@shell/utils/style';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
import { RouteLocationRaw } from 'vue-router'; import { RouteLocationRaw } from 'vue-router';
@ -64,13 +65,10 @@ const {
v-else v-else
class="counts" class="counts"
> >
<span <StateDot
v-if="color" v-if="color"
class="dot" :color="color"
:style="{backgroundColor: stateColorCssVar(color)}" />
>
&nbsp;
</span>
<span <span
v-for="count in counts" v-for="count in counts"
:key="count.label" :key="count.label"
@ -105,14 +103,7 @@ const {
display: none; display: none;
} }
.dot { .state-dot {
display: inline-block;
$size: 6px;
width: $size;
height: $size;
border-radius: 50%;
margin-right: 10px; margin-right: 10px;
} }
} }

View File

@ -0,0 +1,29 @@
import { useGetConfigMapDataTabProps } from '@shell/components/Resource/Detail/ResourceTabs/ConfigMapDataTab/composables';
import { base64Encode } from '@shell/utils/crypto';
describe('composables: ConfigMapDataTab', () => {
const textData = 'This is textData';
const binaryData = 'This is binaryData';
const binaryDataBase64 = base64Encode(binaryData);
it('should handle no data', () => {
const props = useGetConfigMapDataTabProps({});
expect(props.rows).toHaveLength(0);
});
it('should handle text and binary data at the same time', () => {
const data = { text: textData };
const bData = { binary: binaryDataBase64 };
const props = useGetConfigMapDataTabProps({ data, binaryData: bData });
expect(props.rows[0].key).toStrictEqual('text');
expect(props.rows[0].value).toStrictEqual(textData);
expect(props.rows[0].binary).toStrictEqual(false);
expect(props.rows[1].key).toStrictEqual('binary');
expect(props.rows[1].value).toStrictEqual(binaryData);
expect(props.rows[1].binary).toStrictEqual(false);
});
});

View File

@ -0,0 +1,48 @@
import { mount } from '@vue/test-utils';
import ConfigMapDataTab from '@shell/components/Resource/Detail/ResourceTabs/ConfigMapDataTab/index.vue';
jest.mock('clipboard-polyfill', () => {});
jest.mock('vuex');
jest.mock('@shell/components/DetailText.vue', () => ({
name: 'DetailText',
template: '<div>DetailText</div>',
props: {
value: { type: String }, label: { type: String }, binary: { type: Boolean }
}
}));
describe('component: ConfigMapDataTab/index', () => {
const global = {
provide: {
addTab: jest.fn(), removeTab: jest.fn(), sideTabs: false,
},
directives: { 'clean-tooltip': () => { } }
};
it('should render the now rows message', async() => {
const wrapper = mount(ConfigMapDataTab, {
props: { rows: [] },
global
});
expect(wrapper.find('.no-rows').text()).toStrictEqual('sortableTable.noRows');
});
it('should render DetailText with the appropriate props from row', async() => {
const row = {
key: 'ROW',
value: 'VALUE',
binary: false
};
const wrapper = mount(ConfigMapDataTab, {
props: { rows: [row] },
global
});
const component = wrapper.getComponent({ name: 'DetailText' });
expect(component.props('value')).toStrictEqual(row.value);
expect(component.props('label')).toStrictEqual(row.key);
expect(component.props('binary')).toStrictEqual(row.binary);
});
});

View File

@ -0,0 +1,31 @@
import { Props } from '@shell/components/Resource/Detail/ResourceTabs/ConfigMapDataTab/index.vue';
import { computed } from 'vue';
import { base64Decode } from '@shell/utils/crypto';
export const useGetConfigMapDataTabProps = (configMap: any): Props => {
const rows = computed(() => {
const rows: any[] = [];
const { data = {}, binaryData = {} } = configMap;
Object.keys(data).forEach((key) => {
rows.push({
key,
value: data[key],
binary: false
});
});
// we define the binary as false so that the ui doesn't display the size of the binary instead of the actual data...
Object.keys(binaryData).forEach((key) => {
rows.push({
key,
value: base64Decode(binaryData[key]),
binary: false
});
});
return rows;
});
return { rows: rows.value };
};

View File

@ -0,0 +1,50 @@
<script lang="ts">
import { useStore } from 'vuex';
import Tab from '@shell/components/Tabbed/Tab.vue';
import { useI18n } from '@shell/composables/useI18n';
import DetailText from '@shell/components/DetailText.vue';
export interface Row {
key: string;
value: any;
binary: boolean;
}
export interface Props {
rows: Row[];
weight?: number;
}
</script>
<script lang="ts" setup>
const { weight, rows } = defineProps<Props>();
const store = useStore();
const i18n = useI18n(store);
</script>
<template>
<Tab
name="data"
label-key="secret.data"
:weight="weight"
>
<div
v-for="(row,idx) in rows"
:key="idx"
class="mb-20"
>
<DetailText
:value="row.value"
:label="row.key"
:binary="row.binary"
/>
</div>
<div v-if="!rows.length">
<div class="m-20 text-center no-rows">
{{ i18n.t('sortableTable.noRows') }}
</div>
</div>
</Tab>
</template>

View File

@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import Title from '@shell/components/Resource/Detail/TitleBar/Title.vue'; import Title from '@shell/components/Resource/Detail/TitleBar/Title.vue';
describe('component: TitleBar/Title', () => { describe('component: TitleBar/Title', () => {
it('shoulder render container with class title', async() => { it('should render container with class title', async() => {
const wrapper = mount(Title); const wrapper = mount(Title);
expect(wrapper.find('.title').exists()).toBeTruthy(); expect(wrapper.find('.title').exists()).toBeTruthy();

View File

@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils';
import Top from '@shell/components/Resource/Detail/TitleBar/Top.vue'; import Top from '@shell/components/Resource/Detail/TitleBar/Top.vue';
describe('component: TitleBar/Top', () => { describe('component: TitleBar/Top', () => {
it('shoulder render container with class top', async() => { it('should render container with class top', async() => {
const wrapper = mount(Top); const wrapper = mount(Top);
expect(wrapper.find('.top').exists()).toBeTruthy(); expect(wrapper.find('.top').exists()).toBeTruthy();

View File

@ -0,0 +1,45 @@
import { useDefaultTitleBarProps } from '@shell/components/Resource/Detail/TitleBar/composables';
import { useRoute } from 'vue-router';
const mockStore = { getters: { 'type-map/labelFor': jest.fn() } };
const mockRoute = { params: { cluster: 'CLUSTER' } };
const mockDrawer = { openResourceDetailDrawer: jest.fn() };
jest.mock('vuex', () => ({ useStore: () => mockStore }));
jest.mock('vue-router', () => ({ useRoute: () => mockRoute }));
jest.mock('@shell/components/Drawer/ResourceDetailDrawer/composables', () => ({ useResourceDetailDrawer: () => mockDrawer }));
describe('composables: TitleBar', () => {
const resource = {
nameDisplay: 'RESOURCE_NAME',
namespace: 'RESOURCE_NAMESPACE',
type: 'RESOURCE_TYPE',
stateBackground: 'RESOURCE_STATE_BACKGROUND',
stateDisplay: 'RESOURCE_STATE_DISPLAY',
description: 'RESOURCE_DESCRIPTION',
};
const labelFor = 'LABEL_FOR';
it('should return the appropriate values based on input', async() => {
const route = useRoute();
mockStore.getters['type-map/labelFor'].mockImplementation(() => labelFor);
const props = useDefaultTitleBarProps(resource);
expect(props.value.resourceTypeLabel).toStrictEqual(labelFor);
expect(mockStore.getters['type-map/labelFor']).toHaveBeenLastCalledWith({ id: resource.type });
expect(props.value.resourceTo?.params.product).toStrictEqual('explorer');
expect(props.value.resourceTo?.params.cluster).toStrictEqual(route.params.cluster);
expect(props.value.resourceTo?.params.namespace).toStrictEqual(resource.namespace);
expect(props.value.resourceTo?.params.resource).toStrictEqual(resource.type);
expect(props.value.resourceName).toStrictEqual(resource.nameDisplay);
expect(props.value.actionMenuResource).toStrictEqual(resource);
expect(props.value.badge?.color).toStrictEqual(resource.stateBackground);
expect(props.value.badge?.label).toStrictEqual(resource.stateDisplay);
expect(props.value.description).toStrictEqual(resource.description);
props.value.onShowConfiguration?.();
expect(mockDrawer.openResourceDetailDrawer).toHaveBeenCalledTimes(1);
});
});

View File

@ -11,7 +11,7 @@ describe('component: TitleBar/index', () => {
const resourceName = 'RESOURCE_NAME'; const resourceName = 'RESOURCE_NAME';
const store = createStore({}); const store = createStore({});
it('shoulder render container with class title-bar', async() => { it('should render container with class title-bar', async() => {
const wrapper = mount(TitleBar, { const wrapper = mount(TitleBar, {
props: { props: {
resourceTypeLabel, resourceTypeLabel,

View File

@ -1,12 +1,13 @@
import { useResourceDetailDrawer } from '@shell/components/Drawer/ResourceDetailDrawer/composables';
import { TitleBarProps } from '@shell/components/Resource/Detail/TitleBar/index.vue'; import { TitleBarProps } from '@shell/components/Resource/Detail/TitleBar/index.vue';
import { computed, Ref, toValue } from 'vue'; import { computed, Ref, toValue } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
export const useDefaultTitleBarData = (resource: any): Ref<TitleBarProps> => { export const useDefaultTitleBarProps = (resource: any): Ref<TitleBarProps> => {
const route = useRoute(); const route = useRoute();
const store = useStore(); const store = useStore();
const { openResourceDetailDrawer } = useResourceDetailDrawer();
const resourceValue = toValue(resource); const resourceValue = toValue(resource);
return computed(() => ({ return computed(() => ({
@ -26,6 +27,7 @@ export const useDefaultTitleBarData = (resource: any): Ref<TitleBarProps> => {
color: resourceValue.stateBackground, color: resourceValue.stateBackground,
label: resourceValue.stateDisplay label: resourceValue.stateDisplay
}, },
onShowConfiguration: () => resourceValue.goToEdit() description: resourceValue.description,
onShowConfiguration: () => openResourceDetailDrawer(resourceValue)
})); }));
}; };

View File

@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import BadgeState from '@pkg/rancher-components/src/components/BadgeState/BadgeState.vue'; import BadgeState from '@components/BadgeState/BadgeState.vue';
import { RouteLocationRaw } from 'vue-router'; import { RouteLocationRaw } from 'vue-router';
import Title from '@shell/components/Resource/Detail/TitleBar/Title.vue'; import Title from '@shell/components/Resource/Detail/TitleBar/Title.vue';
import Top from '@shell/components/Resource/Detail/TitleBar/Top.vue'; import Top from '@shell/components/Resource/Detail/TitleBar/Top.vue';
import ActionMenu from '@shell/components/ActionMenuShell.vue'; import ActionMenu from '@shell/components/ActionMenuShell.vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import RcButton from '~/pkg/rancher-components/src/components/RcButton/RcButton.vue'; import RcButton from '@components/RcButton/RcButton.vue';
export interface Badge { export interface Badge {
color: 'bg-success' | 'bg-error' | 'bg-warning' | 'bg-info'; color: 'bg-success' | 'bg-error' | 'bg-warning' | 'bg-info';
@ -84,10 +84,10 @@ const emit = defineEmits(['show-configuration']);
</RcButton> </RcButton>
<ActionMenu <ActionMenu
v-if="actionMenuResource" v-if="actionMenuResource"
class="title-bar-action-menu"
button-role="multiAction" button-role="multiAction"
:resource="actionMenuResource" :resource="actionMenuResource"
data-testid="masthead-action-menu" data-testid="masthead-action-menu"
:button-aria-label="i18n.t('component.resource.detail.titleBar.ariaLabel.actionMenu', { resource: resourceName })"
/> />
</div> </div>
</Top> </Top>
@ -120,5 +120,9 @@ const emit = defineEmits(['show-configuration']);
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
.description {
max-width: 60%;
}
} }
</style> </style>

View File

@ -289,6 +289,9 @@ export default {
} }
}, },
refresh() {
this.$refs.yamleditor.refresh();
}
} }
}; };
</script> </script>

View File

@ -15,6 +15,11 @@ const currentComponent = computed(() => store.getters['slideInPanel/component'])
const currentProps = computed(() => store.getters['slideInPanel/componentProps']); const currentProps = computed(() => store.getters['slideInPanel/componentProps']);
const panelTop = computed(() => { const panelTop = computed(() => {
// Some components like the ResourceDetailDrawer are designed to take up the full height of the viewport so we want to be able to specify the top.
if (currentProps?.value?.top) {
return currentProps?.value?.top;
}
const banner = document.getElementById('banner-header'); const banner = document.getElementById('banner-header');
let height = HEADER_HEIGHT; let height = HEADER_HEIGHT;
@ -25,7 +30,8 @@ const panelTop = computed(() => {
return `${ height }px`; return `${ height }px`;
}); });
const panelHeight = computed(() => `calc(100vh - ${ panelTop?.value })`); // Some components like the ResourceDetailDrawer are designed to take up the full height of the viewport so we want to be able to specify the height.
const panelHeight = computed(() => (currentProps?.value?.height) ? (currentProps?.value?.height) : `calc(100vh - ${ panelTop?.value })`);
const panelWidth = computed(() => currentProps?.value?.width || '33%'); const panelWidth = computed(() => currentProps?.value?.width || '33%');
const panelRight = computed(() => (isOpen?.value ? '0' : `-${ panelWidth?.value }`)); const panelRight = computed(() => (isOpen?.value ? '0' : `-${ panelWidth?.value }`));
const panelZIndex = computed(() => `${ (isOpen?.value ? 1 : 2) * (currentProps?.value?.zIndex ?? 1000) }`); const panelZIndex = computed(() => `${ (isOpen?.value ? 1 : 2) * (currentProps?.value?.zIndex ?? 1000) }`);
@ -100,6 +106,9 @@ function closePanel() {
data-testid="slide-in-glass" data-testid="slide-in-glass"
class="slide-in-glass" class="slide-in-glass"
:class="{ 'slide-in-glass-open': isOpen }" :class="{ 'slide-in-glass-open': isOpen }"
:style="{
['z-index']: panelZIndex
}"
@click="closePanel" @click="closePanel"
/> />
<div <div

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { StateColor, stateColorCssVar } from '@shell/utils/style';
import { computed } from 'vue';
interface Props {
color: StateColor;
size?: string;
}
const props = withDefaults(defineProps<Props>(), { size: '8px' });
const backgroundColor = computed(() => stateColorCssVar(props.color));
</script>
<template>
<span class="state-dot" />
</template>
<style lang="scss" scoped>
.state-dot {
display: inline-block;
width: v-bind('props.size');
height: v-bind('props.size');
border-radius: 50%;
background-color: v-bind('backgroundColor');
}
</style>

View File

@ -68,6 +68,10 @@ export default {
extensionParams: { extensionParams: {
type: Object, type: Object,
default: null default: null
},
useHash: {
type: Boolean,
default: true
} }
}, },
@ -233,6 +237,7 @@ export default {
v-bind="$attrs" v-bind="$attrs"
:default-tab="defaultTab" :default-tab="defaultTab"
:resource="value" :resource="value"
:use-hash="useHash"
@changed="tabChange" @changed="tabChange"
> >
<slot /> <slot />

View File

@ -0,0 +1,22 @@
import { Component } from 'vue';
import { useStore } from 'vuex';
export const useDrawer = () => {
const store = useStore();
const open = (component: Component, options?: Record<string, any>) => {
store.commit('slideInPanel/open', {
component,
componentProps: options || {}
});
};
const close = () => {
store.commit('slideInPanel/close');
};
return {
open,
close
};
};

View File

@ -0,0 +1,30 @@
import { computed, Ref, toValue } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
type ResourceType = string | Ref<string>;
type IdType = string | Ref<string>;
export const useResourceIdentifiers = (type: ResourceType) => {
const route = useRoute();
const store = useStore();
const typeValue = toValue(type);
const id = computed(() => route.params.namespace ? `${ route.params.namespace }/${ route.params.id }` : `${ route.params.id }`);
const schema = computed(() => store.getters['cluster/schemaFor'](typeValue));
const clusterId = computed(() => route.params.cluster as string);
return {
clusterId: clusterId.value, id: id.value, schema: schema.value
};
};
export const useFetchResourceWithId = async(type: ResourceType, id: IdType) => {
const store = useStore();
const typeValue = toValue(type);
const idValue = toValue(id);
return await store.dispatch('cluster/find', { type: typeValue, id: idValue });
};

View File

@ -105,7 +105,10 @@ export default {
:register-before-hook="registerBeforeHook" :register-before-hook="registerBeforeHook"
/> />
<Tabbed :side-tabs="true"> <Tabbed
:side-tabs="true"
:use-hash="useTabbedHash"
>
<Tab <Tab
name="data" name="data"
:label="t('configmap.tabs.data.label')" :label="t('configmap.tabs.data.label')"

View File

@ -44,5 +44,10 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
useTabbedHash: {
type: Boolean,
default: true
}
}, },
}); });

View File

@ -1,19 +1,42 @@
<script setup lang="ts"> <script setup lang="ts">
import DetailPage from '@shell/components/Resource/Detail/Page.vue'; import DetailPage from '@shell/components/Resource/Detail/Page.vue';
import TitleBar from '@shell/components/Resource/Detail/TitleBar/index.vue';
import { useDefaultTitleBarProps } from '@shell/components/Resource/Detail/TitleBar/composables';
import Metadata from '@shell/components/Resource/Detail/Metadata/index.vue';
import { useDefaultMetadataProps } from '@shell/components/Resource/Detail/Metadata/composables';
import { CONFIG_MAP } from '@shell/config/types'; import { CONFIG_MAP } from '@shell/config/types';
import { useStore } from 'vuex'; import { useFetchResourceWithId, useResourceIdentifiers } from '@shell/composables/resources';
import ResourceTabs from '@shell/components/form/ResourceTabs/index.vue';
const store = useStore(); import ConfigMapDataTab from '@shell/components/Resource/Detail/ResourceTabs/ConfigMapDataTab/index.vue';
const configMaps = await store.dispatch('cluster/findAll', { type: CONFIG_MAP }); import { useGetConfigMapDataTabProps } from '@shell/components/Resource/Detail/ResourceTabs/ConfigMapDataTab/composables';
const { id, schema } = useResourceIdentifiers(CONFIG_MAP);
const configMap = await useFetchResourceWithId(CONFIG_MAP, id);
const titleBarProps = useDefaultTitleBarProps(configMap);
const metadataProps = useDefaultMetadataProps(configMap);
const configMapDataTabProps = useGetConfigMapDataTabProps(configMap);
</script> </script>
<template> <template>
<DetailPage> <DetailPage>
<template #top-area> <template #top-area>
ConfigMap <TitleBar
v-bind="titleBarProps"
/>
<Metadata
class="mmt-6"
v-bind="metadataProps"
/>
</template> </template>
<template #middle-area> <template #bottom-area>
{{ configMaps }} <ResourceTabs
:value="configMap"
:schema="schema"
>
<ConfigMapDataTab
v-bind="configMapDataTabProps"
/>
</ResourceTabs>
</template> </template>
</DetailPage> </DetailPage>
</template> </template>

View File

@ -736,6 +736,17 @@ export default class Resource {
); );
} }
get stateColorPair() {
return {
state: this.stateDisplay,
color: this.stateSimpleColor
};
}
get stateSimpleColor() {
return this.stateColor.replace('text-', '');
}
get stateBackground() { get stateBackground() {
return this.stateColor.replace('text-', 'bg-'); return this.stateColor.replace('text-', 'bg-');
} }