diff --git a/shell/assets/styles/themes/_dark.scss b/shell/assets/styles/themes/_dark.scss index 607079ce31..fee1a3a763 100644 --- a/shell/assets/styles/themes/_dark.scss +++ b/shell/assets/styles/themes/_dark.scss @@ -165,6 +165,7 @@ --tabbed-container-bg : #{mix($medium, $dark, 20%)}; --yaml-editor-bg : #{$darkest}; + --drawer-body-bg : #{$darkest}; --diff-border : var(--border); --diff-header-bg : var(--nav-bg); diff --git a/shell/assets/styles/themes/_light.scss b/shell/assets/styles/themes/_light.scss index 9da391e6c1..e12e965201 100644 --- a/shell/assets/styles/themes/_light.scss +++ b/shell/assets/styles/themes/_light.scss @@ -483,6 +483,7 @@ BODY, .theme-light { --tabbed-container-bg : #{mix($light, $lighter, 15%)}; --yaml-editor-bg : #{$lighter}; + --drawer-body-bg : #{$lighter}; --diff-border : var(--border); --diff-header-bg : var(--nav-bg); diff --git a/shell/assets/translations/en-us.yaml b/shell/assets/translations/en-us.yaml index dcf7b5fbfc..c43b3404d8 100644 --- a/shell/assets/translations/en-us.yaml +++ b/shell/assets/translations/en-us.yaml @@ -8505,6 +8505,22 @@ errors: withUrl: '{msg}: {url}' withoutUrl: '{msg}' 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: detail: card: diff --git a/shell/components/Drawer/Chrome.vue b/shell/components/Drawer/Chrome.vue new file mode 100644 index 0000000000..f67c71e70f --- /dev/null +++ b/shell/components/Drawer/Chrome.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/shell/components/Drawer/ResourceDetailDrawer/ConfigTab.vue b/shell/components/Drawer/ResourceDetailDrawer/ConfigTab.vue new file mode 100644 index 0000000000..e8acd394ca --- /dev/null +++ b/shell/components/Drawer/ResourceDetailDrawer/ConfigTab.vue @@ -0,0 +1,49 @@ + + + + + diff --git a/shell/components/Drawer/ResourceDetailDrawer/YamlTab.vue b/shell/components/Drawer/ResourceDetailDrawer/YamlTab.vue new file mode 100644 index 0000000000..609656b712 --- /dev/null +++ b/shell/components/Drawer/ResourceDetailDrawer/YamlTab.vue @@ -0,0 +1,48 @@ + + + + + diff --git a/shell/components/Drawer/ResourceDetailDrawer/__tests__/ConfigTab.test.ts b/shell/components/Drawer/ResourceDetailDrawer/__tests__/ConfigTab.test.ts new file mode 100644 index 0000000000..6a8fe8c5f5 --- /dev/null +++ b/shell/components/Drawer/ResourceDetailDrawer/__tests__/ConfigTab.test.ts @@ -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: '
DynamicComponent
', + 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); + }); +}); diff --git a/shell/components/Drawer/ResourceDetailDrawer/__tests__/YamlTab.test.ts b/shell/components/Drawer/ResourceDetailDrawer/__tests__/YamlTab.test.ts new file mode 100644 index 0000000000..1880ae70ba --- /dev/null +++ b/shell/components/Drawer/ResourceDetailDrawer/__tests__/YamlTab.test.ts @@ -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: `
ResourceYaml
`, + 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); + }); +}); diff --git a/shell/components/Drawer/ResourceDetailDrawer/__tests__/composables.test.ts b/shell/components/Drawer/ResourceDetailDrawer/__tests__/composables.test.ts new file mode 100644 index 0000000000..3cefae9e86 --- /dev/null +++ b/shell/components/Drawer/ResourceDetailDrawer/__tests__/composables.test.ts @@ -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 + }); + }); + }); +}); diff --git a/shell/components/Drawer/ResourceDetailDrawer/__tests__/helpers.test.ts b/shell/components/Drawer/ResourceDetailDrawer/__tests__/helpers.test.ts new file mode 100644 index 0000000000..bd1e06a495 --- /dev/null +++ b/shell/components/Drawer/ResourceDetailDrawer/__tests__/helpers.test.ts @@ -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); + }); + }); +}); diff --git a/shell/components/Drawer/ResourceDetailDrawer/composables.ts b/shell/components/Drawer/ResourceDetailDrawer/composables.ts new file mode 100644 index 0000000000..647756c2f7 --- /dev/null +++ b/shell/components/Drawer/ResourceDetailDrawer/composables.ts @@ -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 { + 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) + }; +} diff --git a/shell/components/Drawer/ResourceDetailDrawer/helpers.ts b/shell/components/Drawer/ResourceDetailDrawer/helpers.ts new file mode 100644 index 0000000000..46073bb719 --- /dev/null +++ b/shell/components/Drawer/ResourceDetailDrawer/helpers.ts @@ -0,0 +1,10 @@ +export async function getYaml(resource: any): Promise { + let yaml; + const opt = { headers: { accept: 'application/yaml' } }; + + if (resource.hasLink('view')) { + yaml = (await resource.followLink('view', opt)).data; + } + + return resource.cleanForDownload(yaml); +} diff --git a/shell/components/Drawer/ResourceDetailDrawer/index.vue b/shell/components/Drawer/ResourceDetailDrawer/index.vue new file mode 100644 index 0000000000..31c5c689bd --- /dev/null +++ b/shell/components/Drawer/ResourceDetailDrawer/index.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/shell/components/Resource/Detail/Additional.vue b/shell/components/Resource/Detail/Additional.vue new file mode 100644 index 0000000000..dfbe8aaf41 --- /dev/null +++ b/shell/components/Resource/Detail/Additional.vue @@ -0,0 +1,46 @@ + + + + + + + diff --git a/shell/components/Resource/Detail/Metadata/Annotations/__tests__/index.test.ts b/shell/components/Resource/Detail/Metadata/Annotations/__tests__/index.test.ts index 1158f428d0..458cd0bdc1 100644 --- a/shell/components/Resource/Detail/Metadata/Annotations/__tests__/index.test.ts +++ b/shell/components/Resource/Detail/Metadata/Annotations/__tests__/index.test.ts @@ -3,7 +3,7 @@ import Annotations from '@shell/components/Resource/Detail/Metadata/Annotations/ import { createStore } from 'vuex'; 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 wrapper = mount(Annotations, { props: { annotations }, diff --git a/shell/components/Resource/Detail/Metadata/Annotations/index.vue b/shell/components/Resource/Detail/Metadata/Annotations/index.vue index d33d8d9d59..a7f14214e4 100644 --- a/shell/components/Resource/Detail/Metadata/Annotations/index.vue +++ b/shell/components/Resource/Detail/Metadata/Annotations/index.vue @@ -7,12 +7,15 @@ export type Annotation = Row; export interface AnnotationsProps { annotations: Annotation[]; + + onShowConfiguration?: () => void; } @@ -22,5 +25,7 @@ const i18n = useI18n(store); :propertyName="i18n.t('component.resource.detail.metadata.annotations.title')" :rows="annotations" :outline="true" + + @show-configuration="() => emit('show-configuration')" /> diff --git a/shell/components/Resource/Detail/Metadata/IdentifyingInformation/composable.ts b/shell/components/Resource/Detail/Metadata/IdentifyingInformation/composable.ts index 4dfff8216b..bfe9cb5da1 100644 --- a/shell/components/Resource/Detail/Metadata/IdentifyingInformation/composable.ts +++ b/shell/components/Resource/Detail/Metadata/IdentifyingInformation/composable.ts @@ -1,223 +1,9 @@ import { useI18n } from '@shell/composables/useI18n'; import { computed, ComputedRef, markRaw, toValue } from '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 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'; -export const useContainerRuntime = (resource: any): ComputedRef => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { - 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 => { const store = useStore(); const i18n = useI18n(store); @@ -233,18 +19,6 @@ export const useLiveDate = (resource: any): ComputedRef => { })); }; -export const useProject = (resource: any): ComputedRef => { - 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 => { const store = useStore(); const i18n = useI18n(store); @@ -259,23 +33,3 @@ export const useDefaultIdentifyingInformation = (resource: any): ComputedRef => { - 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, - }, - ]); -}; diff --git a/shell/components/Resource/Detail/Metadata/IdentifyingInformation/index.vue b/shell/components/Resource/Detail/Metadata/IdentifyingInformation/index.vue index 14c60973c2..32252b5d81 100644 --- a/shell/components/Resource/Detail/Metadata/IdentifyingInformation/index.vue +++ b/shell/components/Resource/Detail/Metadata/IdentifyingInformation/index.vue @@ -50,12 +50,16 @@ const { rows } = defineProps(); :class="['status', row.status]" /> {{ row.value }} - {{ row.value }} + {{ row.value }} + diff --git a/shell/components/Resource/Detail/Metadata/KeyValue.vue b/shell/components/Resource/Detail/Metadata/KeyValue.vue index 8bd61e444b..9171fab994 100644 --- a/shell/components/Resource/Detail/Metadata/KeyValue.vue +++ b/shell/components/Resource/Detail/Metadata/KeyValue.vue @@ -30,6 +30,7 @@ const { const store = useStore(); const i18n = useI18n(store); +const emit = defineEmits(['show-configuration']); // Account for the show all button 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 }`;
{{ i18n.t('component.resource.detail.metadata.keyValue.noRows', {propertyName: lowercasePropertyName}) }}
- {{ i18n.t('component.resource.detail.metadata.keyValue.showConfiguration') }} - +
`${ row.key }: ${ row.value }`; {{ displayValue(row) }}
- {{ showAllLabel }} - + @@ -101,6 +104,8 @@ const displayValue = (row: Row) => `${ row.key }: ${ row.value }`; } .row { + width: 100%; + &:not(:first-of-type) { margin-top: 4px; } @@ -109,13 +114,6 @@ const displayValue = (row: Row) => `${ row.key }: ${ row.value }`; margin-top: 8px; } } - .row { - margin-top: 8px; - - &:not(:first-of-type) { - margin-top: 4px; - } - } .show-all { margin-top: 8px; } diff --git a/shell/components/Resource/Detail/Metadata/Labels/__tests__/index.test.ts b/shell/components/Resource/Detail/Metadata/Labels/__tests__/index.test.ts index 289bebf43b..b1ba052e21 100644 --- a/shell/components/Resource/Detail/Metadata/Labels/__tests__/index.test.ts +++ b/shell/components/Resource/Detail/Metadata/Labels/__tests__/index.test.ts @@ -3,7 +3,7 @@ import Labels from '@shell/components/Resource/Detail/Metadata/Labels/index.vue' import { createStore } from 'vuex'; 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 wrapper = mount(Labels, { props: { labels }, diff --git a/shell/components/Resource/Detail/Metadata/Labels/index.vue b/shell/components/Resource/Detail/Metadata/Labels/index.vue index e738fd9f09..8d06f1bade 100644 --- a/shell/components/Resource/Detail/Metadata/Labels/index.vue +++ b/shell/components/Resource/Detail/Metadata/Labels/index.vue @@ -7,6 +7,8 @@ import { useStore } from 'vuex'; export type Label = Row; export interface LabelsProps { labels: Label[]; + + onShowConfiguration?: () => void; } @@ -14,6 +16,7 @@ export interface LabelsProps { + + + + diff --git a/shell/components/Resource/Detail/TitleBar/__tests__/Title.test.ts b/shell/components/Resource/Detail/TitleBar/__tests__/Title.test.ts index 0d3a36b051..843c64c5ad 100644 --- a/shell/components/Resource/Detail/TitleBar/__tests__/Title.test.ts +++ b/shell/components/Resource/Detail/TitleBar/__tests__/Title.test.ts @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'; import Title from '@shell/components/Resource/Detail/TitleBar/Title.vue'; describe('component: TitleBar/Title', () => { - it('shoulder render container with class title', async() => { + it('should render container with class title', async() => { const wrapper = mount(Title); expect(wrapper.find('.title').exists()).toBeTruthy(); diff --git a/shell/components/Resource/Detail/TitleBar/__tests__/Top.test.ts b/shell/components/Resource/Detail/TitleBar/__tests__/Top.test.ts index c0b3f6ccb6..4a6416ed6e 100644 --- a/shell/components/Resource/Detail/TitleBar/__tests__/Top.test.ts +++ b/shell/components/Resource/Detail/TitleBar/__tests__/Top.test.ts @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'; import Top from '@shell/components/Resource/Detail/TitleBar/Top.vue'; describe('component: TitleBar/Top', () => { - it('shoulder render container with class top', async() => { + it('should render container with class top', async() => { const wrapper = mount(Top); expect(wrapper.find('.top').exists()).toBeTruthy(); diff --git a/shell/components/Resource/Detail/TitleBar/__tests__/composables.test.ts b/shell/components/Resource/Detail/TitleBar/__tests__/composables.test.ts new file mode 100644 index 0000000000..066ad2d80c --- /dev/null +++ b/shell/components/Resource/Detail/TitleBar/__tests__/composables.test.ts @@ -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); + }); +}); diff --git a/shell/components/Resource/Detail/TitleBar/__tests__/index.test.ts b/shell/components/Resource/Detail/TitleBar/__tests__/index.test.ts index bd72e9f5b8..d5fbc18d14 100644 --- a/shell/components/Resource/Detail/TitleBar/__tests__/index.test.ts +++ b/shell/components/Resource/Detail/TitleBar/__tests__/index.test.ts @@ -11,7 +11,7 @@ describe('component: TitleBar/index', () => { const resourceName = 'RESOURCE_NAME'; 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, { props: { resourceTypeLabel, diff --git a/shell/components/Resource/Detail/TitleBar/composable.ts b/shell/components/Resource/Detail/TitleBar/composables.ts similarity index 70% rename from shell/components/Resource/Detail/TitleBar/composable.ts rename to shell/components/Resource/Detail/TitleBar/composables.ts index 768b66fdb0..e80196af72 100644 --- a/shell/components/Resource/Detail/TitleBar/composable.ts +++ b/shell/components/Resource/Detail/TitleBar/composables.ts @@ -1,12 +1,13 @@ +import { useResourceDetailDrawer } from '@shell/components/Drawer/ResourceDetailDrawer/composables'; import { TitleBarProps } from '@shell/components/Resource/Detail/TitleBar/index.vue'; import { computed, Ref, toValue } from 'vue'; import { useRoute } from 'vue-router'; import { useStore } from 'vuex'; -export const useDefaultTitleBarData = (resource: any): Ref => { +export const useDefaultTitleBarProps = (resource: any): Ref => { const route = useRoute(); const store = useStore(); - + const { openResourceDetailDrawer } = useResourceDetailDrawer(); const resourceValue = toValue(resource); return computed(() => ({ @@ -26,6 +27,7 @@ export const useDefaultTitleBarData = (resource: any): Ref => { color: resourceValue.stateBackground, label: resourceValue.stateDisplay }, - onShowConfiguration: () => resourceValue.goToEdit() + description: resourceValue.description, + onShowConfiguration: () => openResourceDetailDrawer(resourceValue) })); }; diff --git a/shell/components/Resource/Detail/TitleBar/index.vue b/shell/components/Resource/Detail/TitleBar/index.vue index cd34d0c128..fd4d75aed8 100644 --- a/shell/components/Resource/Detail/TitleBar/index.vue +++ b/shell/components/Resource/Detail/TitleBar/index.vue @@ -1,12 +1,12 @@ diff --git a/shell/components/SlideInPanelManager.vue b/shell/components/SlideInPanelManager.vue index 7caba880d1..1d9684da19 100644 --- a/shell/components/SlideInPanelManager.vue +++ b/shell/components/SlideInPanelManager.vue @@ -15,6 +15,11 @@ const currentComponent = computed(() => store.getters['slideInPanel/component']) const currentProps = computed(() => store.getters['slideInPanel/componentProps']); 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'); let height = HEADER_HEIGHT; @@ -25,7 +30,8 @@ const panelTop = computed(() => { 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 panelRight = computed(() => (isOpen?.value ? '0' : `-${ panelWidth?.value }`)); const panelZIndex = computed(() => `${ (isOpen?.value ? 1 : 2) * (currentProps?.value?.zIndex ?? 1000) }`); @@ -100,6 +106,9 @@ function closePanel() { data-testid="slide-in-glass" class="slide-in-glass" :class="{ 'slide-in-glass-open': isOpen }" + :style="{ + ['z-index']: panelZIndex + }" @click="closePanel" />
+import { StateColor, stateColorCssVar } from '@shell/utils/style'; +import { computed } from 'vue'; + +interface Props { + color: StateColor; + size?: string; +} + +const props = withDefaults(defineProps(), { size: '8px' }); +const backgroundColor = computed(() => stateColorCssVar(props.color)); + + + + + diff --git a/shell/components/form/ResourceTabs/index.vue b/shell/components/form/ResourceTabs/index.vue index cf6300b681..b29854b564 100644 --- a/shell/components/form/ResourceTabs/index.vue +++ b/shell/components/form/ResourceTabs/index.vue @@ -68,6 +68,10 @@ export default { extensionParams: { type: Object, default: null + }, + useHash: { + type: Boolean, + default: true } }, @@ -233,6 +237,7 @@ export default { v-bind="$attrs" :default-tab="defaultTab" :resource="value" + :use-hash="useHash" @changed="tabChange" > diff --git a/shell/composables/drawer.ts b/shell/composables/drawer.ts new file mode 100644 index 0000000000..b36f33db05 --- /dev/null +++ b/shell/composables/drawer.ts @@ -0,0 +1,22 @@ +import { Component } from 'vue'; +import { useStore } from 'vuex'; + +export const useDrawer = () => { + const store = useStore(); + + const open = (component: Component, options?: Record) => { + store.commit('slideInPanel/open', { + component, + componentProps: options || {} + }); + }; + + const close = () => { + store.commit('slideInPanel/close'); + }; + + return { + open, + close + }; +}; diff --git a/shell/composables/resources.ts b/shell/composables/resources.ts new file mode 100644 index 0000000000..45500849e5 --- /dev/null +++ b/shell/composables/resources.ts @@ -0,0 +1,30 @@ +import { computed, Ref, toValue } from 'vue'; +import { useRoute } from 'vue-router'; +import { useStore } from 'vuex'; + +type ResourceType = string | Ref; +type IdType = string | Ref; + +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 }); +}; diff --git a/shell/edit/configmap.vue b/shell/edit/configmap.vue index eb2d7a79e8..f703bfa0c4 100644 --- a/shell/edit/configmap.vue +++ b/shell/edit/configmap.vue @@ -105,7 +105,10 @@ export default { :register-before-hook="registerBeforeHook" /> - + 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 { useStore } from 'vuex'; - -const store = useStore(); -const configMaps = await store.dispatch('cluster/findAll', { type: CONFIG_MAP }); +import { useFetchResourceWithId, useResourceIdentifiers } from '@shell/composables/resources'; +import ResourceTabs from '@shell/components/form/ResourceTabs/index.vue'; +import ConfigMapDataTab from '@shell/components/Resource/Detail/ResourceTabs/ConfigMapDataTab/index.vue'; +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); +