LibraryPanelSearch: Refactor and fix hyphen issue (#55314)

This commit is contained in:
kay delaney 2022-09-21 18:37:17 +01:00 committed by GitHub
parent 199996cbf9
commit 9e0d349bf9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 180 additions and 280 deletions

View File

@ -4564,10 +4564,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/library-panels/components/LibraryPanelsSearch/reducer.test.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/library-panels/components/LibraryPanelsView/actions.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -9,10 +9,11 @@ export interface Props extends Omit<HTMLProps<HTMLInputElement>, 'onChange'> {
value: string | undefined;
width?: number;
onChange: (value: string) => void;
escapeRegex?: boolean;
}
export const FilterInput = React.forwardRef<HTMLInputElement, Props>(
({ value, width, onChange, ...restProps }, ref) => {
({ value, width, onChange, escapeRegex = true, ...restProps }, ref) => {
const innerRef = React.useRef<HTMLInputElement>(null);
const combinedRef = useCombinedRefs(ref, innerRef) as React.Ref<HTMLInputElement>;
@ -38,8 +39,10 @@ export const FilterInput = React.forwardRef<HTMLInputElement, Props>(
suffix={suffix}
width={width}
type="text"
value={value ? unEscapeStringFromRegex(value) : ''}
onChange={(event) => onChange(escapeStringForRegex(event.currentTarget.value))}
value={escapeRegex ? unEscapeStringFromRegex(value ?? '') : value}
onChange={(event) =>
onChange(escapeRegex ? escapeStringForRegex(event.currentTarget.value) : event.currentTarget.value)
}
{...restProps}
ref={combinedRef}
/>

View File

@ -5,7 +5,7 @@ import React, { useCallback, useMemo, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { AsyncMultiSelect, Icon, Button, useStyles2 } from '@grafana/ui';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchHit } from 'app/features/search/types';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
import { FolderInfo, PermissionLevelString } from 'app/types';
export interface FolderFilterProps {
@ -13,34 +13,21 @@ export interface FolderFilterProps {
maxMenuHeight?: number;
}
export function FolderFilter({ onChange: propsOnChange, maxMenuHeight }: FolderFilterProps): JSX.Element {
export function FolderFilter({ onChange, maxMenuHeight }: FolderFilterProps): JSX.Element {
const styles = useStyles2(getStyles);
const [loading, setLoading] = useState(false);
const getOptions = useCallback((searchString: string) => getFoldersAsOptions(searchString, setLoading), []);
const debouncedLoadOptions = useMemo(() => debounce(getOptions, 300), [getOptions]);
const [value, setValue] = useState<Array<SelectableValue<FolderInfo>>>([]);
const onChange = useCallback(
const onSelectOptionChange = useCallback(
(folders: Array<SelectableValue<FolderInfo>>) => {
const changedFolders = [];
for (const folder of folders) {
if (folder.value) {
changedFolders.push(folder.value);
}
}
propsOnChange(changedFolders);
const changedFolderIds = folders.filter((f) => Boolean(f.value)).map((f) => f.value!);
onChange(changedFolderIds);
setValue(folders);
},
[propsOnChange]
[onChange]
);
const selectOptions = {
defaultOptions: true,
isMulti: true,
noOptionsMessage: 'No folders found',
placeholder: 'Filter by folder',
maxMenuHeight,
value,
onChange,
};
return (
<div className={styles.container}>
@ -57,28 +44,35 @@ export function FolderFilter({ onChange: propsOnChange, maxMenuHeight }: FolderF
</Button>
)}
<AsyncMultiSelect
{...selectOptions}
value={value}
onChange={onSelectOptionChange}
isLoading={loading}
loadOptions={debouncedLoadOptions}
maxMenuHeight={maxMenuHeight}
placeholder="Filter by folder"
noOptionsMessage="No folders found"
prefix={<Icon name="filter" />}
aria-label="Folder filter"
defaultOptions
/>
</div>
);
}
async function getFoldersAsOptions(searchString: string, setLoading: (loading: boolean) => void) {
async function getFoldersAsOptions(
searchString: string,
setLoading: (loading: boolean) => void
): Promise<Array<SelectableValue<FolderInfo>>> {
setLoading(true);
const params = {
query: searchString,
type: 'dash-folder',
type: DashboardSearchItemType.DashFolder,
permission: PermissionLevelString.View,
};
// FIXME: stop using id from search and use UID instead
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const searchHits = (await getBackendSrv().search(params)) as DashboardSearchHit[];
const searchHits: DashboardSearchHit[] = await getBackendSrv().search(params);
const options = searchHits.map((d) => ({ label: d.title, value: { id: d.id, title: d.title } }));
if (!searchString || 'general'.includes(searchString.toLowerCase())) {
options.unshift({ label: 'General', value: { id: 0, title: 'General' } });

View File

@ -11,9 +11,7 @@ export interface Props {
}
export const PanelTypeFilter = ({ onChange: propsOnChange, maxMenuHeight }: Props): JSX.Element => {
const plugins = useMemo<PanelPluginMeta[]>(() => {
return getAllPanelPluginMeta();
}, []);
const plugins = useMemo<PanelPluginMeta[]>(() => getAllPanelPluginMeta(), []);
const options = useMemo(
() =>
plugins
@ -24,12 +22,7 @@ export const PanelTypeFilter = ({ onChange: propsOnChange, maxMenuHeight }: Prop
const [value, setValue] = useState<Array<SelectableValue<PanelPluginMeta>>>([]);
const onChange = useCallback(
(plugins: Array<SelectableValue<PanelPluginMeta>>) => {
const changedPlugins = [];
for (const plugin of plugins) {
if (plugin.value) {
changedPlugins.push(plugin.value);
}
}
const changedPlugins = plugins.filter((p) => p.value).map((p) => p.value!);
propsOnChange(changedPlugins);
setValue(plugins);
},

View File

@ -1,26 +1,18 @@
import { css } from '@emotion/css';
import React, { useReducer } from 'react';
import React, { useCallback, useState } from 'react';
import { useDebounce } from 'react-use';
import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data';
import { HorizontalGroup, useStyles2, VerticalGroup, FilterInput } from '@grafana/ui';
import { useStyles2, VerticalGroup, FilterInput } from '@grafana/ui';
import { FolderInfo } from 'app/types';
import { FolderFilter } from '../../../../core/components/FolderFilter/FolderFilter';
import { PanelTypeFilter } from '../../../../core/components/PanelTypeFilter/PanelTypeFilter';
import { SortPicker } from '../../../../core/components/Select/SortPicker';
import { DEFAULT_PER_PAGE_PAGINATION } from '../../../../core/constants';
import { FolderInfo } from '../../../../types';
import { LibraryElementDTO } from '../../types';
import { LibraryPanelsView } from '../LibraryPanelsView/LibraryPanelsView';
import {
folderFilterChanged,
initialLibraryPanelsSearchState,
libraryPanelsSearchReducer,
panelFilterChanged,
searchChanged,
sortChanged,
} from './reducer';
export enum LibraryPanelsSearchVariant {
Tight = 'tight',
Spacious = 'spacious',
@ -49,78 +41,51 @@ export const LibraryPanelsSearch = ({
showSort = false,
showSecondaryActions = false,
}: LibraryPanelsSearchProps): JSX.Element => {
const styles = useStyles2(getStyles);
const [{ sortDirection, panelFilter, folderFilter, searchQuery }, dispatch] = useReducer(libraryPanelsSearchReducer, {
...initialLibraryPanelsSearchState,
folderFilter: currentFolderId ? [currentFolderId.toString(10)] : [],
});
const onFilterChange = (searchString: string) => dispatch(searchChanged(searchString));
const onSortChange = (sorting: SelectableValue<string>) => dispatch(sortChanged(sorting));
const onFolderFilterChange = (folders: FolderInfo[]) => dispatch(folderFilterChanged(folders));
const onPanelFilterChange = (plugins: PanelPluginMeta[]) => dispatch(panelFilterChanged(plugins));
const styles = useStyles2(useCallback((theme) => getStyles(theme, variant), [variant]));
if (variant === LibraryPanelsSearchVariant.Spacious) {
return (
<div className={styles.container}>
<VerticalGroup spacing="lg">
<FilterInput
value={searchQuery}
onChange={onFilterChange}
placeholder={'Search by name or description'}
width={0}
/>
<div className={styles.buttonRow}>
<HorizontalGroup
spacing="sm"
justify={(showSort && showPanelFilter) || showFolderFilter ? 'space-between' : 'flex-end'}
>
{showSort && (
<SortPicker value={sortDirection} onChange={onSortChange} filter={['alpha-asc', 'alpha-desc']} />
)}
<HorizontalGroup
spacing="sm"
justify={showFolderFilter && showPanelFilter ? 'space-between' : 'flex-end'}
>
{showFolderFilter && <FolderFilter onChange={onFolderFilterChange} />}
{showPanelFilter && <PanelTypeFilter onChange={onPanelFilterChange} />}
</HorizontalGroup>
</HorizontalGroup>
</div>
<div className={styles.libraryPanelsView}>
<LibraryPanelsView
onClickCard={onClick}
searchString={searchQuery}
sortDirection={sortDirection}
panelFilter={panelFilter}
folderFilter={folderFilter}
currentPanelId={currentPanelId}
showSecondaryActions={showSecondaryActions}
perPage={perPage}
/>
</div>
</VerticalGroup>
</div>
);
}
const [searchQuery, setSearchQuery] = useState('');
const [debouncedSearchQuery, setDebouncedSearchQuery] = useState('');
useDebounce(() => setDebouncedSearchQuery(searchQuery), 200, [searchQuery]);
const [sortDirection, setSortDirection] = useState<SelectableValue<string>>({});
const [folderFilter, setFolderFilter] = useState<string[]>(currentFolderId ? [String(currentFolderId)] : []);
const [panelFilter, setPanelFilter] = useState<string[]>([]);
const sortOrFiltersVisible = showSort || showPanelFilter || showFolderFilter;
const verticalGroupSpacing = variant === LibraryPanelsSearchVariant.Tight ? 'lg' : 'xs';
return (
<div className={styles.container}>
<VerticalGroup spacing="xs">
<div className={styles.tightButtonRow}>
<div className={styles.tightFilter}>
<FilterInput value={searchQuery} onChange={onFilterChange} placeholder={'Search by name'} width={0} />
</div>
<div className={styles.tightSortFilter}>
{showSort && <SortPicker value={sortDirection} onChange={onSortChange} />}
{showFolderFilter && <FolderFilter onChange={onFolderFilterChange} maxMenuHeight={200} />}
{showPanelFilter && <PanelTypeFilter onChange={onPanelFilterChange} maxMenuHeight={200} />}
<VerticalGroup spacing={verticalGroupSpacing}>
<div className={styles.gridContainer}>
<div className={styles.filterInputWrapper}>
<FilterInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search by name or description"
width={0}
escapeRegex={false}
/>
</div>
{sortOrFiltersVisible && (
<SearchControls
showSort={showSort}
showPanelFilter={showPanelFilter}
showFolderFilter={showFolderFilter}
onSortChange={setSortDirection}
onFolderFilterChange={setFolderFilter}
onPanelFilterChange={setPanelFilter}
sortDirection={sortDirection.value}
variant={variant}
/>
)}
</div>
<div className={styles.libraryPanelsView}>
<LibraryPanelsView
onClickCard={onClick}
searchString={searchQuery}
sortDirection={sortDirection}
searchString={debouncedSearchQuery}
sortDirection={sortDirection.value}
panelFilter={panelFilter}
folderFilter={folderFilter}
currentPanelId={currentPanelId}
@ -133,34 +98,118 @@ export const LibraryPanelsSearch = ({
);
};
function getStyles(theme: GrafanaTheme2) {
function getStyles(theme: GrafanaTheme2, variant: LibraryPanelsSearchVariant) {
const tightLayout = css`
flex-direction: row;
row-gap: ${theme.spacing(1)};
`;
return {
filterInputWrapper: css`
flex-grow: ${variant === LibraryPanelsSearchVariant.Tight ? 1 : 'initial'};
`,
container: css`
width: 100%;
overflow-y: auto;
padding: ${theme.spacing(1)};
`,
buttonRow: css`
display: flex;
justify-content: space-between;
width: 100%;
margin-top: ${theme.spacing(2)}; // Clear types link
`,
tightButtonRow: css`
display: flex;
justify-content: space-between;
width: 100%;
margin-top: ${theme.spacing(4)}; // Clear types link
`,
tightFilter: css`
flex-grow: 1;
`,
tightSortFilter: css`
flex-grow: 1;
padding: ${theme.spacing(0, 0, 0, 0.5)};
`,
libraryPanelsView: css`
width: 100%;
`,
gridContainer: css`
${variant === LibraryPanelsSearchVariant.Tight ? tightLayout : ''};
display: flex;
flex-direction: column;
width: 100%;
column-gap: ${theme.spacing(1)};
row-gap: ${theme.spacing(1)};
padding-bottom: ${theme.spacing(2)};
`,
};
}
interface SearchControlsProps {
showSort: boolean;
showPanelFilter: boolean;
showFolderFilter: boolean;
sortDirection?: string;
onSortChange: (sortValue: SelectableValue) => void;
onFolderFilterChange: (folder: string[]) => void;
onPanelFilterChange: (plugins: string[]) => void;
variant?: LibraryPanelsSearchVariant;
}
const SearchControls = React.memo(
({
variant = LibraryPanelsSearchVariant.Spacious,
showSort,
showPanelFilter,
showFolderFilter,
sortDirection,
onSortChange,
onFolderFilterChange,
onPanelFilterChange,
}: SearchControlsProps) => {
const styles = useStyles2(useCallback((theme) => getRowStyles(theme, variant), [variant]));
const panelFilterChanged = useCallback(
(plugins: PanelPluginMeta[]) => onPanelFilterChange(plugins.map((p) => p.id)),
[onPanelFilterChange]
);
const folderFilterChanged = useCallback(
(folders: FolderInfo[]) => onFolderFilterChange(folders.map((f) => String(f.id))),
[onFolderFilterChange]
);
return (
<div className={styles.container}>
{showSort && <SortPicker value={sortDirection} onChange={onSortChange} filter={['alpha-asc', 'alpha-desc']} />}
{(showFolderFilter || showPanelFilter) && (
<div className={styles.filterContainer}>
{showFolderFilter && <FolderFilter onChange={folderFilterChanged} />}
{showPanelFilter && <PanelTypeFilter onChange={panelFilterChanged} />}
</div>
)}
</div>
);
}
);
SearchControls.displayName = 'SearchControls';
function getRowStyles(theme: GrafanaTheme2, variant = LibraryPanelsSearchVariant.Spacious) {
const searchRowContainer = css`
display: flex;
gap: ${theme.spacing(1)};
flex-grow: 1;
flex-direction: row;
justify-content: end;
`;
const searchRowContainerTight = css`
${searchRowContainer};
flex-grow: initial;
flex-direction: column;
justify-content: normal;
`;
const filterContainer = css`
display: flex;
flex-direction: row;
margin-left: auto;
gap: 4px;
`;
const filterContainerTight = css`
${filterContainer};
flex-direction: column;
margin-left: initial;
`;
switch (variant) {
case LibraryPanelsSearchVariant.Spacious:
return {
container: searchRowContainer,
filterContainer: filterContainer,
};
case LibraryPanelsSearchVariant.Tight:
return {
container: searchRowContainerTight,
filterContainer: filterContainerTight,
};
}
}

View File

@ -1,77 +0,0 @@
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
import {
folderFilterChanged,
initialLibraryPanelsSearchState,
libraryPanelsSearchReducer,
LibraryPanelsSearchState,
panelFilterChanged,
searchChanged,
sortChanged,
} from './reducer';
describe('libraryPanelsSearchReducer', () => {
describe('when searchChanged is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LibraryPanelsSearchState>()
.givenReducer(libraryPanelsSearchReducer, {
...initialLibraryPanelsSearchState,
})
.whenActionIsDispatched(searchChanged('searching for'))
.thenStateShouldEqual({
...initialLibraryPanelsSearchState,
searchQuery: 'searching for',
});
});
});
describe('when sortChanged is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LibraryPanelsSearchState>()
.givenReducer(libraryPanelsSearchReducer, {
...initialLibraryPanelsSearchState,
})
.whenActionIsDispatched(sortChanged({ label: 'Ascending', value: 'asc' }))
.thenStateShouldEqual({
...initialLibraryPanelsSearchState,
sortDirection: 'asc',
});
});
});
describe('when panelFilterChanged is dispatched', () => {
it('then state should be correct', () => {
const plugins: any = [
{ id: 'graph', name: 'Graph' },
{ id: 'timeseries', name: 'Time Series' },
];
reducerTester<LibraryPanelsSearchState>()
.givenReducer(libraryPanelsSearchReducer, {
...initialLibraryPanelsSearchState,
})
.whenActionIsDispatched(panelFilterChanged(plugins))
.thenStateShouldEqual({
...initialLibraryPanelsSearchState,
panelFilter: ['graph', 'timeseries'],
});
});
});
describe('when folderFilterChanged is dispatched', () => {
it('then state should be correct', () => {
const folders: any = [
{ id: 0, name: 'General' },
{ id: 1, name: 'Folder' },
];
reducerTester<LibraryPanelsSearchState>()
.givenReducer(libraryPanelsSearchReducer, {
...initialLibraryPanelsSearchState,
})
.whenActionIsDispatched(folderFilterChanged(folders))
.thenStateShouldEqual({
...initialLibraryPanelsSearchState,
folderFilter: ['0', '1'],
});
});
});
});

View File

@ -1,45 +0,0 @@
import { createAction } from '@reduxjs/toolkit';
import { AnyAction } from 'redux';
import { PanelPluginMeta, SelectableValue } from '@grafana/data';
import { FolderInfo } from '../../../../types';
export interface LibraryPanelsSearchState {
searchQuery: string;
sortDirection?: string;
panelFilter: string[];
folderFilter: string[];
}
export const initialLibraryPanelsSearchState: LibraryPanelsSearchState = {
searchQuery: '',
panelFilter: [],
folderFilter: [],
sortDirection: undefined,
};
export const searchChanged = createAction<string>('libraryPanels/search/searchChanged');
export const sortChanged = createAction<SelectableValue<string>>('libraryPanels/search/sortChanged');
export const panelFilterChanged = createAction<PanelPluginMeta[]>('libraryPanels/search/panelFilterChanged');
export const folderFilterChanged = createAction<FolderInfo[]>('libraryPanels/search/folderFilterChanged');
export const libraryPanelsSearchReducer = (state: LibraryPanelsSearchState, action: AnyAction) => {
if (searchChanged.match(action)) {
return { ...state, searchQuery: action.payload };
}
if (sortChanged.match(action)) {
return { ...state, sortDirection: action.payload.value };
}
if (panelFilterChanged.match(action)) {
return { ...state, panelFilter: action.payload.map((p) => p.id) };
}
if (folderFilterChanged.match(action)) {
return { ...state, folderFilter: action.payload.map((f) => String(f.id!)) };
}
return state;
};

View File

@ -1,8 +1,8 @@
import { css } from '@emotion/css';
import React, { FC, useCallback, useState } from 'react';
import { GrafanaTheme2, PanelPluginMeta } from '@grafana/data';
import { Button, useStyles2, VerticalGroup } from '@grafana/ui';
import { PanelPluginMeta } from '@grafana/data';
import { Button, VerticalGroup } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { PanelModel } from 'app/features/dashboard/state';
import { changeToLibraryPanel } from 'app/features/panel/state/actions';
@ -20,7 +20,6 @@ interface Props {
}
export const PanelLibraryOptionsGroup: FC<Props> = ({ panel, searchQuery }) => {
const styles = useStyles2(getStyles);
const [showingAddPanelModal, setShowingAddPanelModal] = useState(false);
const [changeToPanel, setChangeToPanel] = useState<LibraryElementDTO | undefined>(undefined);
const [panelFilter, setPanelFilter] = useState<string[]>([]);
@ -39,21 +38,11 @@ export const PanelLibraryOptionsGroup: FC<Props> = ({ panel, searchQuery }) => {
}
setChangeToPanel(undefined);
dispatch(changeToLibraryPanel(panel, changeToPanel));
};
const onAddToPanelLibrary = () => {
setShowingAddPanelModal(true);
};
const onChangeLibraryPanel = (panel: LibraryElementDTO) => {
setChangeToPanel(panel);
};
const onDismissChangeToPanel = () => {
setChangeToPanel(undefined);
};
const onAddToPanelLibrary = () => setShowingAddPanelModal(true);
const onDismissChangeToPanel = () => setChangeToPanel(undefined);
return (
<VerticalGroup spacing="md">
@ -72,7 +61,7 @@ export const PanelLibraryOptionsGroup: FC<Props> = ({ panel, searchQuery }) => {
currentPanelId={panel.libraryPanel?.uid}
searchString={searchQuery}
panelFilter={panelFilter}
onClickCard={onChangeLibraryPanel}
onClickCard={setChangeToPanel}
showSecondaryActions
/>
</div>
@ -93,10 +82,8 @@ export const PanelLibraryOptionsGroup: FC<Props> = ({ panel, searchQuery }) => {
);
};
const getStyles = (theme: GrafanaTheme2) => {
return {
libraryPanelsView: css`
width: 100%;
`,
};
const styles = {
libraryPanelsView: css`
width: 100%;
`,
};