Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ function CollectionForm({
</>
)}
<RuleSelector
collection={values}
entityType="Deployment"
scopedResourceSelector={values.resourceSelector.Deployment}
handleChange={onResourceSelectorChange}
Expand All @@ -222,6 +223,7 @@ function CollectionForm({
in
</Label>
<RuleSelector
collection={values}
entityType="Namespace"
scopedResourceSelector={values.resourceSelector.Namespace}
handleChange={onResourceSelectorChange}
Expand All @@ -232,6 +234,7 @@ function CollectionForm({
in
</Label>
<RuleSelector
collection={values}
entityType="Cluster"
scopedResourceSelector={values.resourceSelector.Cluster}
handleChange={onResourceSelectorChange}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import React from 'react';
import { Select, ValidatedOptions } from '@patternfly/react-core';
import React, { ReactElement, ReactNode, useCallback, useMemo, useState } from 'react';
import { debounce, Select, SelectOption, ValidatedOptions } from '@patternfly/react-core';
import useSelectToggle from 'hooks/patternfly/useSelectToggle';
import useRestQuery from 'Containers/Dashboard/hooks/useRestQuery';
import { CancellableRequest } from 'services/cancellationUtils';

export type AutoCompleteSelectProps = {
id: string;
Expand All @@ -10,9 +12,19 @@ export type AutoCompleteSelectProps = {
onChange: (value: string) => void;
validated: ValidatedOptions;
isDisabled: boolean;
autocompleteProvider?: (search: string) => CancellableRequest<string[]>;
OptionComponent?: ReactNode;
};

/* TODO Implement autocompletion */
function getOptions(
OptionComponent: ReactNode,
data: string[] | undefined
): ReactElement[] | undefined {
return data?.map((value) => (
<SelectOption key={value} value={value} component={OptionComponent} />
));
}

export function AutoCompleteSelect({
id,
selectedOption,
Expand All @@ -21,14 +33,36 @@ export function AutoCompleteSelect({
onChange,
validated,
isDisabled,
autocompleteProvider,
OptionComponent = SelectOption,
}: AutoCompleteSelectProps) {
const { isOpen, onToggle, closeSelect } = useSelectToggle();
const [typeahead, setTypeahead] = useState(selectedOption);

const autocompleteCallback = useCallback(() => {
const shouldMakeRequest = isOpen && autocompleteProvider;
if (shouldMakeRequest) {
return autocompleteProvider(typeahead);
}
return {
request: Promise.resolve([]),
cancel: () => {},
};
}, [isOpen, autocompleteProvider, typeahead]);

const { data } = useRestQuery(autocompleteCallback);

function onSelect(_, value) {
onChange(value);
closeSelect();
}

// Debounce the autocomplete requests to not overload the backend
const updateTypeahead = useMemo(
() => debounce((value: string) => setTypeahead(value), 800),
[]
);

return (
<>
<Select
Expand All @@ -39,11 +73,15 @@ export function AutoCompleteSelect({
variant="typeahead"
isCreatable
isOpen={isOpen}
onFilter={() => getOptions(OptionComponent, data)}
onToggle={onToggle}
onTypeaheadInputChanged={updateTypeahead}
selections={selectedOption}
onSelect={onSelect}
isDisabled={isDisabled}
/>
>
{getOptions(OptionComponent, data)}
</Select>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ function ByLabelSelector({
}
>
<TrashIcon
aria-label={`Delete ${value}`}
style={{ cursor: 'pointer' }}
color="var(--pf-global--Color--dark-200)"
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import React from 'react';
import React, { ReactNode, useCallback } from 'react';
import { Button, Flex, FormGroup, ValidatedOptions } from '@patternfly/react-core';
import { TrashIcon } from '@patternfly/react-icons';
import cloneDeep from 'lodash/cloneDeep';

import { FormikErrors } from 'formik';
import useIndexKey from 'hooks/useIndexKey';
import { getCollectionAutoComplete } from 'services/CollectionsService';
import { AutoCompleteSelect } from './AutoCompleteSelect';
import { ByNameResourceSelector, ScopedResourceSelector, SelectorEntityType } from '../types';
import {
ByNameResourceSelector,
Collection,
ScopedResourceSelector,
SelectorEntityType,
} from '../types';
import { generateRequest } from '../converter';

export type ByNameSelectorProps = {
collection: Collection;
entityType: SelectorEntityType;
scopedResourceSelector: ByNameResourceSelector;
handleChange: (
Expand All @@ -17,16 +25,26 @@ export type ByNameSelectorProps = {
) => void;
validationErrors: FormikErrors<ByNameResourceSelector> | undefined;
isDisabled: boolean;
OptionComponent: ReactNode;
};

function ByNameSelector({
collection,
entityType,
scopedResourceSelector,
handleChange,
validationErrors,
isDisabled,
OptionComponent,
}: ByNameSelectorProps) {
const { keyFor, invalidateIndexKeys } = useIndexKey();
const autocompleteProvider = useCallback(
(search: string) => {
const req = generateRequest(collection);
return getCollectionAutoComplete(req.resourceSelectors, entityType, search);
},
[collection, entityType]
);

function onAddValue() {
const selector = cloneDeep(scopedResourceSelector);
Expand Down Expand Up @@ -77,6 +95,8 @@ function ByNameSelector({
: ValidatedOptions.default
}
isDisabled={isDisabled}
autocompleteProvider={autocompleteProvider}
OptionComponent={OptionComponent}
/>
{!isDisabled && (
<Button variant="plain" onClick={() => onDeleteValue(index)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/extend-expect';

import { mockDebounce } from 'test-utils/mocks/@patternfly/react-core';
import RuleSelector from './RuleSelector';
import { ByLabelResourceSelector, ByNameResourceSelector, ScopedResourceSelector } from '../types';

jest.mock('@patternfly/react-core', () => mockDebounce);

jest.mock('services/CollectionsService', () => ({
__esModule: true,
getCollectionAutoComplete: () => ({ request: Promise.resolve([]) }),
}));

// Component wrapper to allow a higher level component to feed updated state back to the RuleSelector.
function DeploymentRuleSelector({ defaultSelector, onChange }) {
const [resourceSelector, setResourceSelector] =
Expand All @@ -17,6 +25,17 @@ function DeploymentRuleSelector({ defaultSelector, onChange }) {

return (
<RuleSelector
collection={{
name: '',
description: '',
inUse: false,
resourceSelector: {
Deployment: { type: 'All' },
Namespace: { type: 'All' },
Cluster: { type: 'All' },
},
embeddedCollectionIds: [],
}}
entityType="Deployment"
scopedResourceSelector={resourceSelector}
handleChange={(_, newSelector) => setResourceSelector(newSelector)}
Expand Down Expand Up @@ -168,19 +187,14 @@ describe('Collection RuleSelector component', () => {
);
await user.type(
screen.getByLabelText('Select label value 1 of 1 for deployment rule 2 of 2'),
// typo
'stabl{Enter}'
'stable{Enter}'
);

await user.click(screen.getAllByText('Add value')[1]);
await user.type(
screen.getByLabelText('Select label value 2 of 2 for deployment rule 2 of 2'),
'beta{Enter}'
);
// test editing typo
await user.type(
screen.getByLabelText('Select label value 1 of 2 for deployment rule 2 of 2'),
'e{Enter}'
);

expect(resourceSelector).toEqual({
type: 'ByLabel',
Expand All @@ -198,5 +212,15 @@ describe('Collection RuleSelector component', () => {
},
],
});

// Check that deletion of all items removes the selector
await user.click(screen.getByLabelText('Delete stable'));
await user.click(screen.getByLabelText('Delete beta'));
await user.click(screen.getByLabelText('Delete visa-processor'));
await user.click(screen.getByLabelText('Delete mastercard-processor'));
await user.click(screen.getByLabelText('Delete discover-processor'));

expect(resourceSelector).toEqual({ type: 'All' });
expect(screen.getByText('All deployments')).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React from 'react';
import React, { ForwardedRef, forwardRef, ReactNode } from 'react';
import { Select, SelectOption } from '@patternfly/react-core';
import pluralize from 'pluralize';
import { FormikErrors } from 'formik';

import useSelectToggle from 'hooks/patternfly/useSelectToggle';
import ResourceIcon from 'Components/PatternFly/ResourceIcon';
import {
Collection,
RuleSelectorOption,
ScopedResourceSelector,
SelectorEntityType,
Expand All @@ -18,6 +20,7 @@ function isRuleSelectorOption(value: string): value is RuleSelectorOption {
}

export type RuleSelectorProps = {
collection: Collection;
entityType: SelectorEntityType;
scopedResourceSelector: ScopedResourceSelector;
handleChange: (
Expand All @@ -29,6 +32,7 @@ export type RuleSelectorProps = {
};

function RuleSelector({
collection,
entityType,
scopedResourceSelector,
handleChange,
Expand All @@ -38,6 +42,20 @@ function RuleSelector({
const { isOpen, onToggle, closeSelect } = useSelectToggle();
const pluralEntity = pluralize(entityType);

// We need to wrap this custom SelectOption component in a forward ref
// because PatternFly will pass a `ref` to it
const OptionComponent = forwardRef(
(
props: { className: string; children: ReactNode },
ref: ForwardedRef<HTMLButtonElement | null>
) => (
<button className={props.className} type="button" ref={ref}>
<ResourceIcon kind={entityType} />
{props.children}
</button>
)
);

function onRuleOptionSelect(_, value) {
if (!isRuleSelectorOption(value)) {
return;
Expand Down Expand Up @@ -84,11 +102,13 @@ function RuleSelector({

{scopedResourceSelector.type === 'ByName' && (
<ByNameSelector
collection={collection}
entityType={entityType}
scopedResourceSelector={scopedResourceSelector}
handleChange={handleChange}
validationErrors={validationErrors}
isDisabled={isDisabled}
OptionComponent={OptionComponent}
/>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default function useRestQuery<ReturnType>(
const { request, cancel } = cancellableRequestFn();

setError(undefined);
setLoading(true);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🙀


request
.then((result) => {
Expand Down
10 changes: 5 additions & 5 deletions ui/apps/platform/src/services/CollectionsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,13 +211,13 @@ export function getCollectionAutoComplete(
searchCategory: string,
searchLabel: string
): CancellableRequest<string[]> {
const params = qs.stringify(
{ resourceSelectors, searchCategory, searchLabel },
{ arrayFormat: 'repeat' }
);
return makeCancellableAxiosRequest((signal) =>
axios
.get<{ values: string[] }>(`${collectionsAutocompleteUrl}?${params}`, { signal })
.post<{ values: string[] }>(
collectionsAutocompleteUrl,
{ resourceSelectors, searchCategory, searchLabel },
{ signal }
)
.then((response) => response.data.values)
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import * as PFReactCore from '@patternfly/react-core';

const { debounce, ...rest } = jest.requireActual('@patternfly/react-core');

// Overrides the PF `debounce` function to do nothing other than return the original function. This
// can be used to avoid issues in tests that result from state updates in a debounced function.
export const mockDebounce = { ...rest, debounce: (fn: () => void) => fn } as jest.Mock<
typeof PFReactCore
>;