-
Notifications
You must be signed in to change notification settings - Fork 334
[11513] Added workload-dashboard #17728
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 21 commits
c5cb980
7c1a8a4
4091873
17992fe
ecdfeef
27fff42
37bab37
42bbb23
2f82e5f
37c8f3d
100cf2d
35451b7
15e0278
9d93cec
f1f339c
1d5116e
c9b94cc
16dd1c5
0d9ee46
36431d6
201d7bd
e618784
4ba249b
e10e265
5d89ac0
c518a45
2f193b6
6a9058b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import PagePo from '@/cypress/e2e/po/pages/page.po'; | ||
| import BurgerMenuPo from '@/cypress/e2e/po/side-bars/burger-side-menu.po'; | ||
| import ProductNavPo from '@/cypress/e2e/po/side-bars/product-side-nav.po'; | ||
| import CardPo from '@/cypress/e2e/po/components/Resource/Detail/Card/statusCard.po'; | ||
| import BannersPo from '@/cypress/e2e/po/components/banners.po'; | ||
|
|
||
| export default class WorkloadDashboardPagePo extends PagePo { | ||
| private static createPath(clusterId: string) { | ||
| return `/c/${ clusterId }/explorer/workload-dashboard`; | ||
| } | ||
|
|
||
| static goTo(clusterId: string): Cypress.Chainable<Cypress.AUTWindow> { | ||
| return super.goTo(WorkloadDashboardPagePo.createPath(clusterId)); | ||
| } | ||
|
|
||
| constructor(clusterId = 'local') { | ||
| super(WorkloadDashboardPagePo.createPath(clusterId)); | ||
| } | ||
|
|
||
| static navTo(clusterId = 'local') { | ||
| const burgerMenu = new BurgerMenuPo(); | ||
| const sideNav = new ProductNavPo(); | ||
|
|
||
| burgerMenu.goToCluster(clusterId); | ||
| sideNav.navToSideMenuGroupByLabel('Workloads'); | ||
| sideNav.navToSideMenuEntryByLabel('Workloads'); | ||
| } | ||
|
|
||
| title() { | ||
| return cy.get('[data-testid="workload-dashboard-title"]'); | ||
| } | ||
|
|
||
| subtitle() { | ||
| return cy.get('[data-testid="workload-dashboard-subtitle"]'); | ||
| } | ||
|
|
||
| byStateSection() { | ||
| return cy.get('[data-testid="workload-dashboard-by-state"]'); | ||
| } | ||
|
|
||
| stateCards() { | ||
| return cy.get('[data-testid="workload-dashboard-state-card"]'); | ||
| } | ||
|
|
||
| byTypeSection() { | ||
| return cy.get('[data-testid="workload-dashboard-by-type"]'); | ||
| } | ||
|
|
||
| byTypeCard(index = 0) { | ||
| return new CardPo(`[data-testid="resource-detail-status-card"]:eq(${ index })`); | ||
| } | ||
|
|
||
| byTypeCards() { | ||
| return cy.get('[data-testid="resource-detail-status-card"]'); | ||
| } | ||
|
|
||
| emptyState() { | ||
| return cy.get('[data-testid="workload-dashboard-empty"]'); | ||
| } | ||
|
|
||
| errorBanner() { | ||
| return new BannersPo('.banner.error'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| import WorkloadDashboardPagePo from '@/cypress/e2e/po/pages/explorer/workload-dashboard.po'; | ||
|
|
||
| const workloadDashboard = new WorkloadDashboardPagePo('local'); | ||
|
|
||
| describe('Workload Dashboard', { testIsolation: 'off', tags: ['@explorer', '@adminUser'] }, () => { | ||
| before(() => { | ||
| cy.login(); | ||
| cy.updateNamespaceFilter('local', 'none', '{"local":[]}'); | ||
| WorkloadDashboardPagePo.navTo(); | ||
| workloadDashboard.waitForPage(); | ||
| }); | ||
|
|
||
| it('should display the title', () => { | ||
| workloadDashboard.title().should('contain.text', 'Workloads Overview'); | ||
| }); | ||
|
|
||
| it('should display a namespace subtitle with workload count', () => { | ||
| workloadDashboard.subtitle().should('be.visible'); | ||
| workloadDashboard.subtitle().invoke('text').should('match', /\(\d+\)/); | ||
| }); | ||
|
|
||
| it('should display the By State section with state cards', () => { | ||
| workloadDashboard.byStateSection().should('be.visible'); | ||
| workloadDashboard.stateCards().should('have.length.gte', 1); | ||
| }); | ||
|
|
||
| it('should display the By Type section with type cards', () => { | ||
| workloadDashboard.byTypeSection().should('be.visible'); | ||
| workloadDashboard.byTypeCards().should('have.length.gte', 1); | ||
| }); | ||
|
|
||
| it('should navigate to the resource list when clicking a By Type card', () => { | ||
| WorkloadDashboardPagePo.navTo(); | ||
| workloadDashboard.waitForPage(); | ||
|
|
||
| workloadDashboard.byTypeCards().first().click(); | ||
|
|
||
| cy.url().should('match', /\/c\/local\/explorer\/(apps\.|batch\.)?[a-z]+/); | ||
| }); | ||
|
|
||
| it('should show empty state when namespace filter matches no workloads', () => { | ||
| cy.updateNamespaceFilter('local', 'none', '{"local":["ns://e2e-nonexistent-ns"]}'); | ||
|
|
||
| WorkloadDashboardPagePo.navTo(); | ||
| workloadDashboard.waitForPage(); | ||
|
|
||
| workloadDashboard.emptyState().should('be.visible'); | ||
| }); | ||
|
|
||
| after(() => { | ||
| cy.login(); | ||
| cy.updateNamespaceFilter('local', 'none', '{"local":["all://user"]}'); | ||
| }); | ||
| }); |
|
marcelofukumoto marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,12 +8,16 @@ import { useI18n } from '@shell/composables/useI18n'; | |
| import { StateColor } from '@shell/utils/style'; | ||
| import { computed } from 'vue'; | ||
| import { useStore } from 'vuex'; | ||
| import type { RouteLocationRaw } from 'vue-router'; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @codyrancher would you be able to review these component updates / new components? your composition api fu is greater than mine |
||
|
|
||
| export interface Props { | ||
| title: string; | ||
| resources?: any[]; | ||
| showScaling?: boolean; | ||
| showPercent?: boolean; | ||
| noResourcesMessage?: string; | ||
| to?: RouteLocationRaw; | ||
| rowTo?: RouteLocationRaw | string; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not using this anymore! Thanks |
||
| } | ||
| </script> | ||
|
|
||
|
|
@@ -24,7 +28,10 @@ const i18n = useI18n(store); | |
| const props = withDefaults(defineProps<Props>(), { | ||
| resources: undefined, | ||
| showScaling: false, | ||
| noResourcesMessage: undefined | ||
| showPercent: true, | ||
| noResourcesMessage: undefined, | ||
| to: undefined, | ||
| rowTo: undefined, | ||
| }); | ||
| const emit = defineEmits(['decrease', 'increase']); | ||
|
|
||
|
|
@@ -38,7 +45,7 @@ const segmentAccumulator = computed(() => { | |
| const color: StateColor = resource.stateSimpleColor; | ||
|
|
||
| accumulator[color] = accumulator[color] || { count: 0 }; | ||
| accumulator[color].count++; | ||
| accumulator[color].count += resource.count || 1; | ||
| }); | ||
|
|
||
| return accumulator; | ||
|
|
@@ -48,12 +55,13 @@ const rowAccumulator = computed(() => { | |
| interface Value { | ||
| count: number; | ||
| color: StateColor; | ||
| stateId: string; | ||
| } | ||
| const accumulator: {[key in string]: Value} = {}; | ||
|
|
||
| props.resources?.forEach((resource: any) => { | ||
| accumulator[resource.stateDisplay] = accumulator[resource.stateDisplay] || { count: 0 }; | ||
| accumulator[resource.stateDisplay].count++; | ||
| accumulator[resource.stateDisplay] = accumulator[resource.stateDisplay] || { count: 0, stateId: resource.stateId || resource.stateDisplay }; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I believe that falling back on stateDisplay for stateId could cause a problem since the stateId is used to create query strings. I don't think
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not using stateDisplay anymore! Only the ID |
||
| accumulator[resource.stateDisplay].count += resource.count || 1; | ||
| accumulator[resource.stateDisplay].color = resource.stateSimpleColor.replace('text-', '') as StateColor; | ||
| }); | ||
|
|
||
|
|
@@ -64,7 +72,13 @@ const percent = (count: number, total: number) => { | |
| return count / total * 100; | ||
| }; | ||
|
|
||
| const count = computed(() => props.resources?.length || 0); | ||
| const count = computed(() => { | ||
| if (!props.resources?.length) { | ||
| return 0; | ||
| } | ||
|
|
||
| return props.resources.reduce((sum: number, r: any) => sum + (r.count || 1), 0); | ||
| }); | ||
|
|
||
| const segmentColors = computed(() => Object.keys(segmentAccumulator.value) as StateColor[]); | ||
| const segments = computed(() => segmentColors.value.map((color: StateColor) => ({ | ||
|
|
@@ -80,17 +94,27 @@ const rows = computed(() => { | |
| return rowStates.value.map((state) => ({ | ||
| color: rowAccumulator.value[state].color, | ||
| label: state, | ||
| stateId: rowAccumulator.value[state].stateId, | ||
| count: rowAccumulator.value[state].count, | ||
| percent: percent(rowAccumulator.value[state].count, count.value) | ||
| })); | ||
| }); | ||
|
|
||
| function rowRoute(stateId: string): RouteLocationRaw | undefined { | ||
| if (!props.rowTo || typeof props.rowTo === 'string') { | ||
| return undefined; | ||
| } | ||
|
|
||
| return { ...props.rowTo, query: { q: `"metadata.state.name":"${ stateId }"` } }; | ||
| } | ||
|
|
||
| </script> | ||
|
|
||
| <template> | ||
| <Card | ||
| :title="title" | ||
| data-testid="resource-detail-status-card" | ||
| :to="props.to" | ||
| > | ||
| <template | ||
| v-if="props.showScaling" | ||
|
|
@@ -120,6 +144,8 @@ const rows = computed(() => { | |
| :label="row.label" | ||
| :count="row.count" | ||
| :percent="row.percent" | ||
| :showPercent="props.showPercent" | ||
| :to="rowRoute(row.stateId)" | ||
| /> | ||
| </div> | ||
| <div | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,36 +1,104 @@ | ||
| import { mount } from '@vue/test-utils'; | ||
| import { shallowMount } from '@vue/test-utils'; | ||
| import Card from '@shell/components/Resource/Detail/Card/index.vue'; | ||
|
|
||
| const mockPush = jest.fn(); | ||
|
|
||
| jest.mock('vue-router', () => ({ useRouter: () => ({ push: mockPush }) })); | ||
|
|
||
| describe('component: Card/index', () => { | ||
| it('should render title and default slot', async() => { | ||
| it('should render title and default slot', () => { | ||
| const title = 'title'; | ||
| const content = 'content'; | ||
| const wrapper = mount(Card, { props: { title }, slots: { default: content } }); | ||
| const wrapper = shallowMount(Card, { props: { title }, slots: { default: content } }); | ||
|
|
||
| expect(wrapper.find('.title').element.innerHTML).toStrictEqual(title); | ||
| expect(wrapper.find('.title').text()).toStrictEqual(title); | ||
| }); | ||
|
|
||
| it('should allow you to override the heading with slot', async() => { | ||
| it('should allow you to override the heading with slot', () => { | ||
| const title = 'title'; | ||
| const content = 'content'; | ||
| const wrapper = mount(Card, { props: { title }, slots: { heading: content } }); | ||
| const wrapper = shallowMount(Card, { props: { title }, slots: { heading: content } }); | ||
|
|
||
| expect(wrapper.find('.title').exists()).toBeFalsy(); | ||
| expect(wrapper.find('.heading').element.innerHTML).toStrictEqual(content); | ||
| expect(wrapper.find('.heading').text()).toStrictEqual(content); | ||
| }); | ||
|
|
||
| it('should allow you to override the title with slot', async() => { | ||
| it('should allow you to override the title with slot', () => { | ||
| const title = 'title'; | ||
| const content = 'content'; | ||
| const wrapper = mount(Card, { props: { title }, slots: { title: content } }); | ||
| const wrapper = shallowMount(Card, { props: { title }, slots: { title: content } }); | ||
|
|
||
| expect(wrapper.find('.title').element.innerHTML).toStrictEqual(content); | ||
| expect(wrapper.find('.title').text()).toStrictEqual(content); | ||
| }); | ||
|
|
||
| it('should allow you to insert heading-action with slot', async() => { | ||
| it('should allow you to insert heading-action with slot', () => { | ||
| const content = '<div id="test">content</div>'; | ||
| const wrapper = mount(Card, { slots: { 'heading-action': content } }); | ||
| const wrapper = shallowMount(Card, { slots: { 'heading-action': content } }); | ||
|
|
||
| expect(wrapper.find('.heading #test').exists()).toBeTruthy(); | ||
| }); | ||
|
|
||
| it('should hide heading when no title or heading slots are provided', () => { | ||
| const wrapper = shallowMount(Card, { slots: { default: 'body' } }); | ||
|
|
||
| expect(wrapper.find('.heading').exists()).toBeFalsy(); | ||
| }); | ||
|
|
||
| describe('click navigation', () => { | ||
| const to = { name: 'test-route', params: { id: '1' } }; | ||
|
|
||
| beforeEach(() => { | ||
| mockPush.mockClear(); | ||
| }); | ||
|
|
||
| it('should navigate when card body is clicked and to prop is set', async() => { | ||
| const wrapper = shallowMount(Card, { props: { to }, slots: { default: '<span>content</span>' } }); | ||
|
|
||
| await wrapper.find('.body span').trigger('click'); | ||
|
|
||
| expect(mockPush).toHaveBeenCalledWith(to); | ||
| }); | ||
|
|
||
| it('should not navigate when no to prop is set', async() => { | ||
| const wrapper = shallowMount(Card, { props: { title: 'test' }, slots: { default: '<span>content</span>' } }); | ||
|
|
||
| await wrapper.find('.body span').trigger('click'); | ||
|
|
||
| expect(mockPush).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should not navigate when clicking on a child anchor element', async() => { | ||
| const wrapper = shallowMount(Card, { | ||
| props: { to }, | ||
| slots: { default: '<a href="#" class="inner-link">link</a>' }, | ||
| }); | ||
|
|
||
| await wrapper.find('.inner-link').trigger('click'); | ||
|
|
||
| expect(mockPush).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should not navigate when clicking on a child button element', async() => { | ||
| const wrapper = shallowMount(Card, { | ||
| props: { to }, | ||
| slots: { default: '<button class="inner-btn">btn</button>' }, | ||
| }); | ||
|
|
||
| await wrapper.find('.inner-btn').trigger('click'); | ||
|
|
||
| expect(mockPush).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('should add clickable class when to prop is set', () => { | ||
| const wrapper = shallowMount(Card, { props: { to }, slots: { default: 'body' } }); | ||
|
|
||
| expect(wrapper.find('.detail-card').classes()).toContain('clickable'); | ||
| }); | ||
|
|
||
| it('should not add clickable class when to prop is not set', () => { | ||
| const wrapper = shallowMount(Card, { slots: { default: 'body' } }); | ||
|
|
||
| expect(wrapper.find('.detail-card').classes()).not.toContain('clickable'); | ||
| }); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this use case is different, the user may have not set any and the default
only user namespacesis usedThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This one is provided by @oboc-sts from the Figma.
Let me ask him to check here directly.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I totally missed the "suggested change"...
Yeah, we can go with the suggestion @richard-cox made above, it makes sense, I like it ;)