diff --git a/node_modules b/node_modules index a19948f51..0e7e504f2 160000 --- a/node_modules +++ b/node_modules @@ -1 +1 @@ -Subproject commit a19948f515732c008e41b8eebff5d60e15738e6f +Subproject commit 0e7e504f2d2858d2087bc530570efdb648a72f41 diff --git a/package.json b/package.json index 10a6e4714..6b65a2d7b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@patternfly/react-icons": "5.4.0", "@patternfly/react-styles": "5.4.0", "@patternfly/react-table": "5.4.1", + "@patternfly/react-templates": "^1.1.8", "@patternfly/react-tokens": "5.4.0", "@xterm/addon-canvas": "0.7.0", "@xterm/xterm": "5.5.0", diff --git a/src/components/common/typeaheadSelectWithHeaders.jsx b/src/components/common/typeaheadSelectWithHeaders.jsx new file mode 100644 index 000000000..d0c2ac8ba --- /dev/null +++ b/src/components/common/typeaheadSelectWithHeaders.jsx @@ -0,0 +1,89 @@ +/* + * This file is part of Cockpit. + * + * Copyright (C) 2024 Red Hat, Inc. + * + * Cockpit is free software; you can redistribute it and/or modify it + * under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation; either version 2.1 of the License, or + * (at your option) any later version. + * + * Cockpit is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with Cockpit; If not, see . + */ + +/* This is the Patternfly 5 TypeaheadSelect, with support for header + * entries. Headers are a more straightforward alternative to grouping + * with SelectGroup. + * + * Properties of TypeaheadSelectWithHeaders are the same as for + * TypeaheadSelect. The get headers, the "selectOptions" properties + * can contain entries like this: + * + * { header: "Header title" } + * + * These header entries are turned into regular, disabled entries that + * are styled to look like group headers. + * + * For laziness reasons, TypeaheadSelectWithHeaders does not allow to + * override the filterFunction, but you can specify a matchFunction + * that works on individual options. + */ + +import React, { useMemo } from 'react'; +import { TypeaheadSelect } from '@patternfly/react-templates/dist/esm/components/Select'; + +function defaultMatchFunction(filterValue, option) { + return option.content.toLowerCase().includes(filterValue.toLowerCase()); +} + +function transformOption(option, idx) { + if (option.header) { + return { + value: " header:" + idx, + content: "", + description: option.header, + isDisabled: true, + }; + } else + return option; +} + +function isHeaderOption(option) { + return option.value.startsWith(" header:"); +} + +export const TypeaheadSelectWithHeaders = ({ + selectOptions, + filterFunction, // must be undefined + matchFunction, + ...props +}) => { + const transformedSelectOptions = useMemo(() => selectOptions.map(transformOption), + [selectOptions]); + + if (filterFunction) + console.warn("TypeaheadSelectWithHeaders does not support filterFunction, use matchFunction"); + + const headerFilterFunction = (filterValue, options) => { + // Filter by search term + const filtered = options.filter(o => (isHeaderOption(o) || + (matchFunction || defaultMatchFunction)(filterValue, o))); + // Remove headers that have nothing following them. + const filtered2 = filtered.filter((o, i) => { + return !(isHeaderOption(o) && (i >= filtered.length - 1 || isHeaderOption(filtered[i + 1]))); + }); + return filtered2; + }; + + return ( + + ); +}; diff --git a/src/components/create-vm-dialog/createVmDialog.jsx b/src/components/create-vm-dialog/createVmDialog.jsx index dffef00b4..053a72061 100644 --- a/src/components/create-vm-dialog/createVmDialog.jsx +++ b/src/components/create-vm-dialog/createVmDialog.jsx @@ -41,6 +41,7 @@ import { useInit } from "hooks.js"; import cockpit from 'cockpit'; import store from "../../store.js"; import { MachinesConnectionSelector } from '../common/machinesConnectionSelector.jsx'; +import { TypeaheadSelectWithHeaders } from '../common/typeaheadSelectWithHeaders.jsx'; import { FormHelper } from "cockpit-components-form-helper.jsx"; import { FileAutoComplete } from "cockpit-components-file-autocomplete.jsx"; import { @@ -435,23 +436,23 @@ class OSRow extends React.Component { oldOsEntries.push(os); } + const make_option = os => ({ + value: os.shortId, + content: getOSStringRepresentation(os), + description: getOSDescription(os), + }); + + const selectOptions = [ + { header: _("Recommended operating systems") }, + ...newOsEntries.map(make_option), + { header: _("Unsupported and older operating systems") }, + ...oldOsEntries.map(make_option), + ]; + this.state = { typeAheadKey: Math.random(), - newOsEntries, - oldOsEntries, - }; - this.createValue = os => { - return ({ - toString: function() { return this.displayName }, - compareTo: function(value) { - if (typeof value == "string") - return this.shortId.toLowerCase().includes(value.toLowerCase()) || this.displayName.toLowerCase().includes(value.toLowerCase()); - else - return this.shortId == value.shortId; - }, - ...os, - displayName: getOSStringRepresentation(os), - }); + selectOptions, + osInfoListExt, }; } @@ -464,44 +465,21 @@ class OSRow extends React.Component { data-loading={!!isLoading} id="os-select-group" label={_("Operating system")}> - { - this.setState({ - isOpen: false - }); - onValueChanged('os', value); - }} - onClear={() => { - this.setState({ isOpen: false }); - onValueChanged('os', null); - }} - onToggle={(_event, isOpen) => this.setState({ isOpen })} - isOpen={this.state.isOpen} - menuAppendTo="parent" - isGrouped - > - - {this.state.newOsEntries - .map(os => ()) - } - - - {this.state.oldOsEntries - .map(os => ()) - } - - + { + const os = this.state.osInfoListExt.find(os => os.shortId === value); + if (os) + onValueChanged('os', os); + }} + onClearSelection={() => { + onValueChanged('os', null); + }} + /> ); diff --git a/src/machines.scss b/src/machines.scss index a4f6b4c7c..0d7ce67b7 100644 --- a/src/machines.scss +++ b/src/machines.scss @@ -102,3 +102,10 @@ #storage-pool-delete-modal span.pf-v5-c-check__body { margin-block-start: 0; } + +#os-select { + /* Don't get too tall */ + max-block-size: min(20rem, 50vh); + /* Don't have a horizontal scrollbar */ + overflow-y: auto; +} diff --git a/test/check-machines-create b/test/check-machines-create index 3f26609f6..652a3bd89 100755 --- a/test/check-machines-create +++ b/test/check-machines-create @@ -935,7 +935,7 @@ vnc_password= "{vnc_passwd}" TREE_URL = 'https://archive.fedoraproject.org/pub/archive/fedora/linux/releases/28/Server/x86_64/os' # LINUX can be filtered if 3 years old - OLD_FILTERED_OS = 'Red Hat Enterprise Linux 8.3' + OLD_FILTERED_OS = 'Red Hat Enterprise Linux 8.3 (Ootpa)' UNSUPPORTED_FILTERED_OS = 'Fedora 34' NOT_FOUND_FILTERED_OS = 'Red Hat Enterprise Linux 4.9' @@ -1079,7 +1079,7 @@ vnc_password= "{vnc_passwd}" # https://bugzilla.redhat.com/show_bug.cgi?id=1987120 b.select_from_dropdown("#source-type", "url") fake_fedora = "Fedora 28" # 128 MiB minimum storage - suse = "SUSE CaaS Platform Unknown" # 20 GiB minimum storage + suse = "SUSE CaaS Platform Unknown (unknown)" # 20 GiB minimum storage b.set_input_text("#os-select-group input", fake_fedora) b.click(f"#os-select li button:contains('{fake_fedora}')") b.wait_val("#storage-limit", "128") @@ -1139,15 +1139,13 @@ vnc_password= "{vnc_passwd}" b.wait_not_present("#navbar-oops") # re-input an OS which is "Fedora 28" - # need to click the extend button to show the OS list - b.click("#os-select-group button[aria-label=\"Options menu\"]") b.set_input_text("#os-select-group input", "Fedora") b.click("#os-select li button:contains('Fedora 28')") b.wait_attr_contains("#os-select-group input", "value", "Fedora 28") b.wait_not_present("#navbar-oops") # click the 'X' button to clear the OS input and check there is no Ooops - b.click("#os-select-group button[aria-label=\"Clear all\"]") + b.click("#os-select-group button[aria-label=\"Clear input value\"]") b.wait_attr("#os-select-group input", "value", "") b.wait_not_present("#navbar-oops") @@ -1186,12 +1184,12 @@ vnc_password= "{vnc_passwd}" if not self.os_is_not_found: # There should be exactly one entry - b.wait_in_text("#os-select li button", self.os_name) + b.wait_in_text("#os-select li button:not(:disabled)", self.os_name) # It might have a description if self.os_is_unsupported: - b.wait_in_text("#os-select li button", "Vendor support ended") + b.wait_in_text("#os-select li button:not(:disabled)", "Vendor support ended") elif self.os_is_old: - b.wait_in_text("#os-select li button", "Released ") + b.wait_in_text("#os-select li button:not(:disabled)", "Released ") else: b.wait_in_text("#os-select li button", "No results found") @@ -1215,14 +1213,14 @@ vnc_password= "{vnc_passwd}" def checkOsSorted(self, sorted_list): b = self.browser - b.click("#os-select-group .pf-v5-c-select .pf-v5-c-button") + b.click("#os-select-group button.pf-v5-c-menu-toggle__button") # Find the first OS from the sorted list, and get a text of it's next neighbour next_os = b.text(f"#os-select-group li:contains({sorted_list[0]}) + li") # The next neighbour should contain the second OS from the sorted list self.assertEqual(next_os, sorted_list[1]) # make the select list go away to not obscure other elements - b.click("#os-select-group .pf-v5-c-select__toggle-button") + b.click("#os-select-group button.pf-v5-c-menu-toggle__button") b.wait_not_present("#os-select li") return self @@ -1364,7 +1362,7 @@ vnc_password= "{vnc_passwd}" b.wait_not_present("#offline-token") if self.os_name: - b.click("#os-select-group > div button") + b.click("#os-select-group button.pf-v5-c-menu-toggle__button") b.click(f"#os-select li:contains('{self.os_name}') button") b.wait_attr("#os-select-group input", "value", self.os_name)