Skip to content

Commit

Permalink
Add multiselect menu with typeahead option
Browse files Browse the repository at this point in the history
  • Loading branch information
ashley-o0o committed Aug 30, 2024
1 parent 3174a74 commit 9884360
Show file tree
Hide file tree
Showing 7 changed files with 195 additions and 20 deletions.
31 changes: 31 additions & 0 deletions frontend/src/__tests__/cypress/cypress/pages/connectionTypes.ts
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down Expand Up @@ -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<HTMLElement> {
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 {}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
87 changes: 78 additions & 9 deletions frontend/src/components/MultiSelection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<MultiSelectionProps> = ({
value = [],
groupedValues = [],
Expand All @@ -53,9 +62,13 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
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<string>('');
Expand Down Expand Up @@ -97,15 +110,48 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
[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]);

Expand Down Expand Up @@ -156,6 +202,7 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
case 'Enter':
if (isOpen && focusedItem) {
onSelect(focusedItem);
setInputValue('');
}
if (!isOpen) {
setIsOpen(true);
Expand Down Expand Up @@ -188,6 +235,7 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
option.id === menuItem.id ? { ...option, selected: !option.selected } : option,
),
);
setInputValue('');
}
textInputRef.current?.focus();
};
Expand All @@ -209,6 +257,7 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
>
<TextInputGroup isPlain>
<TextInputGroupMain
inputId={inputId}
value={inputValue}
onClick={onToggleClick}
onChange={onTextInputChange}
Expand Down Expand Up @@ -267,7 +316,14 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
onOpenChange={() => setOpen(false)}
toggle={toggle}
>
{visibleOptions.length === 0 && inputValue ? (
{createOption && isCreateOptionOnTop && groupOptions.length > 0 ? (
<SelectList isAriaMultiselectable>
<SelectOption value={createOption.id} isFocused={focusedItemIndex === 0}>
{createOption.name}
</SelectOption>
</SelectList>
) : null}
{!createOption && visibleOptions.length === 0 && inputValue ? (
<SelectList isAriaMultiselectable>
<SelectOption isDisabled>No results found</SelectOption>
</SelectList>
Expand All @@ -279,8 +335,8 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
{g.values.map((option) => (
<SelectOption
key={option.name}
isFocused={focusedItemIndex === option.index}
id={`select-multi-typeahead-${option.name.replace(' ', '-')}`}
isFocused={focusedItemIndex === option.index + (isCreateOptionOnTop ? 1 : 0)}
data-testid={`select-multi-typeahead-${option.name.replace(' ', '-')}`}
value={option.id}
ref={null}
isSelected={option.selected}
Expand All @@ -293,20 +349,33 @@ export const MultiSelection: React.FC<MultiSelectionProps> = ({
{index < selectGroups.length - 1 || selectOptions.length ? <Divider /> : null}
</>
))}
{selectOptions.length ? (
{selectOptions.length ||
(createOption && (!isCreateOptionOnTop || groupOptions.length === 0)) ? (
<SelectList isAriaMultiselectable>
{createOption && isCreateOptionOnTop && groupOptions.length === 0 ? (
<SelectOption value={createOption.id}>{createOption.name}</SelectOption>
) : null}
{selectOptions.map((option) => (
<SelectOption
key={option.name}
isFocused={focusedItemIndex === option.index}
id={`select-multi-typeahead-${option.name.replace(' ', '-')}`}
isFocused={focusedItemIndex === option.index + (isCreateOptionOnTop ? 1 : 0)}
data-testid={`select-multi-typeahead-${option.name.replace(' ', '-')}`}
value={option.id}
ref={null}
isSelected={option.selected}
>
{option.name}
</SelectOption>
))}
{createOption && !isCreateOptionOnTop ? (
<SelectOption
data-testid={`select-multi-typeahead-${Option.name.replace(' ', '-')}`}
value={createOption.id}
isFocused={focusedItemIndex === visibleOptions.length - 1}
>
{createOption.name}
</SelectOption>
) : null}
</SelectList>
) : null}
</Select>
Expand Down
28 changes: 23 additions & 5 deletions frontend/src/pages/connectionTypes/ConnectionTypesTableRow.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -72,29 +79,40 @@ const ConnectionTypesTableRow: React.FC<ConnectionTypesTableRowProps> = ({

return (
<Tr>
<Td dataLabel={connectionTypeColumns[0].label} width={50}>
<Td dataLabel={connectionTypeColumns[0].label} width={30}>
<TableRowTitleDescription
title={getDisplayNameFromK8sResource(obj)}
description={getDescriptionFromK8sResource(obj)}
/>
</Td>
<Td dataLabel={connectionTypeColumns[1].label} data-testid="connection-type-creator">
<Td dataLabel={connectionTypeColumns[1].label}>
{obj.data?.category?.length ? (
<LabelGroup>
{obj.data.category.map((category) => (
<CategoryLabel key={category} category={category} />
))}
</LabelGroup>
) : (
'-'
)}
</Td>
<Td dataLabel={connectionTypeColumns[2].label} data-testid="connection-type-creator">
{ownedByDSC(obj) ? (
<Label data-testid="connection-type-user-label">{creator}</Label>
) : (
creator
)}
</Td>
<Td
dataLabel={connectionTypeColumns[2].label}
dataLabel={connectionTypeColumns[3].label}
data-testid="connection-type-created"
modifier="nowrap"
>
<Timestamp date={createdDate} tooltip={{ variant: TimestampTooltipVariant.default }}>
{createdDate ? relativeTime(Date.now(), createdDate.getTime()) : 'Unknown'}
</Timestamp>
</Td>
<Td dataLabel={connectionTypeColumns[3].label}>
<Td dataLabel={connectionTypeColumns[4].label}>
<Switch
isChecked={isEnabled}
aria-label="toggle enabled"
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/pages/connectionTypes/columns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ export const connectionTypeColumns: SortableData<ConnectionTypeConfigMapObj>[] =
field: 'name',
sortable: sorter,
},
{
label: 'Category',
field: 'category',
sortable: false,
},
{
label: 'Creator',
field: 'creator',
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/pages/connectionTypes/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export const initialFilterData: Record<ConnectionTypesOptions, string | undefine
[ConnectionTypesOptions.keyword]: '',
[ConnectionTypesOptions.createdBy]: '',
};

export const categoryOptions = ['Object storage', 'Database', 'Model registry', 'URI'];
Loading

0 comments on commit 9884360

Please sign in to comment.