diff --git a/.changeset/mighty-carrots-decide.md b/.changeset/mighty-carrots-decide.md new file mode 100644 index 0000000000..68f210da48 --- /dev/null +++ b/.changeset/mighty-carrots-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': minor +--- + +Allow search filters to provide labels and values separately, and not only values diff --git a/packages/app/src/components/search/SearchModal.tsx b/packages/app/src/components/search/SearchModal.tsx index 77c95399d0..04f49d6ddb 100644 --- a/packages/app/src/components/search/SearchModal.tsx +++ b/packages/app/src/components/search/SearchModal.tsx @@ -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; }} /> diff --git a/packages/app/src/components/search/SearchPage.tsx b/packages/app/src/components/search/SearchPage.tsx index 9d45583044..743a08561c 100644 --- a/packages/app/src/components/search/SearchPage.tsx +++ b/packages/app/src/components/search/SearchPage.tsx @@ -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; }} /> diff --git a/plugins/search-react/report.api.md b/plugins/search-react/report.api.md index 1e74e16132..f0ec6030fd 100644 --- a/plugins/search-react/report.api.md +++ b/plugins/search-react/report.api.md @@ -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); + values?: FilterValue[] | ((partial: string) => Promise); defaultValue?: string[] | string | null; valuesDebounceMs?: number; }; diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx index fbdfbb1e4c..f95f0153fe 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.Autocomplete.tsx @@ -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) => ( - + tagValue.map((option, index: number) => ( + )); 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} /> diff --git a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx index e21c8a8697..da96c711c4 100644 --- a/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx +++ b/plugins/search-react/src/components/SearchFilter/SearchFilter.tsx @@ -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); + values?: FilterValue[] | ((partial: string) => Promise); 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 ? {label} : null} - {values.map((value: string) => ( + {!!formLabel && ( + {formLabel} + )} + {values.map(({ value, label }) => ( @@ -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 ( { 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: '' }) => diff --git a/plugins/search-react/src/components/SearchFilter/hooks.ts b/plugins/search-react/src/components/SearchFilter/hooks.ts index 987c85bb7c..48b842b914 100644 --- a/plugins/search-react/src/components/SearchFilter/hooks.ts +++ b/plugins/search-react/src/components/SearchFilter/hooks.ts @@ -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) | undefined, + fn: ((partial: string) => Promise) | undefined, inputValue: string, - defaultValues: string[] = [], + defaultValues: FilterValueWithLabel[] = [], debounce: number = 250, ) => { - const valuesMemo = useRef>>({}); - const definiteFn = fn || (() => Promise.resolve([])); + const valuesMemo = useRef< + Record> + >({}); + const definiteFn = useCallback( + async (partial: string) => { + return ( + (await fn?.(partial))?.map(v => ensureFilterValueWithLabel(v)) || [] + ); + }, + [fn], + ); const [state, callback] = useAsyncFn(definiteFn, [inputValue], { loading: true, diff --git a/plugins/search-react/src/components/SearchFilter/index.ts b/plugins/search-react/src/components/SearchFilter/index.ts index 9f92c7c8e2..d26cd25911 100644 --- a/plugins/search-react/src/components/SearchFilter/index.ts +++ b/plugins/search-react/src/components/SearchFilter/index.ts @@ -21,3 +21,4 @@ export type { SearchFilterWrapperProps, } from './SearchFilter'; export type { SearchAutocompleteFilterProps } from './SearchFilter.Autocomplete'; +export type { FilterValueWithLabel, FilterValue } from './types'; diff --git a/plugins/search-react/src/components/SearchFilter/types.ts b/plugins/search-react/src/components/SearchFilter/types.ts new file mode 100644 index 0000000000..32350abb92 --- /dev/null +++ b/plugins/search-react/src/components/SearchFilter/types.ts @@ -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( + value: T | T[] | undefined, +): typeof value extends undefined + ? undefined + : typeof value extends ArrayLike + ? 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; +}