mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Search/ui issues (#23945)
* Search: Move layout to query reducer/hook * Search: Move extra layout/sort logic to reducer * Search: Tweak action row spacing * Search: Update TagOption * Search: Remove duplicate function * Search: Add Clear tags button * Search: Align checkbox * Search: Add TagFilter.displayName * Search: Update default placeholder * Search: Return all dashboards for list view * Search: Apply custom line-height to ActionRow checkbox
This commit is contained in:
parent
295e15246e
commit
32492dd650
@ -1,11 +1,10 @@
|
||||
// Libraries
|
||||
import React from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import { css } from 'emotion';
|
||||
// @ts-ignore
|
||||
import { components } from '@torkelo/react-select';
|
||||
import { AsyncSelect, stylesFactory } from '@grafana/ui';
|
||||
import { Icon } from '@grafana/ui';
|
||||
import { escapeStringForRegex } from '@grafana/data';
|
||||
import { AsyncSelect, stylesFactory, useTheme, resetSelectStyles, Icon } from '@grafana/ui';
|
||||
import { escapeStringForRegex, GrafanaTheme } from '@grafana/data';
|
||||
// Components
|
||||
import { TagOption } from './TagOption';
|
||||
import { TagBadge } from './TagBadge';
|
||||
@ -31,17 +30,20 @@ const filterOption = (option: any, searchQuery: string) => {
|
||||
return regex.test(option.value);
|
||||
};
|
||||
|
||||
export class TagFilter extends React.Component<Props, any> {
|
||||
static defaultProps = {
|
||||
placeholder: 'Tags',
|
||||
};
|
||||
export const TagFilter: FC<Props> = ({
|
||||
hideValues,
|
||||
isClearable,
|
||||
onChange,
|
||||
placeholder = 'Filter by tag',
|
||||
tagOptions,
|
||||
tags,
|
||||
width,
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
}
|
||||
|
||||
onLoadOptions = (query: string) => {
|
||||
return this.props.tagOptions().then(options => {
|
||||
const onLoadOptions = (query: string) => {
|
||||
return tagOptions().then(options => {
|
||||
return options.map(option => ({
|
||||
value: option.term,
|
||||
label: option.term,
|
||||
@ -50,61 +52,64 @@ export class TagFilter extends React.Component<Props, any> {
|
||||
});
|
||||
};
|
||||
|
||||
onChange = (newTags: any[]) => {
|
||||
const onTagChange = (newTags: any[]) => {
|
||||
// On remove with 1 item returns null, so we need to make sure it's an empty array in that case
|
||||
// https://github.com/JedWatson/react-select/issues/3632
|
||||
this.props.onChange((newTags || []).map(tag => tag.value));
|
||||
onChange((newTags || []).map(tag => tag.value));
|
||||
};
|
||||
|
||||
render() {
|
||||
const styles = getStyles();
|
||||
const value = tags.map(tag => ({ value: tag, label: tag, count: 0 }));
|
||||
|
||||
const tags = this.props.tags.map(tag => ({ value: tag, label: tag, count: 0 }));
|
||||
const { width, placeholder, hideValues, isClearable } = this.props;
|
||||
|
||||
const selectOptions = {
|
||||
defaultOptions: true,
|
||||
filterOption,
|
||||
getOptionLabel: (i: any) => i.label,
|
||||
getOptionValue: (i: any) => i.value,
|
||||
isClearable,
|
||||
isMulti: true,
|
||||
loadOptions: this.onLoadOptions,
|
||||
loadingMessage: 'Loading...',
|
||||
noOptionsMessage: 'No tags found',
|
||||
onChange: this.onChange,
|
||||
placeholder,
|
||||
value: tags,
|
||||
width,
|
||||
components: {
|
||||
Option: TagOption,
|
||||
MultiValueLabel: (): any => {
|
||||
return null; // We want the whole tag to be clickable so we use MultiValueRemove instead
|
||||
},
|
||||
MultiValueRemove: (props: any) => {
|
||||
const { data } = props;
|
||||
|
||||
return (
|
||||
<components.MultiValueRemove {...props}>
|
||||
<TagBadge key={data.label} label={data.label} removeIcon={true} count={data.count} />
|
||||
</components.MultiValueRemove>
|
||||
);
|
||||
},
|
||||
MultiValueContainer: hideValues ? (): any => null : components.MultiValueContainer,
|
||||
const selectOptions = {
|
||||
defaultOptions: true,
|
||||
filterOption,
|
||||
getOptionLabel: (i: any) => i.label,
|
||||
getOptionValue: (i: any) => i.value,
|
||||
isMulti: true,
|
||||
loadOptions: onLoadOptions,
|
||||
loadingMessage: 'Loading...',
|
||||
noOptionsMessage: 'No tags found',
|
||||
onChange: onTagChange,
|
||||
placeholder,
|
||||
styles: resetSelectStyles(),
|
||||
value,
|
||||
width,
|
||||
components: {
|
||||
Option: TagOption,
|
||||
MultiValueLabel: (): any => {
|
||||
return null; // We want the whole tag to be clickable so we use MultiValueRemove instead
|
||||
},
|
||||
};
|
||||
MultiValueRemove: (props: any) => {
|
||||
const { data } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.tagFilter}>
|
||||
<AsyncSelect {...selectOptions} prefix={<Icon name="tag-alt" />} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<components.MultiValueRemove {...props}>
|
||||
<TagBadge key={data.label} label={data.label} removeIcon={true} count={data.count} />
|
||||
</components.MultiValueRemove>
|
||||
);
|
||||
},
|
||||
MultiValueContainer: hideValues ? (): any => null : components.MultiValueContainer,
|
||||
},
|
||||
};
|
||||
|
||||
const getStyles = stylesFactory(() => {
|
||||
return (
|
||||
<div className={styles.tagFilter}>
|
||||
{isClearable && tags.length > 0 && (
|
||||
<span className={styles.clear} onClick={() => onTagChange([])}>
|
||||
Clear tags
|
||||
</span>
|
||||
)}
|
||||
<AsyncSelect {...selectOptions} prefix={<Icon name="tag-alt" />} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TagFilter.displayName = 'TagFilter';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
tagFilter: css`
|
||||
position: relative;
|
||||
min-width: 180px;
|
||||
flex-grow: 1;
|
||||
|
||||
@ -113,5 +118,18 @@ const getStyles = stylesFactory(() => {
|
||||
cursor: pointer;
|
||||
}
|
||||
`,
|
||||
clear: css`
|
||||
text-decoration: underline;
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
top: -22px;
|
||||
right: 0;
|
||||
cursor: pointer;
|
||||
color: ${theme.colors.textWeak};
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colors.textStrong};
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -1,7 +1,8 @@
|
||||
// Libraries
|
||||
import React from 'react';
|
||||
// @ts-ignore
|
||||
import { components } from '@torkelo/react-select';
|
||||
import React, { FC } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { useTheme, stylesFactory } from '@grafana/ui';
|
||||
import { GrafanaTheme } from '@grafana/data';
|
||||
|
||||
import { OptionProps } from 'react-select/src/components/Option';
|
||||
import { TagBadge } from './TagBadge';
|
||||
|
||||
@ -10,15 +11,40 @@ interface ExtendedOptionProps extends OptionProps<any> {
|
||||
data: any;
|
||||
}
|
||||
|
||||
export const TagOption = (props: ExtendedOptionProps) => {
|
||||
const { data, className, label } = props;
|
||||
export const TagOption: FC<ExtendedOptionProps> = ({ data, className, label, isFocused, innerProps }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getStyles(theme);
|
||||
|
||||
return (
|
||||
<components.Option {...props}>
|
||||
<div className={cx(styles.option, isFocused && styles.optionFocused)} aria-label="Tag option" {...innerProps}>
|
||||
<div className={`tag-filter-option ${className || ''}`}>
|
||||
<TagBadge label={label} removeIcon={false} count={data.count} />
|
||||
</div>
|
||||
</components.Option>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TagOption;
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
option: css`
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
border-left: 2px solid transparent;
|
||||
&:hover {
|
||||
background: ${theme.colors.dropdownOptionHoverBg};
|
||||
}
|
||||
`,
|
||||
optionFocused: css`
|
||||
background: ${theme.colors.dropdownOptionHoverBg};
|
||||
border-style: solid;
|
||||
border-top: 0;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left-width: 2px;
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -74,13 +74,15 @@ export class SearchSrv {
|
||||
const filters = hasFilters(options) || query.folderIds?.length > 0;
|
||||
|
||||
query.folderIds = query.folderIds || [];
|
||||
if (!filters) {
|
||||
query.folderIds = [0];
|
||||
}
|
||||
|
||||
if (query.layout === SearchLayout.List) {
|
||||
return backendSrv.search({ ...query, type: DashboardSearchItemType.DashDB });
|
||||
}
|
||||
|
||||
if (!filters) {
|
||||
query.folderIds = [0];
|
||||
}
|
||||
|
||||
if (!options.skipRecent && !filters) {
|
||||
promises.push(this.getRecentDashboards(sections));
|
||||
}
|
||||
|
@ -8,8 +8,8 @@ import { SearchSrv } from 'app/core/services/search_srv';
|
||||
import { DashboardQuery, SearchLayout } from '../types';
|
||||
|
||||
export const layoutOptions = [
|
||||
{ label: 'Folders', value: SearchLayout.Folders, icon: 'folder' },
|
||||
{ label: 'List', value: SearchLayout.List, icon: 'list-ul' },
|
||||
{ value: SearchLayout.Folders, icon: 'folder' },
|
||||
{ value: SearchLayout.List, icon: 'list-ul' },
|
||||
];
|
||||
|
||||
const searchSrv = new SearchSrv();
|
||||
@ -39,20 +39,21 @@ export const ActionRow: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<div className={styles.actionRow}>
|
||||
<div className={styles.rowContainer}>
|
||||
<HorizontalGroup spacing="md" width="auto">
|
||||
{!hideLayout ? (
|
||||
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} />
|
||||
) : null}
|
||||
<SortPicker onChange={onSortChange} value={query.sort} />
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
<HorizontalGroup spacing="md" width="auto">
|
||||
{!hideLayout ? (
|
||||
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} />
|
||||
) : null}
|
||||
<SortPicker onChange={onSortChange} value={query.sort} />
|
||||
</HorizontalGroup>
|
||||
<HorizontalGroup spacing="md" width="auto">
|
||||
{showStarredFilter && <Checkbox label="Filter by starred" onChange={onStarredFilterChange} />}
|
||||
<TagFilter
|
||||
placeholder="Filter by tag"
|
||||
tags={query.tag}
|
||||
tagOptions={searchSrv.getDashboardTags}
|
||||
onChange={onTagFilterChange}
|
||||
/>
|
||||
{showStarredFilter && (
|
||||
<div className={styles.checkboxWrapper}>
|
||||
<Checkbox label="Filter by starred" onChange={onStarredFilterChange} />
|
||||
</div>
|
||||
)}
|
||||
<TagFilter isClearable tags={query.tag} tagOptions={searchSrv.getDashboardTags} onChange={onTagFilterChange} />
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
);
|
||||
@ -69,9 +70,17 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: ${theme.spacing.md} 0;
|
||||
padding: ${theme.spacing.lg} 0;
|
||||
width: 100%;
|
||||
}
|
||||
`,
|
||||
rowContainer: css`
|
||||
margin-right: ${theme.spacing.md};
|
||||
`,
|
||||
checkboxWrapper: css`
|
||||
label {
|
||||
line-height: 1.2;
|
||||
}
|
||||
`,
|
||||
};
|
||||
});
|
||||
|
@ -127,7 +127,8 @@ const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
noResults: css`
|
||||
padding: ${md};
|
||||
background: ${theme.colors.bg2};
|
||||
text-style: italic;
|
||||
font-style: italic;
|
||||
margin-top: ${theme.spacing.md};
|
||||
`,
|
||||
listModeWrapper: css`
|
||||
position: relative;
|
||||
|
@ -83,8 +83,8 @@ describe('SearchResultsFilter', () => {
|
||||
wrapper
|
||||
.find({ placeholder: 'Filter by tag' })
|
||||
.at(0)
|
||||
.prop('onChange')(tags[0]);
|
||||
.prop('onChange')([tags[0]]);
|
||||
expect(mockFilterByTags).toHaveBeenCalledTimes(1);
|
||||
expect(mockFilterByTags).toHaveBeenCalledWith(tags[0]);
|
||||
expect(mockFilterByTags).toHaveBeenCalledWith(['tag1']);
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user