diff --git a/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts index f93829500d..4987f3fcf2 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts @@ -1,6 +1,7 @@ import { appChrome } from '~/__tests__/cypress/cypress/pages/appChrome'; import { TableRow } from './components/table'; import { TableToolbar } from './components/TableToolbar'; +import { Contextual } from './components/Contextual'; class CreateConnectionTypeTableRow extends TableRow { findSectionHeading() { @@ -92,6 +93,36 @@ class CreateConnectionTypePage { this.getFieldsTableRow(index).findName().should('contain.text', name), ); } + + getCategorySection() { + return new CategorySection(() => cy.findByTestId('connection-type-category-toggle')); + } +} + +class CategorySection extends Contextual { + findCategoryTable() { + return this.find().click(); + } + + private findChipGroup() { + return this.find().findByRole('list', { name: 'Current selections' }); + } + + findChipItem(name: string | RegExp) { + return this.findChipGroup().find('li').contains('span', name); + } + + clearMultiChipItem() { + this.find().findByRole('button', { name: 'Clear input value' }).click(); + } + + findMultiGroupInput() { + return this.find().find('input'); + } + + findMultiGroupSelectButton(name: string) { + return cy.findByTestId(`select-multi-typeahead-${name}`).click(); + } } class ConnectionTypesTableToolbar extends TableToolbar {} diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts index 2100a83db1..315f4c66c9 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/connectionTypes/createConnectionType.cy.ts @@ -29,15 +29,40 @@ describe('create', () => { createConnectionTypePage.findConnectionTypePreviewToggle().should('exist'); }); - it('Allows create button with valid name', () => { + it('Allows create button with valid name and category', () => { + const categorySection = createConnectionTypePage.getCategorySection(); createConnectionTypePage.visitCreatePage(); createConnectionTypePage.findConnectionTypeName().should('have.value', ''); createConnectionTypePage.findSubmitButton().should('be.disabled'); createConnectionTypePage.findConnectionTypeName().type('hello'); + categorySection.findCategoryTable(); + categorySection.findMultiGroupSelectButton('Object-storage'); createConnectionTypePage.findSubmitButton().should('be.enabled'); }); + + it('Selects category or creates new category', () => { + createConnectionTypePage.visitCreatePage(); + + const categorySection = createConnectionTypePage.getCategorySection(); + + categorySection.findCategoryTable(); + categorySection.findMultiGroupSelectButton('Object-storage'); + + categorySection.findChipItem('Object storage').should('exist'); + categorySection.clearMultiChipItem(); + + categorySection.findMultiGroupSelectButton('Object-storage'); + + categorySection.findMultiGroupInput().type('Database'); + categorySection.findMultiGroupSelectButton('Database'); + + categorySection.findMultiGroupInput().type('New category'); + + categorySection.findMultiGroupSelectButton('Option'); + categorySection.findChipItem('New category').should('exist'); + }); }); describe('duplicate', () => { diff --git a/frontend/src/components/MultiSelection.tsx b/frontend/src/components/MultiSelection.tsx index 0f141bf77d..1ab72eeadb 100644 --- a/frontend/src/components/MultiSelection.tsx +++ b/frontend/src/components/MultiSelection.tsx @@ -36,14 +36,23 @@ type MultiSelectionProps = { groupedValues?: GroupSelectionOptions[]; setValue: (itemSelection: SelectionOptions[]) => void; toggleId?: string; + inputId?: string; ariaLabel: string; placeholder?: string; isDisabled?: boolean; selectionRequired?: boolean; noSelectedOptionsMessage?: string; toggleTestId?: string; + /** Flag to indicate if the typeahead select allows new items */ + isCreatable?: boolean; + /** Flag to indicate if create option should be at top of typeahead */ + isCreateOptionOnTop?: boolean; + /** Message to display to create a new option */ + createOptionMessage?: string | ((newValue: string) => string); }; +const defaultCreateOptionMessage = (newValue: string) => `Create "${newValue}"`; + export const MultiSelection: React.FC = ({ value = [], groupedValues = [], @@ -53,9 +62,13 @@ export const MultiSelection: React.FC = ({ ariaLabel = 'Options menu', id, toggleId, + inputId, toggleTestId, selectionRequired, noSelectedOptionsMessage = 'One or more options must be selected', + isCreatable = false, + isCreateOptionOnTop = false, + createOptionMessage = defaultCreateOptionMessage, }) => { const [isOpen, setIsOpen] = React.useState(false); const [inputValue, setInputValue] = React.useState(''); @@ -97,15 +110,48 @@ export const MultiSelection: React.FC = ({ [groupOptions, inputValue, value], ); - const allOptions = React.useMemo(() => { + const allValues = React.useMemo(() => { const options = []; groupedValues.forEach((group) => options.push(...group.values)); options.push(...value); - return options; }, [groupedValues, value]); - const visibleOptions = [...groupOptions, ...selectOptions]; + const createOption = React.useMemo(() => { + const inputValueTrim = inputValue.trim(); + + if ( + isCreatable && + inputValueTrim && + !allValues.find((o) => String(o.name).toLowerCase() === inputValueTrim.toLowerCase()) + ) { + return { + id: inputValueTrim, + name: + typeof createOptionMessage === 'string' + ? createOptionMessage + : createOptionMessage(inputValueTrim), + selected: false, + }; + } + return undefined; + }, [inputValue, isCreatable, createOptionMessage, allValues]); + + const allOptions = React.useMemo(() => { + const options = [...allValues]; + if (createOption) { + options.push(createOption); + } + return options; + }, [allValues, createOption]); + + const visibleOptions = React.useMemo(() => { + let options = [...groupOptions, ...selectOptions]; + if (createOption) { + options = isCreateOptionOnTop ? [createOption, ...options] : [...options, createOption]; + } + return options; + }, [groupOptions, selectOptions, createOption, isCreateOptionOnTop]); const selected = React.useMemo(() => allOptions.filter((v) => v.selected), [allOptions]); @@ -156,6 +202,7 @@ export const MultiSelection: React.FC = ({ case 'Enter': if (isOpen && focusedItem) { onSelect(focusedItem); + setInputValue(''); } if (!isOpen) { setIsOpen(true); @@ -188,6 +235,7 @@ export const MultiSelection: React.FC = ({ option.id === menuItem.id ? { ...option, selected: !option.selected } : option, ), ); + setInputValue(''); } textInputRef.current?.focus(); }; @@ -209,6 +257,7 @@ export const MultiSelection: React.FC = ({ > = ({ onOpenChange={() => setOpen(false)} toggle={toggle} > - {visibleOptions.length === 0 && inputValue ? ( + {createOption && isCreateOptionOnTop && groupOptions.length > 0 ? ( + + + {createOption.name} + + + ) : null} + {!createOption && visibleOptions.length === 0 && inputValue ? ( No results found @@ -279,8 +335,8 @@ export const MultiSelection: React.FC = ({ {g.values.map((option) => ( = ({ {index < selectGroups.length - 1 || selectOptions.length ? : null} ))} - {selectOptions.length ? ( + {selectOptions.length || + (createOption && (!isCreateOptionOnTop || groupOptions.length === 0)) ? ( + {createOption && isCreateOptionOnTop && groupOptions.length === 0 ? ( + {createOption.name} + ) : null} {selectOptions.map((option) => ( = ({ {option.name} ))} + {createOption && !isCreateOptionOnTop ? ( + + {createOption.name} + + ) : null} ) : null} diff --git a/frontend/src/pages/connectionTypes/ConnectionTypesTableRow.tsx b/frontend/src/pages/connectionTypes/ConnectionTypesTableRow.tsx index 0e072d7a50..fd2a26cd07 100644 --- a/frontend/src/pages/connectionTypes/ConnectionTypesTableRow.tsx +++ b/frontend/src/pages/connectionTypes/ConnectionTypesTableRow.tsx @@ -1,7 +1,13 @@ import * as React from 'react'; import { useNavigate } from 'react-router'; import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; -import { Label, Switch, Timestamp, TimestampTooltipVariant } from '@patternfly/react-core'; +import { + Label, + LabelGroup, + Switch, + Timestamp, + TimestampTooltipVariant, +} from '@patternfly/react-core'; import { ConnectionTypeConfigMapObj } from '~/concepts/connectionTypes/types'; import { relativeTime } from '~/utilities/time'; import { updateConnectionTypeEnabled } from '~/services/connectionTypesService'; @@ -14,6 +20,7 @@ import { ownedByDSC, } from '~/concepts/k8s/utils'; import { connectionTypeColumns } from '~/pages/connectionTypes/columns'; +import CategoryLabel from '~/concepts/connectionTypes/CategoryLabel'; type ConnectionTypesTableRowProps = { obj: ConnectionTypeConfigMapObj; @@ -72,13 +79,24 @@ const ConnectionTypesTableRow: React.FC = ({ return ( - + - + + {obj.data?.category?.length ? ( + + {obj.data.category.map((category) => ( + + ))} + + ) : ( + '-' + )} + + {ownedByDSC(obj) ? ( ) : ( @@ -86,7 +104,7 @@ const ConnectionTypesTableRow: React.FC = ({ )} @@ -94,7 +112,7 @@ const ConnectionTypesTableRow: React.FC = ({ {createdDate ? relativeTime(Date.now(), createdDate.getTime()) : 'Unknown'} - + [] = field: 'name', sortable: sorter, }, + { + label: 'Category', + field: 'category', + sortable: false, + }, { label: 'Creator', field: 'creator', diff --git a/frontend/src/pages/connectionTypes/const.ts b/frontend/src/pages/connectionTypes/const.ts index 80853c89eb..8eeeccc784 100644 --- a/frontend/src/pages/connectionTypes/const.ts +++ b/frontend/src/pages/connectionTypes/const.ts @@ -14,3 +14,5 @@ export const initialFilterData: Record = ({ prefill, isEdit, onSave }) const [connectionEnabled, setConnectionEnabled] = React.useState(prefillEnabled); const [connectionFields, setConnectionFields] = React.useState(prefillFields); - const [category] = React.useState(prefillCategory); + const [category, setCategory] = React.useState(prefillCategory); + + const categoryItems = React.useMemo( + () => + category + .filter((c) => !categoryOptions.includes(c)) + .concat(categoryOptions) + .map((c) => ({ id: c, name: c, selected: category.includes(c) })), + [category], + ); const connectionTypeObj = React.useMemo( () => @@ -85,8 +96,8 @@ const ManageConnectionTypePage: React.FC = ({ prefill, isEdit, onSave }) const isValid = React.useMemo(() => { const trimmedName = connectionNameDesc.name.trim(); - return Boolean(trimmedName) && !isEnvVarConflict; - }, [connectionNameDesc.name, isEnvVarConflict]); + return Boolean(trimmedName) && !isEnvVarConflict && category.length > 0; + }, [connectionNameDesc.name, isEnvVarConflict, category]); const onCancel = () => { navigate('/connectionTypes'); @@ -102,7 +113,7 @@ const ManageConnectionTypePage: React.FC = ({ prefill, isEdit, onSave }) title={isEdit ? 'Edit connection type' : 'Create connection type'} loaded empty={false} - errorMessage="Unable load to connection types" + errorMessage="Unable to load connection types" breadcrumb={} headerAction={ isDrawerExpanded ? undefined : ( @@ -139,7 +150,21 @@ const ManageConnectionTypePage: React.FC = ({ prefill, isEdit, onSave }) setData={setConnectionNameDesc} autoFocusName /> - + + { + setCategory(value.filter((v) => v.selected).map((v) => String(v.id))); + }} + /> + +