Skip to content

Commit d11f5b7

Browse files
authored
Add VM actions to search details page (stolostron#3879)
* Add VM actions to search details page Signed-off-by: zlayne <zlayne@redhat.com> * fix translation Signed-off-by: zlayne <zlayne@redhat.com> * fix translation Signed-off-by: zlayne <zlayne@redhat.com> * add test Signed-off-by: zlayne <zlayne@redhat.com> * add test Signed-off-by: zlayne <zlayne@redhat.com> --------- Signed-off-by: zlayne <zlayne@redhat.com>
1 parent 75797ee commit d11f5b7

File tree

6 files changed

+202
-58
lines changed

6 files changed

+202
-58
lines changed

frontend/public/locales/en/translation.json

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"(1 of {{0}})": "(1 of {{0}})",
1010
"{{0}} values": "{{0}} values",
1111
"{{0}} within the last:": "{{0}} within the last:",
12+
"{{action}} {{resourceKind}}": "{{action}} {{resourceKind}}",
1213
"{{count}} cluster cannot be edited ": "{{count}} cluster cannot be edited ",
1314
"{{count}} cluster cannot be edited _plural": "{{count}} clusters cannot be edited ",
1415
"{{count}} clusters with issues": "{{count}} cluster with issues",

frontend/src/routes/Search/Details/DetailsPage.test.tsx

+21
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { nockGet, nockIgnoreApiPaths, nockIgnoreRBAC, nockPostRequest } from '..
99
import { waitForNocks } from '../../../lib/test-util'
1010
import { NavigationPath } from '../../../NavigationPath'
1111
import Search from '../Search'
12+
import { getResourceParams } from './DetailsPage'
1213

1314
jest.mock('react-router-dom-v5-compat', () => {
1415
const originalModule = jest.requireActual('react-router-dom-v5-compat')
@@ -125,4 +126,24 @@ describe('DetailsPage', () => {
125126
).toBeTruthy()
126127
)
127128
})
129+
130+
test('Should return the url search params correctly', () => {
131+
const res = getResourceParams()
132+
expect(res).toMatchSnapshot()
133+
})
134+
135+
test('Should return the url search params incorrectly', () => {
136+
Object.defineProperty(window, 'location', {
137+
value: {
138+
pathname: '/multicloud/search/resources',
139+
search: '?',
140+
state: {
141+
from: '/multicloud/search',
142+
fromSearch: '?filters={%22textsearch%22:%22kind%3APod%2',
143+
},
144+
},
145+
})
146+
const res = getResourceParams()
147+
expect(res).toMatchSnapshot()
148+
})
128149
})

frontend/src/routes/Search/Details/DetailsPage.tsx

+70-51
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
/* Copyright Contributors to the Open Cluster Management project */
22
// Copyright (c) 2021 Red Hat, Inc.
33

4-
import { Dropdown, DropdownItem, DropdownToggle } from '@patternfly/react-core'
5-
import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react'
4+
import { Divider, Dropdown, DropdownItem, DropdownToggle } from '@patternfly/react-core'
5+
import { Dispatch, SetStateAction, useContext, useEffect, useMemo, useState } from 'react'
66
import { Link, Outlet, useLocation, useNavigate, useOutletContext } from 'react-router-dom-v5-compat'
77
import { Pages, usePageVisitMetricHandler } from '../../../hooks/console-metrics'
88
import { useTranslation } from '../../../lib/acm-i18next'
99
import { NavigationPath } from '../../../NavigationPath'
1010
import { IResource } from '../../../resources'
1111
import { fireManagedClusterView } from '../../../resources/managedclusterview'
1212
import { getResource } from '../../../resources/utils/resource-request'
13-
import { AcmPage, AcmPageHeader, AcmSecondaryNav, AcmSecondaryNavItem } from '../../../ui-components'
13+
import { useRecoilValue, useSharedAtoms } from '../../../shared-recoil'
14+
import { AcmPage, AcmPageHeader, AcmSecondaryNav, AcmSecondaryNavItem, AcmToastContext } from '../../../ui-components'
15+
import { handleVMActions } from '../SearchResults/utils'
1416
import { DeleteResourceModal } from './DeleteResourceModal'
1517

1618
export type SearchDetailsContext = {
@@ -27,40 +29,23 @@ export type SearchDetailsContext = {
2729
}
2830

2931
export function getResourceParams() {
30-
let cluster = '',
31-
kind = '',
32-
apiversion = '',
33-
namespace = '',
34-
name = ''
35-
const urlParams = decodeURIComponent(window.location.search).replace('?', '').split('&')
36-
urlParams.forEach((param) => {
37-
const paramKey = param.split('=')[0]
38-
const paramValue = param.split('=')[1]
39-
switch (paramKey) {
40-
case 'cluster':
41-
cluster = paramValue
42-
break
43-
case 'kind':
44-
kind = paramValue
45-
break
46-
case 'apiversion':
47-
apiversion = paramValue
48-
break
49-
case 'namespace':
50-
namespace = paramValue
51-
break
52-
case 'name':
53-
name = paramValue
54-
break
55-
}
56-
})
57-
return { cluster, kind, apiversion, namespace, name }
32+
const params = new URLSearchParams(decodeURIComponent(window.location.search))
33+
return {
34+
cluster: params.get('cluster') || '',
35+
kind: params.get('kind') || '',
36+
apiversion: params.get('apiversion') || '',
37+
namespace: params.get('namespace') || '',
38+
name: params.get('name') || '',
39+
}
5840
}
5941

6042
export default function DetailsPage() {
6143
usePageVisitMetricHandler(Pages.searchDetails)
6244
const { t } = useTranslation()
6345
const navigate = useNavigate()
46+
const toast = useContext(AcmToastContext)
47+
const { settingsState } = useSharedAtoms()
48+
const vmActionsEnabled = useRecoilValue(settingsState)?.VIRTUAL_MACHINE_ACTIONS === 'enabled'
6449
const [resource, setResource] = useState<any>(undefined)
6550
const [containers, setContainers] = useState<string[]>()
6651
const [resourceVersion, setResourceVersion] = useState<string>('')
@@ -155,6 +140,59 @@ export default function DetailsPage() {
155140
[apiversion, cluster, containers, kind, name, namespace, resource, resourceError]
156141
)
157142

143+
const getResourceActions = useMemo(() => {
144+
const actions = [
145+
<DropdownItem
146+
component="button"
147+
key="edit-resource"
148+
onClick={() => {
149+
navigate(`${NavigationPath.resourceYAML}${window.location.search}`)
150+
}}
151+
>
152+
{t('Edit {{resourceKind}}', { resourceKind: kind })}
153+
</DropdownItem>,
154+
<DropdownItem
155+
component="button"
156+
key="delete-resource"
157+
onClick={() => {
158+
setIsDeleteResourceModalOpen(true)
159+
}}
160+
>
161+
{t('Delete {{resourceKind}}', { resourceKind: kind })}
162+
</DropdownItem>,
163+
]
164+
if (vmActionsEnabled && kind.toLowerCase() === 'virtualmachine') {
165+
actions.unshift(
166+
...[
167+
{ action: 'Start', path: '/virtualmachines/start' },
168+
{ action: 'Stop', path: '/virtualmachines/stop' },
169+
{ action: 'Restart', path: '/virtualmachines/restart' },
170+
{ action: 'Pause', path: '/virtualmachineinstances/pause' },
171+
{ action: 'Unpause', path: '/virtualmachineinstances/unpause' },
172+
].map((action) => (
173+
<DropdownItem
174+
key={`${action.action}-vm-resource`}
175+
component="button"
176+
onClick={() =>
177+
handleVMActions(
178+
action.action.toLowerCase(),
179+
action.path,
180+
{ cluster, name, namespace },
181+
() => setResourceVersion(''), // trigger resource refetchto update details page data.
182+
toast,
183+
t
184+
)
185+
}
186+
>
187+
{t(`{{action}} {{resourceKind}}`, { action: action.action, resourceKind: kind })}
188+
</DropdownItem>
189+
)),
190+
<Divider key={'action-divider'} />
191+
)
192+
}
193+
return actions
194+
}, [cluster, kind, name, namespace, vmActionsEnabled, navigate, toast, t])
195+
158196
return (
159197
<AcmPage
160198
header={
@@ -197,26 +235,7 @@ export default function DetailsPage() {
197235
{t('Actions')}
198236
</DropdownToggle>
199237
}
200-
dropdownItems={[
201-
<DropdownItem
202-
component="button"
203-
key="edit-resource"
204-
onClick={() => {
205-
navigate(`${NavigationPath.resourceYAML}${window.location.search}`)
206-
}}
207-
>
208-
{t('Edit {{resourceKind}}', { resourceKind: kind })}
209-
</DropdownItem>,
210-
<DropdownItem
211-
component="button"
212-
key="delete-resource"
213-
onClick={() => {
214-
setIsDeleteResourceModalOpen(true)
215-
}}
216-
>
217-
{t('Delete {{resourceKind}}', { resourceKind: kind })}
218-
</DropdownItem>,
219-
]}
238+
dropdownItems={getResourceActions}
220239
onSelect={() => setResourceActionsOpen(false)}
221240
/>
222241
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`DetailsPage Should return the url search params correctly 1`] = `
4+
Object {
5+
"apiversion": "v1",
6+
"cluster": "local-cluster",
7+
"kind": "Pod",
8+
"name": "testLocalPod",
9+
"namespace": "testNamespace",
10+
}
11+
`;
12+
13+
exports[`DetailsPage Should return the url search params incorrectly 1`] = `
14+
Object {
15+
"apiversion": "",
16+
"cluster": "",
17+
"kind": "",
18+
"name": "",
19+
"namespace": "",
20+
}
21+
`;

frontend/src/routes/Search/SearchResults/utils.test.tsx

+40
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ const toastContextMock: any = {
1818
addAlert: jest.fn(),
1919
}
2020

21+
jest.mock('../../../resources', () => ({
22+
...jest.requireActual('../../../resources'),
23+
putRequest: jest.fn(() => {
24+
return { promise: Promise.resolve() }
25+
}),
26+
}))
27+
jest.mock('./utils', () => ({
28+
...jest.requireActual('./utils'),
29+
handleVMActions: jest.fn(() => {
30+
return Promise.resolve()
31+
}),
32+
}))
33+
2134
const allClusters = [
2235
{
2336
name: 'local-cluster',
@@ -217,6 +230,33 @@ test('Correctly return VirtualMachine with actions disabled', () => {
217230
)
218231
expect(res).toMatchSnapshot()
219232
})
233+
test('should handle vm action buttons', () => {
234+
const item = { managedHub: 'cluster1' }
235+
const vmActionsEnabled = true
236+
const actions = getRowActions(
237+
'VirtualMachine',
238+
'kind:VirtualMachine',
239+
false,
240+
() => {},
241+
() => {},
242+
allClusters,
243+
navigate,
244+
toastContextMock,
245+
vmActionsEnabled,
246+
t
247+
)
248+
const startVMAction = actions.find((action) => action.id === 'startVM')
249+
const stopVMAction = actions.find((action) => action.id === 'stopVM')
250+
const restartVMAction = actions.find((action) => action.id === 'restartVM')
251+
const pauseVMAction = actions.find((action) => action.id === 'pauseVM')
252+
const unpauseVMAction = actions.find((action) => action.id === 'unpauseVM')
253+
254+
startVMAction?.click(item)
255+
stopVMAction?.click(item)
256+
restartVMAction?.click(item)
257+
pauseVMAction?.click(item)
258+
unpauseVMAction?.click(item)
259+
})
220260

221261
test('generateSearchResultExport - Correctly generates and triggers csv download for single resource kind', () => {
222262
const toastContextMock: any = {

frontend/src/routes/Search/SearchResults/utils.tsx

+49-7
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,22 @@ export interface ISearchResult {
2727
__type: string
2828
}
2929

30-
function handleVMActions(action: string, path: string, item: any, toast: IAlertContext, t: TFunction) {
30+
export function handleVMActions(
31+
action: string,
32+
path: string,
33+
item: any,
34+
refetchVM: () => void, // provide a callback fn to refetch the vm
35+
toast: IAlertContext,
36+
t: TFunction
37+
) {
3138
putRequest(`${getBackendUrl()}${path}`, {
3239
managedCluster: item.cluster,
3340
vmName: item.name,
3441
vmNamespace: item.namespace,
3542
})
3643
.promise.then(() => {
3744
// Wait 5 seconds to allow search collector to catch up & refetch search results to update table.
38-
setTimeout(() => searchClient.refetchQueries({ include: ['searchResultItems'] }), 5000)
45+
setTimeout(refetchVM, 5000)
3946
})
4047
.catch((err) => {
4148
console.error(`VirtualMachine: ${item.name} ${action} error. ${err}`)
@@ -246,35 +253,70 @@ export function getRowActions(
246253
id: 'startVM',
247254
title: t('Start {{resourceKind}}', { resourceKind }),
248255
click: (item: any) => {
249-
handleVMActions('start', '/virtualmachines/start', item, toast, t)
256+
handleVMActions(
257+
'start',
258+
'/virtualmachines/start',
259+
item,
260+
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
261+
toast,
262+
t
263+
)
250264
},
251265
}
252266
const stopVM = {
253267
id: 'stopVM',
254268
title: t('Stop {{resourceKind}}', { resourceKind }),
255269
click: (item: any) => {
256-
handleVMActions('stop', '/virtualmachines/stop', item, toast, t)
270+
handleVMActions(
271+
'stop',
272+
'/virtualmachines/stop',
273+
item,
274+
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
275+
toast,
276+
t
277+
)
257278
},
258279
}
259280
const restartVM = {
260281
id: 'restartVM',
261282
title: t('Restart {{resourceKind}}', { resourceKind }),
262283
click: (item: any) => {
263-
handleVMActions('restart', '/virtualmachines/restart', item, toast, t)
284+
handleVMActions(
285+
'restart',
286+
'/virtualmachines/restart',
287+
item,
288+
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
289+
toast,
290+
t
291+
)
264292
},
265293
}
266294
const pauseVM = {
267295
id: 'pauseVM',
268296
title: t('Pause {{resourceKind}}', { resourceKind }),
269297
click: (item: any) => {
270-
handleVMActions('pause', '/virtualmachineinstances/pause', item, toast, t)
298+
handleVMActions(
299+
'pause',
300+
'/virtualmachineinstances/pause',
301+
item,
302+
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
303+
toast,
304+
t
305+
)
271306
},
272307
}
273308
const unpauseVM = {
274309
id: 'unpauseVM',
275310
title: t('Unpause {{resourceKind}}', { resourceKind }),
276311
click: (item: any) => {
277-
handleVMActions('unpause', '/virtualmachineinstances/unpause', item, toast, t)
312+
handleVMActions(
313+
'unpause',
314+
'/virtualmachineinstances/unpause',
315+
item,
316+
() => searchClient.refetchQueries({ include: ['searchResultItems'] }),
317+
toast,
318+
t
319+
)
278320
},
279321
}
280322

0 commit comments

Comments
 (0)