Skip to content
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

CNV-45822: Vm tree view 3 #2290

Merged
merged 1 commit into from
Dec 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions locales/en/plugin__kubevirt-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -761,6 +763,8 @@
"More info: ": "More info: ",
"Mount point": "Mount point",
"Mount Windows drivers disk": "Mount Windows drivers disk",
"Move <1>{getName(vm)}</1> VirtualMachine to folder": "Move <1>{getName(vm)}</1> VirtualMachine to folder",
"Move to folder": "Move to folder",
"MP": "MP",
"MTV": "MTV",
"my-storage-claim": "my-storage-claim",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions src/utils/components/FolderSelect/FolderSelect.tsx
Original file line number Diff line number Diff line change
@@ -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<FoldersSelectProps> = ({
isFullWidth = false,
namespace,
selectedFolder,
setSelectedFolder,
}) => {
const { t } = useKubevirtTranslation();
const [folderOptions, setFolderOptions] = useFolderOptions(namespace);

return (
<SelectTypeahead
canCreate
dataTestId="vm-folder-select"
initialOptions={folderOptions}
isFullWidth={isFullWidth}
placeholder={t('Search folder')}
selected={selectedFolder}
setInitialOptions={setFolderOptions}
setSelected={setSelectedFolder}
/>
);
};

export default FolderSelect;
275 changes: 275 additions & 0 deletions src/utils/components/FolderSelect/SelectTypeahead.tsx
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we use the common SelectTypeahead ? Add functionalities that we are missing there?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can but the component is not working good with the search and few more things, it was just easier to implement it from the start, I will remove the original component as we have only 1 occurrence of it and replace it with the new component in a different PR

Original file line number Diff line number Diff line change
@@ -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<SetStateAction<SelectOptionProps[]>>;
setSelected: (newFolder: string) => void;
};
const SelectTypeahead: FC<SelectTypeaheadProps> = ({
canCreate = false,
dataTestId,
initialOptions,
isFullWidth = false,
placeholder,
selected,
setInitialOptions,
setSelected,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState<string>(selected);
const [filterValue, setFilterValue] = useState<string>('');
const [selectOptions, setSelectOptions] = useState<SelectOptionProps[]>(initialOptions);
const [focusedItemIndex, setFocusedItemIndex] = useState<null | number>(null);
const [activeItemId, setActiveItemId] = useState<null | string>(null);
const textInputRef = useRef<HTMLInputElement>();

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<Element, 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: <FolderIcon />, 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<HTMLInputElement>, 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<HTMLInputElement>) => {
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<MenuToggleElement>) => (
<MenuToggle
isExpanded={isOpen}
isFullWidth={isFullWidth}
onClick={onToggleClick}
ref={toggleRef}
variant="typeahead"
>
<TextInputGroup isPlain>
<TextInputGroupMain
autoComplete="off"
icon={<SearchIcon />}
innerRef={textInputRef}
onChange={onTextInputChange}
onClick={onToggleClick}
onKeyDown={onInputKeyDown}
placeholder={placeholder}
value={inputValue}
{...(activeItemId && { 'aria-activedescendant': activeItemId })}
isExpanded={isOpen}
role="combobox"
/>

{!isEmpty(inputValue) && (
<TextInputGroupUtilities>
<Button onClick={onClearButtonClick} variant={ButtonVariant.plain}>
<TimesIcon />
</Button>
</TextInputGroupUtilities>
)}
</TextInputGroup>
</MenuToggle>
);

return (
<Select
onOpenChange={(open) => {
!open && closeMenu();
}}
data-test={dataTestId}
isOpen={isOpen}
onSelect={onSelect}
selected={selected}
toggle={toggle}
>
<SelectList>
{selectOptions?.map((option, index) => (
<SelectOption
className={option.className}
id={createItemId(option.value)}
isFocused={focusedItemIndex === index}
key={option.value || option.children}
{...option}
/>
))}
</SelectList>
</Select>
);
};

export default SelectTypeahead;
41 changes: 41 additions & 0 deletions src/utils/components/FolderSelect/hooks/useFolderOptions.tsx
Original file line number Diff line number Diff line change
@@ -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<SetStateAction<SelectOptionProps[]>>];

const useFolderOptions: UseFolderOptions = (namespace) => {
const [folders, setFolders] = useState<SelectOptionProps[]>();
const [vms] = useK8sWatchResource<V1VirtualMachine[]>({
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: <FolderIcon />, value: folderLabel });
}
return uniqueValues;
}, []);

setFolders(folderOptions);
}, [vms]);

return [folders, setFolders];
};

export default useFolderOptions;
1 change: 1 addition & 0 deletions src/utils/components/FolderSelect/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const CREATE_NEW = 'create';
Loading
Loading