-
Notifications
You must be signed in to change notification settings - Fork 297
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
Fixes #38107 - Booted container images page #11269
base: master
Are you sure you want to change the base?
Changes from all commits
e85d4db
69569c1
0127216
230ace7
104bd76
1983cdc
9b2397c
d945b8e
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,13 @@ | ||
module Katello | ||
module Concerns | ||
module BookmarkControllerValidatorExtensions | ||
extend ActiveSupport::Concern | ||
|
||
def valid_controllers_list | ||
@valid_controllers_list ||= (["dashboard", "common_parameters", "/katello/api/v2/host_bootc_images"] + | ||
ActiveRecord::Base.connection.tables.map(&:to_s) + | ||
Permission.resources.map(&:tableize)).uniq | ||
end | ||
end | ||
end | ||
end |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
import { foremanApi } from '../../services/api'; | ||
|
||
const BOOTED_CONTAINER_IMAGES_KEY = 'BOOTED_CONTAINER_IMAGES'; | ||
export const BOOTED_CONTAINER_IMAGES_API_PATH = foremanApi.getApiUrl('/hosts/bootc_images'); | ||
export default BOOTED_CONTAINER_IMAGES_KEY; |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,240 @@ | ||||||
import React from 'react'; | ||||||
import { TableComposable, Thead, Th, Tbody, Tr, Td, ExpandableRowContent } from '@patternfly/react-table'; | ||||||
import TableIndexPage from 'foremanReact/components/PF4/TableIndexPage/TableIndexPage'; | ||||||
import { | ||||||
useSetParamsAndApiAndSearch, | ||||||
useTableIndexAPIResponse, | ||||||
} from 'foremanReact/components/PF4/TableIndexPage/Table/TableIndexHooks'; | ||||||
import { | ||||||
useUrlParams, | ||||||
useSet, | ||||||
} from 'foremanReact/components/PF4/TableIndexPage/Table/TableHooks'; | ||||||
import { | ||||||
getColumnHelpers, | ||||||
} from 'foremanReact/components/PF4/TableIndexPage/Table/helpers'; | ||||||
import { | ||||||
useTableSort, | ||||||
} from 'foremanReact/components/PF4/Helpers/useTableSort'; | ||||||
import Pagination from 'foremanReact/components/Pagination'; | ||||||
import EmptyPage from 'foremanReact/routes/common/EmptyPage'; | ||||||
import { translate as __ } from 'foremanReact/common/I18n'; | ||||||
import BOOTED_CONTAINER_IMAGES_KEY, { BOOTED_CONTAINER_IMAGES_API_PATH } from './BootedContainerImagesConstants'; | ||||||
|
||||||
const BootedContainerImagesPage = () => { | ||||||
const columns = { | ||||||
bootc_booted_image: { | ||||||
title: __('Image name'), | ||||||
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. Can we change this to Bootc - booted image name so it's also easier to figure out the serach query? 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 changed all of the references to image_name to bootc_booted_image like in the API. |
||||||
isSorted: true, | ||||||
}, | ||||||
digest: { | ||||||
title: __('Image digests'), | ||||||
wrapper: ({ digests }) => digests.length, | ||||||
}, | ||||||
hosts: { | ||||||
title: __('Hosts'), | ||||||
wrapper: ({ bootc_booted_image: bootcBootedImage, digests }) => ( | ||||||
<a href={`/hosts?search=bootc_booted_image%20=%20${bootcBootedImage}`}>{digests.reduce((total, digest) => total + digest.host_count, 0)}</a> | ||||||
), | ||||||
}, | ||||||
}; | ||||||
|
||||||
const { | ||||||
searchParam: urlSearchQuery = '', | ||||||
page: urlPage, | ||||||
per_page: urlPerPage, | ||||||
} = useUrlParams(); | ||||||
const defaultParams = { search: urlSearchQuery }; | ||||||
if (urlPage) defaultParams.page = Number(urlPage); | ||||||
if (urlPerPage) defaultParams.per_page = Number(urlPerPage); | ||||||
const apiOptions = { key: BOOTED_CONTAINER_IMAGES_KEY }; | ||||||
|
||||||
const response = useTableIndexAPIResponse({ | ||||||
apiUrl: BOOTED_CONTAINER_IMAGES_API_PATH, | ||||||
apiOptions, | ||||||
defaultParams, | ||||||
}); | ||||||
const columnsToSortParams = {}; | ||||||
Object.keys(columns).forEach((key) => { | ||||||
if (columns[key].isSorted) { | ||||||
columnsToSortParams[columns[key].title] = key; | ||||||
} | ||||||
}); | ||||||
const { setParamsAndAPI, params } = useSetParamsAndApiAndSearch({ | ||||||
defaultParams, | ||||||
apiOptions, | ||||||
setAPIOptions: response.setAPIOptions, | ||||||
}); | ||||||
const onSort = (_event, index, direction) => { | ||||||
setParamsAndAPI({ | ||||||
...params, | ||||||
order: `${Object.keys(columns)[index]} ${direction}`, | ||||||
}); | ||||||
}; | ||||||
const { pfSortParams } = useTableSort({ | ||||||
allColumns: Object.keys(columns).map(k => columns[k].title), | ||||||
columnsToSortParams, | ||||||
onSort, | ||||||
}); | ||||||
const expandedImages = useSet([]); | ||||||
const imageIsExpanded = bootcBootedImage => expandedImages.has(bootcBootedImage); | ||||||
const STATUS = { | ||||||
PENDING: 'PENDING', | ||||||
RESOLVED: 'RESOLVED', | ||||||
ERROR: 'ERROR', | ||||||
}; | ||||||
|
||||||
const { | ||||||
response: { | ||||||
results = [], | ||||||
subtotal, | ||||||
message: errorMessage, | ||||||
}, | ||||||
status = STATUS.PENDING, | ||||||
} = response; | ||||||
|
||||||
const [columnNamesKeys, keysToColumnNames] = getColumnHelpers(columns); | ||||||
const onPagination = (newPagination) => { | ||||||
setParamsAndAPI({ ...params, ...newPagination }); | ||||||
}; | ||||||
const getColumnWidth = (key) => { | ||||||
if (key === 'bootc_booted_image') return 40; | ||||||
if (key === 'digest') return 15; | ||||||
return 45; | ||||||
}; | ||||||
|
||||||
return ( | ||||||
<TableIndexPage | ||||||
apiUrl={BOOTED_CONTAINER_IMAGES_API_PATH} | ||||||
apiOptions={apiOptions} | ||||||
header={__('Booted container images')} | ||||||
createable={false} | ||||||
isDeleteable={false} | ||||||
controller="/katello/api/v2/host_bootc_images" | ||||||
> | ||||||
<> | ||||||
<TableComposable variant="compact" ouiaId="booted-containers-table" isStriped> | ||||||
<Thead> | ||||||
<Tr ouiaId="table-header"> | ||||||
<> | ||||||
<Th /> | ||||||
{columnNamesKeys.map(k => ( | ||||||
<Th | ||||||
width={getColumnWidth(k)} | ||||||
key={k} | ||||||
sort={ | ||||||
Object.values(columnsToSortParams).includes(k) && | ||||||
pfSortParams(keysToColumnNames[k]) | ||||||
} | ||||||
> | ||||||
{keysToColumnNames[k]} | ||||||
</Th> | ||||||
))} | ||||||
</> | ||||||
</Tr> | ||||||
</Thead> | ||||||
<Tbody> | ||||||
{status === STATUS.PENDING && results.length === 0 && ( | ||||||
<Tr ouiaId="table-loading"> | ||||||
<Td colSpan={100}> | ||||||
<EmptyPage | ||||||
message={{ | ||||||
type: 'loading', | ||||||
text: __('Loading...'), | ||||||
}} | ||||||
/> | ||||||
</Td> | ||||||
</Tr> | ||||||
)} | ||||||
{!status === STATUS.PENDING && | ||||||
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.
Suggested change
don't wanna check the opposite of a string ;) |
||||||
results.length === 0 && | ||||||
!errorMessage && ( | ||||||
<Tr ouiaId="table-empty"> | ||||||
<Td colSpan={100}> | ||||||
<EmptyPage | ||||||
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'm not sure why, but this empty state page does not want to get rendered. The loading page renders even, just not this one. 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. see above, maybe that's it? |
||||||
message={{ | ||||||
type: 'empty', | ||||||
}} | ||||||
/> | ||||||
</Td> | ||||||
</Tr> | ||||||
)} | ||||||
{errorMessage && ( | ||||||
<Tr ouiaId="table-error"> | ||||||
<Td colSpan={100}> | ||||||
<EmptyPage message={{ type: 'error', text: errorMessage }} /> | ||||||
</Td> | ||||||
</Tr> | ||||||
)} | ||||||
</Tbody> | ||||||
{results?.map((result, rowIndex) => { | ||||||
const { bootc_booted_image: bootcBootedImage, digests } = result; | ||||||
const isExpanded = imageIsExpanded(bootcBootedImage); | ||||||
return ( | ||||||
<Tbody key={`bootable-container-images-body-${bootcBootedImage}`} isExpanded={isExpanded}> | ||||||
<Tr key={bootcBootedImage} ouiaId={`table-row-${rowIndex}`}> | ||||||
<> | ||||||
<Td | ||||||
expand={digests.length > 0 && { | ||||||
rowIndex, | ||||||
isExpanded, | ||||||
onToggle: (_event, _rInx, isOpen) => | ||||||
expandedImages.onToggle(isOpen, bootcBootedImage), | ||||||
expandId: `booted-containers-expander-${bootcBootedImage}`, | ||||||
}} | ||||||
/> | ||||||
{columnNamesKeys.map(k => ( | ||||||
<Td | ||||||
key={`${bootcBootedImage}-${keysToColumnNames[k]}`} | ||||||
dataLabel={keysToColumnNames[k]} | ||||||
> | ||||||
{columns[k].wrapper ? columns[k].wrapper(result) : result[k]} | ||||||
</Td> | ||||||
))} | ||||||
</> | ||||||
</Tr> | ||||||
{digests ? | ||||||
<Tr isExpanded={isExpanded} ouiaId={`table-row-outer-expandable-${rowIndex}`}> | ||||||
<Td /> | ||||||
<Td colSpan={3}> | ||||||
<ExpandableRowContent> | ||||||
<TableComposable variant="compact" isStriped ouiaId={`table-composable-expanded-${rowIndex}`}> | ||||||
<Thead> | ||||||
<Tr ouiaId={`table-row-inner-expandable-${rowIndex}`}> | ||||||
<Th width={50}>{__('Image digest')}</Th> | ||||||
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 tried to use the width to align the inner "Hosts" data with the outer "Hosts" data, but they are always off by a little bit. So, for now, I distanced them enough to not look completely weird. Ideally I could remove the Hosts header in the inner expanded table and have that data be perfectly lined up with the outer table's hosts count. However, I'm not sure if that's easily possible with these PF components. 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. Hard-coding width or height in pixels is usually not advisable, because everything can change depending on the user's text size in the browser. 🤔 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. The width there that I'm setting is a percentage, so it should adapt. |
||||||
<Th width={50}>{__('Hosts')}</Th> | ||||||
</Tr> | ||||||
</Thead> | ||||||
<Tbody> | ||||||
{digests.map((digest, index) => ( | ||||||
<Tr key={digest.bootc_booted_digest} ouiaId={`table-row-expandable-content-${index}`}> | ||||||
<Td>{digest.bootc_booted_digest}</Td> | ||||||
<Td> | ||||||
<a href={`/hosts?search=bootc_booted_digest%20=%20${digest.bootc_booted_digest}`}>{digest.host_count}</a> | ||||||
</Td> | ||||||
</Tr> | ||||||
))} | ||||||
</Tbody> | ||||||
</TableComposable> | ||||||
</ExpandableRowContent> | ||||||
</Td> | ||||||
</Tr> : null} | ||||||
</Tbody> | ||||||
); | ||||||
})} | ||||||
</TableComposable> | ||||||
{results.length > 0 && !errorMessage && | ||||||
<Pagination | ||||||
key="table-bottom-pagination" | ||||||
page={params.page} | ||||||
perPage={params.perPage} | ||||||
itemCount={subtotal} | ||||||
onChange={onPagination} | ||||||
updateParamsByUrl | ||||||
/> | ||||||
} | ||||||
</> | ||||||
</TableIndexPage> | ||||||
); | ||||||
}; | ||||||
|
||||||
export default BootedContainerImagesPage; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,42 @@ | ||
import Immutable from 'seamless-immutable'; | ||
|
||
const bootedContainerImagesResponse = Immutable({ | ||
total: 2, | ||
page: 1, | ||
per_page: 20, | ||
subtotal: 2, | ||
results: [ | ||
{ | ||
bootc_booted_image: 'quay.io/centos-bootc/centos-bootc:stream10', | ||
digests: [ | ||
{ | ||
bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd572', | ||
host_count: 1, | ||
}, | ||
{ | ||
bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd573', | ||
host_count: 1, | ||
}, | ||
{ | ||
bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd574', | ||
host_count: 1, | ||
}, | ||
{ | ||
bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd575', | ||
host_count: 1, | ||
}, | ||
], | ||
}, | ||
{ | ||
bootc_booted_image: 'quay.io/centos-bootc/centos-bootc:stream9', | ||
digests: [ | ||
{ | ||
bootc_booted_digest: 'sha256:54256a998f0c62e16f3927c82b570f90bd8449a52e03daabd5fd16d6419fd576', | ||
host_count: 1, | ||
}, | ||
], | ||
}, | ||
], | ||
}); | ||
|
||
export default bootedContainerImagesResponse; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
import React from 'react'; | ||
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. This test is in-progress. |
||
import { renderWithRedux, patientlyWaitFor, act } from 'react-testing-lib-wrapper'; | ||
import { nockInstance, assertNockRequest, mockAutocomplete } from '../../../test-utils/nockWrapper'; | ||
import api from '../../../services/api'; | ||
import BOOTED_CONTAINER_IMAGES_KEY from '../BootedContainerImagesConstants'; | ||
import BootedContainerImagesPage from '../BootedContainerImagesPage'; | ||
import bootcImagesData from './bootedContainerImages.fixtures'; | ||
|
||
// const bootedContainerImagesIndexPath = api.getApiUrl('/booted_container_images'); | ||
// const renderOptions = { apiNamespace: BOOTED_CONTAINER_IMAGES_KEY }; | ||
const bootcImagesUrl = '/api/v2/hosts/bootc_images'; | ||
const autocompleteUrl = '/host_bootc_images/auto_complete_search'; | ||
const autocompleteQuery = { | ||
search: '', | ||
}; | ||
|
||
let firstImage; | ||
let secondImage; | ||
beforeEach(() => { | ||
const { results } = bootcImagesData; | ||
[firstImage, secondImage] = results; | ||
}); | ||
|
||
test('BootedContainerImagesPage renders correctly', async (done) => { | ||
const autocompleteScope = mockAutocomplete(nockInstance, autocompleteUrl, autocompleteQuery); | ||
const scope = nockInstance | ||
.get(bootcImagesUrl) | ||
.query(true) | ||
// Why does the page load twice? | ||
.times(2) | ||
.reply(200, bootcImagesData); | ||
|
||
const { queryByText, queryAllByText } = renderWithRedux(<BootedContainerImagesPage />); | ||
expect(queryByText(firstImage.bootc_booted_image)).toBeNull(); | ||
|
||
await patientlyWaitFor(() => { | ||
expect(queryByText(firstImage.bootc_booted_image)).toBeInTheDocument(); | ||
}); | ||
|
||
assertNockRequest(autocompleteScope); | ||
assertNockRequest(scope, done); | ||
act(done); | ||
}); |
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.
Main nav and main page title are some of the few exceptions where we don't use sentence case.