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)