From 891af07e4877db2dc7867853c2e901f55b92ec6d Mon Sep 17 00:00:00 2001 From: Aviv Turgeman Date: Thu, 28 Nov 2024 17:25:07 +0200 Subject: [PATCH] CNV-45822: Vm tree view 3 Signed-off-by: Aviv Turgeman --- locales/en/plugin__kubevirt-plugin.json | 6 + .../components/FolderSelect/FolderSelect.tsx | 37 +++ .../FolderSelect/SelectTypeahead.tsx | 275 ++++++++++++++++++ .../FolderSelect/hooks/useFolderOptions.tsx | 41 +++ .../FolderSelect/utils/constants.ts | 1 + .../components/FolderSelect/utils/utils.ts | 15 + .../MoveVMToFolderModal.tsx | 50 ++++ src/utils/store/customizeInstanceType.ts | 4 +- .../CreateVMFooter/CreateVMFooter.tsx | 40 +-- .../components/DetailsLeftGrid.tsx | 22 +- .../state/utils/state.ts | 1 + .../state/utils/types.ts | 2 + .../CreateFromInstanceTypes/utils/utils.ts | 27 +- .../tabs/CustomizeInstanceTypeDetailsTab.tsx | 29 +- .../TemplatesCatalogDrawerCreateForm.tsx | 14 + .../hooks/useCreateDrawerForm.tsx | 14 +- .../components/WizardOverviewGrid.tsx | 32 +- .../actions/VirtualMachineActionFactory.tsx | 97 +++--- .../hooks/useVirtualMachineActionsProvider.ts | 4 +- .../useVirtualMachineActionsProvider.test.tsx | 12 +- .../VirtualMachinesOverviewTabDetails.tsx | 8 +- .../virtualmachines/tree/utils/utils.tsx | 1 + 22 files changed, 624 insertions(+), 108 deletions(-) create mode 100644 src/utils/components/FolderSelect/FolderSelect.tsx create mode 100644 src/utils/components/FolderSelect/SelectTypeahead.tsx create mode 100644 src/utils/components/FolderSelect/hooks/useFolderOptions.tsx create mode 100644 src/utils/components/FolderSelect/utils/constants.ts create mode 100644 src/utils/components/FolderSelect/utils/utils.ts create mode 100644 src/utils/components/MoveVMToFolderModal/MoveVMToFolderModal.tsx diff --git a/locales/en/plugin__kubevirt-plugin.json b/locales/en/plugin__kubevirt-plugin.json index a00e2f8c0..edfbb440f 100644 --- a/locales/en/plugin__kubevirt-plugin.json +++ b/locales/en/plugin__kubevirt-plugin.json @@ -328,6 +328,7 @@ "Create activation key": "Create activation key", "Create DataSource": "Create DataSource", "Create MigrationPolicy": "Create MigrationPolicy", + "Create new option \"{{filterValue}}\"": "Create new option \"{{filterValue}}\"", "Create new sysprep": "Create new sysprep", "Create new VirtualMachine": "Create new VirtualMachine", "Create project": "Create project", @@ -545,6 +546,7 @@ "Filter": "Filter", "Filter by keyword...": "Filter by keyword...", "Flavor": "Flavor", + "Folder": "Folder", "Follow guided documentation to build applications and familiarize yourself with key features.": "Follow guided documentation to build applications and familiarize yourself with key features.", "Force stop": "Force stop", "Form view": "Form view", @@ -761,6 +763,8 @@ "More info: ": "More info: ", "Mount point": "Mount point", "Mount Windows drivers disk": "Mount Windows drivers disk", + "Move <1>{getName(vm)} VirtualMachine to folder": "Move <1>{getName(vm)} VirtualMachine to folder", + "Move to folder": "Move to folder", "MP": "MP", "MTV": "MTV", "my-storage-claim": "my-storage-claim", @@ -869,6 +873,7 @@ "None": "None", "Not available": "Not available", "Not configured": "Not configured", + "Not found": "Not found", "Not migratable": "Not migratable", "Note that for Node field expressions, entering a full path is required in the \"Key\" field (e.g. \"metadata.name: value\").": "Note that for Node field expressions, entering a full path is required in the \"Key\" field (e.g. \"metadata.name: value\").", "O series": "O series", @@ -1053,6 +1058,7 @@ "Search by labels...": "Search by labels...", "Search by name...": "Search by name...", "Search by reason...": "Search by reason...", + "Search folder": "Search folder", "Search for configurable items": "Search for configurable items", "Secondary NAD networks": "Secondary NAD networks", "seconds": "seconds", diff --git a/src/utils/components/FolderSelect/FolderSelect.tsx b/src/utils/components/FolderSelect/FolderSelect.tsx new file mode 100644 index 000000000..425fee855 --- /dev/null +++ b/src/utils/components/FolderSelect/FolderSelect.tsx @@ -0,0 +1,37 @@ +import React, { FC } from 'react'; + +import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; + +import useFolderOptions from './hooks/useFolderOptions'; +import SelectTypeahead from './SelectTypeahead'; + +type FoldersSelectProps = { + isFullWidth?: boolean; + namespace: string; + selectedFolder: string; + setSelectedFolder: (newFolder: string) => void; +}; +const FolderSelect: FC = ({ + isFullWidth = false, + namespace, + selectedFolder, + setSelectedFolder, +}) => { + const { t } = useKubevirtTranslation(); + const [folderOptions, setFolderOptions] = useFolderOptions(namespace); + + return ( + + ); +}; + +export default FolderSelect; diff --git a/src/utils/components/FolderSelect/SelectTypeahead.tsx b/src/utils/components/FolderSelect/SelectTypeahead.tsx new file mode 100644 index 000000000..aaf74c6f0 --- /dev/null +++ b/src/utils/components/FolderSelect/SelectTypeahead.tsx @@ -0,0 +1,275 @@ +import React, { Dispatch, FC, SetStateAction, useEffect, useRef, useState } from 'react'; + +import { isEmpty } from '@kubevirt-utils/utils/utils'; +import { + Button, + ButtonVariant, + MenuToggle, + MenuToggleElement, + Select, + SelectList, + SelectOption, + SelectOptionProps, + TextInputGroup, + TextInputGroupMain, + TextInputGroupUtilities, +} from '@patternfly/react-core'; +import { FolderIcon, SearchIcon } from '@patternfly/react-icons'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon'; + +import { CREATE_NEW } from './utils/constants'; +import { createItemId, getCreateNewFolderOption } from './utils/utils'; + +type SelectTypeaheadProps = { + canCreate?: boolean; + dataTestId?: string; + initialOptions: SelectOptionProps[]; + isFullWidth?: boolean; + placeholder?: string; + selected: string; + setInitialOptions: Dispatch>; + setSelected: (newFolder: string) => void; +}; +const SelectTypeahead: FC = ({ + canCreate = false, + dataTestId, + initialOptions, + isFullWidth = false, + placeholder, + selected, + setInitialOptions, + setSelected, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [inputValue, setInputValue] = useState(selected); + const [filterValue, setFilterValue] = useState(''); + const [selectOptions, setSelectOptions] = useState(initialOptions); + const [focusedItemIndex, setFocusedItemIndex] = useState(null); + const [activeItemId, setActiveItemId] = useState(null); + const textInputRef = useRef(); + + useEffect(() => { + if (isEmpty(initialOptions)) { + setSelectOptions([getCreateNewFolderOption(filterValue, canCreate)]); + return; + } + let newSelectOptions: SelectOptionProps[] = initialOptions || []; + + // Filter menu items based on the text input value when one exists + if (filterValue) { + newSelectOptions = initialOptions?.filter((menuItem) => + String(menuItem.children).toLowerCase().includes(filterValue.toLowerCase()), + ); + + // If no option matches the filter exactly, display creation option + if (!initialOptions?.some((option) => option.value === filterValue) && canCreate) { + newSelectOptions = [...newSelectOptions, getCreateNewFolderOption(filterValue, canCreate)]; + } + } + + setSelectOptions(newSelectOptions); + }, [canCreate, filterValue, initialOptions]); + + const setActiveAndFocusedItem = (itemIndex: number) => { + setFocusedItemIndex(itemIndex); + const focusedItem = selectOptions[itemIndex]; + setActiveItemId(createItemId(focusedItem.value)); + }; + + const resetActiveAndFocusedItem = () => { + setFocusedItemIndex(null); + setActiveItemId(null); + }; + + const closeMenu = () => { + setIsOpen(false); + resetActiveAndFocusedItem(); + }; + + const selectOption = (value: number | string, content: number | string) => { + setInputValue(String(content)); + setFilterValue(''); + setSelected(String(value)); + + closeMenu(); + }; + + const onSelect = ( + _event: React.MouseEvent | undefined, + value: number | string | undefined, + ) => { + if (value) { + if (value === CREATE_NEW) { + if (!initialOptions?.some((item) => item.children === filterValue)) { + setInitialOptions((prevFolders) => [ + ...(prevFolders || []), + { children: filterValue, icon: , value: filterValue }, + ]); + } + setSelected(filterValue); + setFilterValue(''); + closeMenu(); + } else { + const optionText = selectOptions.find((option) => option.value === value)?.children; + selectOption(value, optionText as string); + } + } + }; + + const onTextInputChange = (_event: React.FormEvent, value: string) => { + setInputValue(value); + setFilterValue(value); + + if (!isEmpty(value) && !isOpen) setIsOpen(true); + + resetActiveAndFocusedItem(); + + if (value !== selected) { + setSelected(''); + } + }; + + const handleMenuArrowKeys = (key: string) => { + let indexToFocus = 0; + + if (!isOpen) { + setIsOpen(true); + } + + if (selectOptions.every((option) => option.isDisabled)) { + return; + } + + if (key === 'ArrowUp') { + // When no index is set or at the first index, focus to the last, otherwise decrement focus index + if (focusedItemIndex === null || focusedItemIndex === 0) { + indexToFocus = selectOptions.length - 1; + } else { + indexToFocus = focusedItemIndex - 1; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus--; + if (indexToFocus === -1) { + indexToFocus = selectOptions.length - 1; + } + } + } + + if (key === 'ArrowDown') { + // When no index is set or at the last index, focus to the first, otherwise increment focus index + if (focusedItemIndex === null || focusedItemIndex === selectOptions.length - 1) { + indexToFocus = 0; + } else { + indexToFocus = focusedItemIndex + 1; + } + + // Skip disabled options + while (selectOptions[indexToFocus].isDisabled) { + indexToFocus++; + if (indexToFocus === selectOptions.length) { + indexToFocus = 0; + } + } + } + + setActiveAndFocusedItem(indexToFocus); + }; + + const onInputKeyDown = (event: React.KeyboardEvent) => { + const focusedItem = focusedItemIndex !== null ? selectOptions[focusedItemIndex] : null; + + switch (event.key) { + case 'Enter': + if (isOpen && focusedItem && !focusedItem.isAriaDisabled) { + onSelect(undefined, focusedItem.value as string); + } + + if (!isOpen) { + setIsOpen(true); + } + + break; + case 'ArrowUp': + case 'ArrowDown': + event.preventDefault(); + handleMenuArrowKeys(event.key); + break; + } + }; + + const onToggleClick = () => { + setIsOpen((open) => !open); + textInputRef?.current?.focus(); + }; + + const onClearButtonClick = () => { + setSelected(''); + setInputValue(''); + setFilterValue(''); + resetActiveAndFocusedItem(); + textInputRef?.current?.focus(); + }; + + const toggle = (toggleRef: React.Ref) => ( + + + } + innerRef={textInputRef} + onChange={onTextInputChange} + onClick={onToggleClick} + onKeyDown={onInputKeyDown} + placeholder={placeholder} + value={inputValue} + {...(activeItemId && { 'aria-activedescendant': activeItemId })} + isExpanded={isOpen} + role="combobox" + /> + + {!isEmpty(inputValue) && ( + + + + )} + + + ); + + return ( + + ); +}; + +export default SelectTypeahead; diff --git a/src/utils/components/FolderSelect/hooks/useFolderOptions.tsx b/src/utils/components/FolderSelect/hooks/useFolderOptions.tsx new file mode 100644 index 000000000..c7541084d --- /dev/null +++ b/src/utils/components/FolderSelect/hooks/useFolderOptions.tsx @@ -0,0 +1,41 @@ +import React, { Dispatch, SetStateAction, useEffect, useState } from 'react'; + +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import { VirtualMachineModelGroupVersionKind } from '@kubevirt-utils/models'; +import { getLabel } from '@kubevirt-utils/resources/shared'; +import { isEmpty } from '@kubevirt-utils/utils/utils'; +import { useK8sWatchResource } from '@openshift-console/dynamic-plugin-sdk'; +import { SelectOptionProps } from '@patternfly/react-core'; +import { FolderIcon } from '@patternfly/react-icons'; +import { VM_FOLDER_LABEL } from '@virtualmachines/tree/utils/constants'; + +type UseFolderOptions = ( + namespace: string, +) => [SelectOptionProps[], Dispatch>]; + +const useFolderOptions: UseFolderOptions = (namespace) => { + const [folders, setFolders] = useState(); + const [vms] = useK8sWatchResource({ + groupVersionKind: VirtualMachineModelGroupVersionKind, + isList: true, + namespace, + }); + + useEffect(() => { + if (isEmpty(vms)) return null; + + const folderOptions = vms.reduce((uniqueValues, vm) => { + const folderLabel = getLabel(vm, VM_FOLDER_LABEL); + if (folderLabel && !uniqueValues.some((obj) => obj.value === folderLabel)) { + uniqueValues.push({ children: folderLabel, icon: , value: folderLabel }); + } + return uniqueValues; + }, []); + + setFolders(folderOptions); + }, [vms]); + + return [folders, setFolders]; +}; + +export default useFolderOptions; diff --git a/src/utils/components/FolderSelect/utils/constants.ts b/src/utils/components/FolderSelect/utils/constants.ts new file mode 100644 index 000000000..c0d575b89 --- /dev/null +++ b/src/utils/components/FolderSelect/utils/constants.ts @@ -0,0 +1 @@ +export const CREATE_NEW = 'create'; diff --git a/src/utils/components/FolderSelect/utils/utils.ts b/src/utils/components/FolderSelect/utils/utils.ts new file mode 100644 index 000000000..e0c229a4f --- /dev/null +++ b/src/utils/components/FolderSelect/utils/utils.ts @@ -0,0 +1,15 @@ +import { t } from '@kubevirt-utils/hooks/useKubevirtTranslation'; +import { isEmpty } from '@kubevirt-utils/utils/utils'; +import { SelectOptionProps } from '@patternfly/react-core'; + +import { CREATE_NEW } from './constants'; +export const createItemId = (value: any) => `select-typeahead-${value.replace(' ', '-')}`; + +export const getCreateNewFolderOption = ( + filterValue: string, + canCreate = false, +): SelectOptionProps => ({ + children: canCreate ? t(`Create new option "{{filterValue}}"`, { filterValue }) : t('Not found'), + isDisabled: !canCreate || isEmpty(filterValue), + value: CREATE_NEW, +}); diff --git a/src/utils/components/MoveVMToFolderModal/MoveVMToFolderModal.tsx b/src/utils/components/MoveVMToFolderModal/MoveVMToFolderModal.tsx new file mode 100644 index 000000000..dbd426b2c --- /dev/null +++ b/src/utils/components/MoveVMToFolderModal/MoveVMToFolderModal.tsx @@ -0,0 +1,50 @@ +import React, { FC, useState } from 'react'; +import { Trans } from 'react-i18next'; + +import { V1VirtualMachine } from '@kubevirt-ui/kubevirt-api/kubevirt'; +import FolderSelect from '@kubevirt-utils/components/FolderSelect/FolderSelect'; +import TabModal from '@kubevirt-utils/components/TabModal/TabModal'; +import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; +import { getLabel, getName, getNamespace } from '@kubevirt-utils/resources/shared'; +import { Stack, StackItem } from '@patternfly/react-core'; +import { VM_FOLDER_LABEL } from '@virtualmachines/tree/utils/constants'; + +type MoveVMToFolderModalProps = { + isOpen: boolean; + onClose: () => void; + onSubmit: (folderName: string) => Promise; + vm: V1VirtualMachine; +}; + +const MoveVMToFolderModal: FC = ({ isOpen, onClose, onSubmit, vm }) => { + const { t } = useKubevirtTranslation(); + const [folderName, setFolderName] = useState(getLabel(vm, VM_FOLDER_LABEL)); + + return ( + + headerText={t('Move to folder')} + isOpen={isOpen} + onClose={onClose} + onSubmit={() => onSubmit(folderName)} + submitBtnText={t('Move to folder')} + > + + + + Move {getName(vm)} VirtualMachine to folder + + + + + + + + ); +}; + +export default MoveVMToFolderModal; diff --git a/src/utils/store/customizeInstanceType.ts b/src/utils/store/customizeInstanceType.ts index c13e228ef..f775c339e 100644 --- a/src/utils/store/customizeInstanceType.ts +++ b/src/utils/store/customizeInstanceType.ts @@ -24,7 +24,7 @@ effect(() => { type UpdateCustomizeInstanceTypeArgs = { data: any; merge?: boolean; - path?: string; + path?: string | string[]; }[]; export type UpdateCustomizeInstanceType = ( @@ -49,7 +49,7 @@ export const updateCustomizeInstanceType: UpdateCustomizeInstanceType = ( } vm = produce(vm, (vmDraft) => { - const pathParts = path.split('.'); + const pathParts = typeof path === 'string' ? path.split('.') : path; let obj = vmDraft; pathParts.forEach((part: string, index: number) => { diff --git a/src/views/catalog/CreateFromInstanceTypes/components/CreateVMFooter/CreateVMFooter.tsx b/src/views/catalog/CreateFromInstanceTypes/components/CreateVMFooter/CreateVMFooter.tsx index 067384e01..4cbe19c9b 100644 --- a/src/views/catalog/CreateFromInstanceTypes/components/CreateVMFooter/CreateVMFooter.tsx +++ b/src/views/catalog/CreateFromInstanceTypes/components/CreateVMFooter/CreateVMFooter.tsx @@ -101,13 +101,13 @@ const CreateVMFooter: FC = () => { setIsSubmitting(true); setError(null); - const vmToCreate = generateVM( - instanceTypeVMState, - vmNamespaceTarget, + const vmToCreate = generateVM({ + autoUpdateEnabled, + instanceTypeState: instanceTypeVMState, startVM, subscriptionData, - autoUpdateEnabled, - ); + targetNamespace: vmNamespaceTarget, + }); logITFlowEvent(CREATE_VM_BUTTON_CLICKED, vmToCreate); @@ -164,21 +164,21 @@ const CreateVMFooter: FC = () => { try { await setVM( - generateVM( - instanceTypeVMState, - vmNamespaceTarget, + generateVM({ + autoUpdateEnabled, + instanceTypeState: instanceTypeVMState, startVM, subscriptionData, - autoUpdateEnabled, - ), + targetNamespace: vmNamespaceTarget, + }), ); - vmSignal.value = generateVM( - instanceTypeVMState, - vmNamespaceTarget, + vmSignal.value = generateVM({ + autoUpdateEnabled, + instanceTypeState: instanceTypeVMState, startVM, subscriptionData, - autoUpdateEnabled, - ); + targetNamespace: vmNamespaceTarget, + }); logITFlowEvent(CUSTOMIZE_VM_BUTTON_CLICKED, vmSignal.value); @@ -260,13 +260,13 @@ const CreateVMFooter: FC = () => { logITFlowEvent(VIEW_YAML_AND_CLI_CLICKED, null, { vmName: vmName }); createModal((props) => ( )); diff --git a/src/views/catalog/CreateFromInstanceTypes/components/VMDetailsSection/components/DetailsLeftGrid.tsx b/src/views/catalog/CreateFromInstanceTypes/components/VMDetailsSection/components/DetailsLeftGrid.tsx index a0d100f2b..07494de9f 100644 --- a/src/views/catalog/CreateFromInstanceTypes/components/VMDetailsSection/components/DetailsLeftGrid.tsx +++ b/src/views/catalog/CreateFromInstanceTypes/components/VMDetailsSection/components/DetailsLeftGrid.tsx @@ -5,6 +5,7 @@ import { instanceTypeActionType, UseInstanceTypeAndPreferencesValues, } from '@catalog/CreateFromInstanceTypes/state/utils/types'; +import FolderSelect from '@kubevirt-utils/components/FolderSelect/FolderSelect'; import VirtualMachineDescriptionItem from '@kubevirt-utils/components/VirtualMachineDescriptionItem/VirtualMachineDescriptionItem'; import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; import { convertResourceArrayToMap } from '@kubevirt-utils/resources/shared'; @@ -19,8 +20,9 @@ type DetailsLeftGridProps = { const DetailsLeftGrid: FC = ({ instanceTypesAndPreferencesData }) => { const { t } = useKubevirtTranslation(); - const { instanceTypeVMState, setInstanceTypeVMState } = useInstanceTypeVMStore(); - const { selectedBootableVolume, selectedInstanceType, vmName } = instanceTypeVMState; + const { instanceTypeVMState, setInstanceTypeVMState, vmNamespaceTarget } = + useInstanceTypeVMStore(); + const { folder, selectedBootableVolume, selectedInstanceType, vmName } = instanceTypeVMState; const { clusterInstanceTypes, preferences } = instanceTypesAndPreferencesData; const preferencesMap = useMemo(() => convertResourceArrayToMap(preferences), [preferences]); @@ -52,6 +54,22 @@ const DetailsLeftGrid: FC = ({ instanceTypesAndPreferences } descriptionHeader={t('Name')} /> + { + setInstanceTypeVMState({ + payload: newFolder, + type: instanceTypeActionType.setFolder, + }); + }} + isFullWidth + namespace={vmNamespaceTarget} + selectedFolder={folder} + /> + } + descriptionHeader={t('Folder')} + /> { +type GenerateVMArgs = { + autoUpdateEnabled?: boolean; + instanceTypeState: InstanceTypeVMState; + startVM: boolean; + subscriptionData: RHELAutomaticSubscriptionData; + targetNamespace: string; +}; +type GenerateVMCallback = (props: GenerateVMArgs) => V1VirtualMachine; + +export const generateVM: GenerateVMCallback = ({ + autoUpdateEnabled, + instanceTypeState, + startVM, + subscriptionData, + targetNamespace, +}) => { const { + folder, pvcSource, selectedBootableVolume, selectedInstanceType, @@ -126,6 +136,7 @@ export const generateVM = ( metadata: { name: virtualmachineName, namespace: targetNamespace, + ...(folder && { labels: { [VM_FOLDER_LABEL]: folder } }), }, spec: { dataVolumeTemplates: [ diff --git a/src/views/catalog/CustomizeInstanceType/tabs/configuration/utils/tabs/CustomizeInstanceTypeDetailsTab.tsx b/src/views/catalog/CustomizeInstanceType/tabs/configuration/utils/tabs/CustomizeInstanceTypeDetailsTab.tsx index 6387b4c00..4cde0ccd2 100644 --- a/src/views/catalog/CustomizeInstanceType/tabs/configuration/utils/tabs/CustomizeInstanceTypeDetailsTab.tsx +++ b/src/views/catalog/CustomizeInstanceType/tabs/configuration/utils/tabs/CustomizeInstanceTypeDetailsTab.tsx @@ -8,19 +8,21 @@ import HeadlessMode from '@kubevirt-utils/components/HeadlessMode/HeadlessMode'; import HostnameModal from '@kubevirt-utils/components/HostnameModal/HostnameModal'; import Loading from '@kubevirt-utils/components/Loading/Loading'; import { useModal } from '@kubevirt-utils/components/ModalProvider/ModalProvider'; +import MoveVMToFolderModal from '@kubevirt-utils/components/MoveVMToFolderModal/MoveVMToFolderModal'; import MutedTextSpan from '@kubevirt-utils/components/MutedTextSpan/MutedTextSpan'; import SearchItem from '@kubevirt-utils/components/SearchItem/SearchItem'; import VirtualMachineDescriptionItem from '@kubevirt-utils/components/VirtualMachineDescriptionItem/VirtualMachineDescriptionItem'; import { DISABLED_GUEST_SYSTEM_LOGS_ACCESS } from '@kubevirt-utils/hooks/useFeatures/constants'; import { useFeatures } from '@kubevirt-utils/hooks/useFeatures/useFeatures'; import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; -import { asAccessReview, getAnnotation, getName } from '@kubevirt-utils/resources/shared'; +import { asAccessReview, getAnnotation, getLabel, getName } from '@kubevirt-utils/resources/shared'; import { DESCRIPTION_ANNOTATION, getDevices, getHostname } from '@kubevirt-utils/resources/vm'; import { updateCustomizeInstanceType, vmSignal } from '@kubevirt-utils/store/customizeInstanceType'; import { K8sVerb, useAccessReview } from '@openshift-console/dynamic-plugin-sdk'; import { DescriptionList, Grid, GridItem, Switch, Title } from '@patternfly/react-core'; import DetailsSectionBoot from '@virtualmachines/details/tabs/configuration/details/components/DetailsSectionBoot'; import DetailsSectionHardware from '@virtualmachines/details/tabs/configuration/details/components/DetailsSectionHardware'; +import { VM_FOLDER_LABEL } from '@virtualmachines/tree/utils/constants'; const CustomizeInstanceTypeDetailsTab = () => { const vm = vmSignal.value; @@ -84,6 +86,31 @@ const CustomizeInstanceTypeDetailsTab = () => { descriptionHeader={{t('Description')}} isEdit /> + + createModal(({ isOpen, onClose }) => ( + + Promise.resolve( + updateCustomizeInstanceType([ + { + data: folderName, + path: ['metadata', 'labels', VM_FOLDER_LABEL], + }, + ]), + ) + } + isOpen={isOpen} + onClose={onClose} + vm={vm} + /> + )) + } + data-test-id={`${vmName}-folder`} + descriptionData={getLabel(vm, VM_FOLDER_LABEL)} + descriptionHeader={{t('Folder')}} + isEdit + /> createModal(({ isOpen, onClose }) => ( diff --git a/src/views/catalog/templatescatalog/components/TemplatesCatalogDrawer/TemplatesCatalogDrawerCreateForm.tsx b/src/views/catalog/templatescatalog/components/TemplatesCatalogDrawer/TemplatesCatalogDrawerCreateForm.tsx index f85988e1c..641062bde 100644 --- a/src/views/catalog/templatescatalog/components/TemplatesCatalogDrawer/TemplatesCatalogDrawerCreateForm.tsx +++ b/src/views/catalog/templatescatalog/components/TemplatesCatalogDrawer/TemplatesCatalogDrawerCreateForm.tsx @@ -1,6 +1,7 @@ import React, { FC, memo } from 'react'; import { DRAWER_FORM_ID } from '@catalog/templatescatalog/utils/consts'; +import FolderSelect from '@kubevirt-utils/components/FolderSelect/FolderSelect'; import VirtualMachineDescriptionItem from '@kubevirt-utils/components/VirtualMachineDescriptionItem/VirtualMachineDescriptionItem'; import { RUNSTRATEGY_ALWAYS, @@ -43,11 +44,13 @@ export const TemplatesCatalogDrawerCreateForm: FC + + + { + onChangeFolder(newFolder); + }} + namespace={namespace} + selectedFolder={folder} + /> + + { + setVM( + produce(vm, (draftVM) => { + if (folderName && folderName !== getLabel(vm, VM_FOLDER_LABEL)) + draftVM.metadata.labels = { ...vm?.metadata?.labels, [VM_FOLDER_LABEL]: folderName }; + }), + ); + }; + return { createError, + folder: getLabel(vm, VM_FOLDER_LABEL), isCustomizeDisabled: !processedTemplateAccessReview || isCustomizing, isCustomizeLoading: isCustomizing || modelsLoading, isQuickCreateDisabled: @@ -294,6 +305,7 @@ const useCreateDrawerForm = ( storageClassRequiredMissing, isQuickCreateLoading: isQuickCreating || modelsLoading, nameField, + onChangeFolder, onChangeStartVM, onCustomize, onQuickCreate, diff --git a/src/views/catalog/wizard/tabs/overview/components/WizardOverviewGrid.tsx b/src/views/catalog/wizard/tabs/overview/components/WizardOverviewGrid.tsx index 98dfc7ce8..5c98d7016 100644 --- a/src/views/catalog/wizard/tabs/overview/components/WizardOverviewGrid.tsx +++ b/src/views/catalog/wizard/tabs/overview/components/WizardOverviewGrid.tsx @@ -13,11 +13,12 @@ import { getBootloaderTitleFromVM } from '@kubevirt-utils/components/FirmwareBoo import HardwareDevices from '@kubevirt-utils/components/HardwareDevices/HardwareDevices'; import HostnameModal from '@kubevirt-utils/components/HostnameModal/HostnameModal'; import { useModal } from '@kubevirt-utils/components/ModalProvider/ModalProvider'; +import MoveVMToFolderModal from '@kubevirt-utils/components/MoveVMToFolderModal/MoveVMToFolderModal'; import WorkloadProfileModal from '@kubevirt-utils/components/WorkloadProfileModal/WorkloadProfileModal'; import { DISABLED_GUEST_SYSTEM_LOGS_ACCESS } from '@kubevirt-utils/hooks/useFeatures/constants'; import { useFeatures } from '@kubevirt-utils/hooks/useFeatures/useFeatures'; import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; -import { getAnnotation } from '@kubevirt-utils/resources/shared'; +import { getAnnotation, getLabel } from '@kubevirt-utils/resources/shared'; import { getVmCPUMemory, WORKLOADS_LABELS } from '@kubevirt-utils/resources/template'; import { getCPU, @@ -29,6 +30,7 @@ import { } from '@kubevirt-utils/resources/vm'; import { readableSizeUnit } from '@kubevirt-utils/utils/units'; import { DescriptionList, Grid, GridItem, Switch } from '@patternfly/react-core'; +import { VM_FOLDER_LABEL } from '@virtualmachines/tree/utils/constants'; import { printableVMStatus } from '@virtualmachines/utils'; import { WizardDescriptionItem } from '../../../components/WizardDescriptionItem'; @@ -121,6 +123,34 @@ const WizardOverviewGrid: FC = ({ tabsData, updateVM, v title={t('Namespace')} /> + + createModal(({ isOpen, onClose }) => ( + + updateVM((draftVM) => { + if (!folderName) { + delete draftVM?.metadata?.labels?.[VM_FOLDER_LABEL]; + return; + } + draftVM.metadata.labels = { + ...draftVM?.metadata?.labels, + [VM_FOLDER_LABEL]: folderName, + }; + }) + } + isOpen={isOpen} + onClose={onClose} + vm={vm} + /> + )) + } + description={getLabel(vm, VM_FOLDER_LABEL)} + isEdit + testId="wizard-overview-folder" + title={t('Folder')} + /> + void): Action => { - return { - accessReview: asAccessReview(VirtualMachineModel, vm, 'patch'), - cta: () => - createModal(({ isOpen, onClose }) => ( - - k8sPatch({ - data: [ - { - op: 'replace', - path: '/metadata/annotations', - value: updatedAnnotations, - }, - ], - model: VirtualMachineModel, - resource: vm, - }) - } - isOpen={isOpen} - obj={vm} - onClose={onClose} - /> - )), - disabled: false, - id: 'vm-action-edit-annotations', - label: t('Edit annotations'), - }; - }, - - editLabels: (vm: V1VirtualMachine, createModal: (modal: ModalComponent) => void): Action => { - return { - accessReview: asAccessReview(VirtualMachineModel, vm, 'patch'), - cta: () => - createModal(({ isOpen, onClose }) => ( - - k8sPatch({ - data: [ - { - op: 'replace', - path: '/metadata/labels', - value: labels, - }, - ], - model: VirtualMachineModel, - resource: vm, - }) - } - isOpen={isOpen} - obj={vm} - onClose={onClose} - /> - )), - disabled: false, - id: 'vm-action-edit-labels', - label: t('Edit labels'), - }; - }, forceStop: (vm: V1VirtualMachine): Action => { return { accessReview: asAccessReview(VirtualMachineModel, vm, 'patch'), @@ -213,6 +154,40 @@ export const VirtualMachineActionFactory = { label: 'Migration', options: migrationActions, }), + moveToFolder: (vm: V1VirtualMachine, createModal: (modal: ModalComponent) => void): Action => { + return { + accessReview: { + group: VirtualMachineModel.apiGroup, + namespace: getNamespace(vm), + resource: VirtualMachineModel.plural, + verb: 'patch', + }, + cta: () => + createModal((props) => ( + { + const labels = vm?.metadata?.labels || {}; + labels[VM_FOLDER_LABEL] = folderName; + return k8sPatch({ + data: [ + { + op: 'replace', + path: '/metadata/labels', + value: labels, + }, + ], + model: VirtualMachineModel, + resource: vm, + }); + }} + vm={vm} + {...props} + /> + )), + id: 'vm-action-move-to-folder', + label: t('Move to folder'), + }; + }, pause: (vm: V1VirtualMachine): Action => { return { accessReview: asAccessReview(VirtualMachineModel, vm, 'patch'), diff --git a/src/views/virtualmachines/actions/hooks/useVirtualMachineActionsProvider.ts b/src/views/virtualmachines/actions/hooks/useVirtualMachineActionsProvider.ts index 193c8f41b..528f78f8b 100644 --- a/src/views/virtualmachines/actions/hooks/useVirtualMachineActionsProvider.ts +++ b/src/views/virtualmachines/actions/hooks/useVirtualMachineActionsProvider.ts @@ -64,10 +64,8 @@ const useVirtualMachineActionsProvider: UseVirtualMachineActionsProvider = ( VirtualMachineActionFactory.clone(vm, createModal), VirtualMachineActionFactory.snapshot(vm, createModal), VirtualMachineActionFactory.migrationActions(migrateOrCancelMigrationCompute, migrateStorage), - // VirtualMachineActionFactory.openConsole(vm), VirtualMachineActionFactory.copySSHCommand(vm, virtctlCommand), - VirtualMachineActionFactory.editLabels(vm, createModal), - VirtualMachineActionFactory.editAnnotations(vm, createModal), + VirtualMachineActionFactory.moveToFolder(vm, createModal), VirtualMachineActionFactory.delete(vm, createModal), ]; }, [vm, vmim, isSingleNodeCluster, createModal, virtctlCommand]); diff --git a/src/views/virtualmachines/actions/tests/useVirtualMachineActionsProvider.test.tsx b/src/views/virtualmachines/actions/tests/useVirtualMachineActionsProvider.test.tsx index 9b6666368..72b0b4721 100644 --- a/src/views/virtualmachines/actions/tests/useVirtualMachineActionsProvider.test.tsx +++ b/src/views/virtualmachines/actions/tests/useVirtualMachineActionsProvider.test.tsx @@ -31,8 +31,7 @@ describe('useVirtualMachineActionsProvider tests', () => { 'vm-action-snapshot', 'migration-menu', 'vm-action-copy-ssh', - 'vm-action-edit-labels', - 'vm-action-edit-annotations', + 'vm-action-move-to-folder', 'vm-action-delete', ]); }); @@ -56,8 +55,7 @@ describe('useVirtualMachineActionsProvider tests', () => { 'vm-action-snapshot', 'migration-menu', 'vm-action-copy-ssh', - 'vm-action-edit-labels', - 'vm-action-edit-annotations', + 'vm-action-move-to-folder', 'vm-action-delete', ]); }); @@ -81,8 +79,7 @@ describe('useVirtualMachineActionsProvider tests', () => { 'vm-action-snapshot', 'migration-menu', 'vm-action-copy-ssh', - 'vm-action-edit-labels', - 'vm-action-edit-annotations', + 'vm-action-move-to-folder', 'vm-action-delete', ]); }); @@ -109,8 +106,7 @@ describe('useVirtualMachineActionsProvider tests', () => { 'vm-action-snapshot', 'migration-menu', 'vm-action-copy-ssh', - 'vm-action-edit-labels', - 'vm-action-edit-annotations', + 'vm-action-move-to-folder', 'vm-action-delete', ]); diff --git a/src/views/virtualmachines/details/tabs/overview/components/VirtualMachinesOverviewTabDetails/VirtualMachinesOverviewTabDetails.tsx b/src/views/virtualmachines/details/tabs/overview/components/VirtualMachinesOverviewTabDetails/VirtualMachinesOverviewTabDetails.tsx index d6ad27493..a17e26681 100644 --- a/src/views/virtualmachines/details/tabs/overview/components/VirtualMachinesOverviewTabDetails/VirtualMachinesOverviewTabDetails.tsx +++ b/src/views/virtualmachines/details/tabs/overview/components/VirtualMachinesOverviewTabDetails/VirtualMachinesOverviewTabDetails.tsx @@ -12,7 +12,7 @@ import { timestampFor } from '@kubevirt-utils/components/Timestamp/utils/datetim import VirtualMachineDescriptionItem from '@kubevirt-utils/components/VirtualMachineDescriptionItem/VirtualMachineDescriptionItem'; import { VirtualMachineDetailsTab } from '@kubevirt-utils/constants/tabs-constants'; import { useKubevirtTranslation } from '@kubevirt-utils/hooks/useKubevirtTranslation'; -import { getName, getVMStatus } from '@kubevirt-utils/resources/shared'; +import { getLabel, getName, getVMStatus } from '@kubevirt-utils/resources/shared'; import { getInstanceTypeMatcher, getMachineType } from '@kubevirt-utils/resources/vm'; import { NO_DATA_DASH } from '@kubevirt-utils/resources/vm/utils/constants'; import { getOsNameFromGuestAgent } from '@kubevirt-utils/resources/vmi'; @@ -33,6 +33,7 @@ import { } from '@patternfly/react-core'; import { createURL } from '@virtualmachines/details/tabs/overview/utils/utils'; import VMNotMigratableLabel from '@virtualmachines/list/components/VMNotMigratableLabel/VMNotMigratableLabel'; +import { VM_FOLDER_LABEL } from '@virtualmachines/tree/utils/constants'; import { printableVMStatus } from '@virtualmachines/utils'; import InstanceTypeDescription from './components/InstanceTypeDescription'; @@ -113,6 +114,11 @@ const VirtualMachinesOverviewTabDetails: FC + diff --git a/src/views/virtualmachines/tree/utils/utils.tsx b/src/views/virtualmachines/tree/utils/utils.tsx index 04f0beb36..074197f61 100644 --- a/src/views/virtualmachines/tree/utils/utils.tsx +++ b/src/views/virtualmachines/tree/utils/utils.tsx @@ -16,6 +16,7 @@ export const treeDataMap = signal>(null); export const selectedTreeItem = signal(null); export const setSelectedTreeItem = (selected: TreeViewDataItem) => (selectedTreeItem.value = [selected]); + const buildProjectMap = ( vms: V1VirtualMachine[], currentPageVMName: string,