Add support for providing values and labels to the search filters
Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-search-react': minor
|
||||
---
|
||||
|
||||
Allow search filters to provide labels and values separately, and not only values
|
||||
@@ -148,15 +148,18 @@ export const SearchModal = ({ toggleModal }: { toggleModal: () => void }) => {
|
||||
values={async () => {
|
||||
// Return a list of entities which are documented.
|
||||
const { items } = await catalogApi.getEntities({
|
||||
fields: ['metadata.name'],
|
||||
fields: ['metadata.name', 'metadata.title'],
|
||||
filter: {
|
||||
'metadata.annotations.backstage.io/techdocs-ref':
|
||||
CATALOG_FILTER_EXISTS,
|
||||
},
|
||||
});
|
||||
|
||||
const names = items.map(entity => entity.metadata.name);
|
||||
names.sort();
|
||||
const names = items.map(entity => ({
|
||||
value: entity.metadata.name,
|
||||
label: entity.metadata.title ?? entity.metadata.name,
|
||||
}));
|
||||
names.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return names;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -97,15 +97,18 @@ const SearchPage = () => {
|
||||
values={async () => {
|
||||
// Return a list of entities which are documented.
|
||||
const { items } = await catalogApi.getEntities({
|
||||
fields: ['metadata.name'],
|
||||
fields: ['metadata.name', 'metadata.title'],
|
||||
filter: {
|
||||
'metadata.annotations.backstage.io/techdocs-ref':
|
||||
CATALOG_FILTER_EXISTS,
|
||||
},
|
||||
});
|
||||
|
||||
const names = items.map(entity => entity.metadata.name);
|
||||
names.sort();
|
||||
const names = items.map(entity => ({
|
||||
value: entity.metadata.name,
|
||||
label: entity.metadata.title ?? entity.metadata.name,
|
||||
}));
|
||||
names.sort((a, b) => a.label.localeCompare(b.label));
|
||||
return names;
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -63,6 +63,15 @@ export type DefaultResultListItemProps = {
|
||||
toggleModal?: () => void;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export type FilterValue = string | FilterValueWithLabel;
|
||||
|
||||
// @public (undocumented)
|
||||
export type FilterValueWithLabel = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
// @public (undocumented)
|
||||
export const HighlightedSearchResultText: (
|
||||
props: HighlightedSearchResultTextProps,
|
||||
@@ -215,7 +224,7 @@ export type SearchFilterComponentProps = {
|
||||
className?: string;
|
||||
name: string;
|
||||
label?: string;
|
||||
values?: string[] | ((partial: string) => Promise<string[]>);
|
||||
values?: FilterValue[] | ((partial: string) => Promise<FilterValue[]>);
|
||||
defaultValue?: string[] | string | null;
|
||||
valuesDebounceMs?: number;
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import Autocomplete, {
|
||||
import { useSearch } from '../../context';
|
||||
import { useAsyncFilterValues, useDefaultFilterValue } from './hooks';
|
||||
import { SearchFilterComponentProps } from './SearchFilter';
|
||||
import { ensureFilterValueWithLabel, FilterValueWithLabel } from './types';
|
||||
|
||||
/**
|
||||
* @public
|
||||
@@ -55,7 +56,9 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
|
||||
const asyncValues =
|
||||
typeof givenValues === 'function' ? givenValues : undefined;
|
||||
const defaultValues =
|
||||
typeof givenValues === 'function' ? undefined : givenValues;
|
||||
typeof givenValues === 'function'
|
||||
? undefined
|
||||
: givenValues?.map(v => ensureFilterValueWithLabel(v));
|
||||
const { value: values, loading } = useAsyncFilterValues(
|
||||
asyncValues,
|
||||
inputValue,
|
||||
@@ -63,19 +66,26 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
|
||||
valuesDebounceMs,
|
||||
);
|
||||
const { filters, setFilters } = useSearch();
|
||||
const filterValue =
|
||||
(filters[name] as string | string[] | undefined) || (multiple ? [] : null);
|
||||
const filterValueWithLabel = ensureFilterValueWithLabel(
|
||||
filters[name] as string | string[] | undefined,
|
||||
);
|
||||
const filterValue = filterValueWithLabel || (multiple ? [] : null);
|
||||
|
||||
// Set new filter values on input change.
|
||||
const handleChange = (
|
||||
_: ChangeEvent<{}>,
|
||||
newValue: string | string[] | null,
|
||||
newValue: FilterValueWithLabel | FilterValueWithLabel[] | null,
|
||||
) => {
|
||||
setFilters(prevState => {
|
||||
const { [name]: filter, ...others } = prevState;
|
||||
|
||||
if (newValue) {
|
||||
return { ...others, [name]: newValue };
|
||||
return {
|
||||
...others,
|
||||
[name]: Array.isArray(newValue)
|
||||
? newValue.map(v => v.value)
|
||||
: newValue.value,
|
||||
};
|
||||
}
|
||||
return { ...others };
|
||||
});
|
||||
@@ -94,11 +104,11 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
|
||||
|
||||
// Render tags as primary-colored chips.
|
||||
const renderTags = (
|
||||
tagValue: string[],
|
||||
tagValue: FilterValueWithLabel[],
|
||||
getTagProps: AutocompleteGetTagProps,
|
||||
) =>
|
||||
tagValue.map((option: string, index: number) => (
|
||||
<Chip label={option} color="primary" {...getTagProps({ index })} />
|
||||
tagValue.map((option, index: number) => (
|
||||
<Chip label={option.label} color="primary" {...getTagProps({ index })} />
|
||||
));
|
||||
|
||||
return (
|
||||
@@ -113,6 +123,7 @@ export const AutocompleteFilter = (props: SearchAutocompleteFilterProps) => {
|
||||
value={filterValue}
|
||||
onChange={handleChange}
|
||||
onInputChange={(_, newValue) => setInputValue(newValue)}
|
||||
getOptionLabel={option => option.label}
|
||||
renderInput={renderInput}
|
||||
renderTags={renderTags}
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
SearchAutocompleteFilterProps,
|
||||
} from './SearchFilter.Autocomplete';
|
||||
import { useAsyncFilterValues, useDefaultFilterValue } from './hooks';
|
||||
import { ensureFilterValueWithLabel, FilterValue } from './types';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
label: {
|
||||
@@ -60,7 +61,7 @@ export type SearchFilterComponentProps = {
|
||||
* input value is provided as an input to allow values to be filtered. This
|
||||
* function is debounced and values cached.
|
||||
*/
|
||||
values?: string[] | ((partial: string) => Promise<string[]>);
|
||||
values?: FilterValue[] | ((partial: string) => Promise<FilterValue[]>);
|
||||
defaultValue?: string[] | string | null;
|
||||
/**
|
||||
* Debounce time in milliseconds, used when values is an async callback.
|
||||
@@ -84,7 +85,7 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => {
|
||||
const {
|
||||
className,
|
||||
defaultValue,
|
||||
label,
|
||||
label: formLabel,
|
||||
name,
|
||||
values: givenValues = [],
|
||||
valuesDebounceMs,
|
||||
@@ -95,7 +96,9 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => {
|
||||
const asyncValues =
|
||||
typeof givenValues === 'function' ? givenValues : undefined;
|
||||
const defaultValues =
|
||||
typeof givenValues === 'function' ? undefined : givenValues;
|
||||
typeof givenValues === 'function'
|
||||
? undefined
|
||||
: givenValues.map(v => ensureFilterValueWithLabel(v));
|
||||
const { value: values = [], loading } = useAsyncFilterValues(
|
||||
asyncValues,
|
||||
'',
|
||||
@@ -123,21 +126,23 @@ export const CheckboxFilter = (props: SearchFilterComponentProps) => {
|
||||
fullWidth
|
||||
data-testid="search-checkboxfilter-next"
|
||||
>
|
||||
{label ? <FormLabel className={classes.label}>{label}</FormLabel> : null}
|
||||
{values.map((value: string) => (
|
||||
{!!formLabel && (
|
||||
<FormLabel className={classes.label}>{formLabel}</FormLabel>
|
||||
)}
|
||||
{values.map(({ value, label }) => (
|
||||
<FormControlLabel
|
||||
key={value}
|
||||
classes={{
|
||||
root: classes.checkboxWrapper,
|
||||
label: classes.textWrapper,
|
||||
}}
|
||||
label={value}
|
||||
label={label}
|
||||
control={
|
||||
<Checkbox
|
||||
color="primary"
|
||||
inputProps={{ 'aria-labelledby': value }}
|
||||
inputProps={{ 'aria-labelledby': label }}
|
||||
value={value}
|
||||
name={value}
|
||||
name={label}
|
||||
onChange={handleChange}
|
||||
checked={((filters[name] as string[]) ?? []).includes(value)}
|
||||
/>
|
||||
@@ -164,7 +169,9 @@ export const SelectFilter = (props: SearchFilterComponentProps) => {
|
||||
const asyncValues =
|
||||
typeof givenValues === 'function' ? givenValues : undefined;
|
||||
const defaultValues =
|
||||
typeof givenValues === 'function' ? undefined : givenValues;
|
||||
typeof givenValues === 'function'
|
||||
? undefined
|
||||
: givenValues?.map(v => ensureFilterValueWithLabel(v));
|
||||
const { value: values = [], loading } = useAsyncFilterValues(
|
||||
asyncValues,
|
||||
'',
|
||||
@@ -184,7 +191,7 @@ export const SelectFilter = (props: SearchFilterComponentProps) => {
|
||||
});
|
||||
};
|
||||
|
||||
const items = [allOption, ...values.map(value => ({ value, label: value }))];
|
||||
const items = [allOption, ...values];
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
|
||||
@@ -235,7 +235,10 @@ describe('SearchFilter.hooks', () => {
|
||||
|
||||
describe('useAsyncFilterValues', () => {
|
||||
it('should immediately return given values when provided', () => {
|
||||
const givenValues = ['value1', 'value2'];
|
||||
const givenValues = [
|
||||
{ value: 'value1', label: 'value 1' },
|
||||
{ value: 'value2', label: 'value 2' },
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useAsyncFilterValues(undefined, '', givenValues),
|
||||
);
|
||||
@@ -245,7 +248,10 @@ describe('SearchFilter.hooks', () => {
|
||||
});
|
||||
|
||||
it('should return resolved values of provided async function', async () => {
|
||||
const expectedValues = ['value1', 'value2'];
|
||||
const expectedValues = [
|
||||
{ value: 'value1', label: 'value 1' },
|
||||
{ value: 'value2', label: 'value 2' },
|
||||
];
|
||||
const asyncFn = () => Promise.resolve(expectedValues);
|
||||
const { result } = renderHook(() =>
|
||||
useAsyncFilterValues(asyncFn, '', undefined, 1000),
|
||||
@@ -262,7 +268,10 @@ describe('SearchFilter.hooks', () => {
|
||||
});
|
||||
|
||||
it('should debounce method invocation', async () => {
|
||||
const expectedValues = ['value1', 'value2'];
|
||||
const expectedValues = [
|
||||
{ value: 'value1', label: 'value 1' },
|
||||
{ value: 'value2', label: 'value 2' },
|
||||
];
|
||||
const asyncFn = jest.fn().mockResolvedValue(expectedValues);
|
||||
renderHook(() => useAsyncFilterValues(asyncFn, '', undefined, 1000));
|
||||
|
||||
@@ -307,7 +316,10 @@ describe('SearchFilter.hooks', () => {
|
||||
});
|
||||
|
||||
it('should not call provided method more than once when re-rendered with same input', async () => {
|
||||
const expectedValues = ['value1', 'value2'];
|
||||
const expectedValues = [
|
||||
{ value: 'value1', label: 'value 1' },
|
||||
{ value: 'value2', label: 'value 2' },
|
||||
];
|
||||
const asyncFn = jest.fn().mockResolvedValue(expectedValues);
|
||||
const { rerender } = renderHook(
|
||||
(props: { inputValue: string } = { inputValue: '' }) =>
|
||||
|
||||
@@ -14,11 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import useAsyncFn from 'react-use/esm/useAsyncFn';
|
||||
import useDebounce from 'react-use/esm/useDebounce';
|
||||
|
||||
import { useSearch } from '../../context';
|
||||
import {
|
||||
ensureFilterValueWithLabel,
|
||||
FilterValue,
|
||||
FilterValueWithLabel,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* Utility hook for either asynchronously loading filter values from a given
|
||||
@@ -27,13 +32,22 @@ import { useSearch } from '../../context';
|
||||
* @public
|
||||
*/
|
||||
export const useAsyncFilterValues = (
|
||||
fn: ((partial: string) => Promise<string[]>) | undefined,
|
||||
fn: ((partial: string) => Promise<FilterValue[]>) | undefined,
|
||||
inputValue: string,
|
||||
defaultValues: string[] = [],
|
||||
defaultValues: FilterValueWithLabel[] = [],
|
||||
debounce: number = 250,
|
||||
) => {
|
||||
const valuesMemo = useRef<Record<string, string[] | Promise<string[]>>>({});
|
||||
const definiteFn = fn || (() => Promise.resolve([]));
|
||||
const valuesMemo = useRef<
|
||||
Record<string, FilterValueWithLabel[] | Promise<FilterValueWithLabel[]>>
|
||||
>({});
|
||||
const definiteFn = useCallback(
|
||||
async (partial: string) => {
|
||||
return (
|
||||
(await fn?.(partial))?.map(v => ensureFilterValueWithLabel(v)) || []
|
||||
);
|
||||
},
|
||||
[fn],
|
||||
);
|
||||
|
||||
const [state, callback] = useAsyncFn(definiteFn, [inputValue], {
|
||||
loading: true,
|
||||
|
||||
@@ -21,3 +21,4 @@ export type {
|
||||
SearchFilterWrapperProps,
|
||||
} from './SearchFilter';
|
||||
export type { SearchAutocompleteFilterProps } from './SearchFilter.Autocomplete';
|
||||
export type { FilterValueWithLabel, FilterValue } from './types';
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type FilterValueWithLabel = { value: string; label: string };
|
||||
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
export type FilterValue = string | FilterValueWithLabel;
|
||||
|
||||
/**
|
||||
* Ensure a value is on object form, with a label.
|
||||
* Accepts undefined, a single value, or an array of values and returns the
|
||||
* expected result - a filter value/label or an array of such, if any.
|
||||
*/
|
||||
export function ensureFilterValueWithLabel<T extends FilterValue>(
|
||||
value: T | T[] | undefined,
|
||||
): typeof value extends undefined
|
||||
? undefined
|
||||
: typeof value extends ArrayLike<any>
|
||||
? FilterValueWithLabel[]
|
||||
: FilterValueWithLabel {
|
||||
if (value === undefined) {
|
||||
return undefined as any;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(
|
||||
v => ensureFilterValueWithLabel(v) as FilterValueWithLabel,
|
||||
) as any;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return { value, label: value } as FilterValueWithLabel as any;
|
||||
}
|
||||
return value as FilterValueWithLabel as any;
|
||||
}
|
||||
Reference in New Issue
Block a user