From bbc2f4cbad60bd99fe203e653ae4bfdf567395a0 Mon Sep 17 00:00:00 2001 From: Matej Kubinec <32638572+matejkubinec@users.noreply.github.com> Date: Mon, 27 Nov 2023 08:37:00 +0100 Subject: [PATCH] PMM-12476 Cluster view search (#687) * PMM-12476 Add clusters search and filtering * PMM-12476 Use correct type * PMM-12476 Cleanup * PMM-12476 Remove logging and dead code * PMM-12476 Allow actions dialog to be visible --- .../percona/inventory/Inventory.messages.ts | 5 + .../inventory/Tabs/Services/ClusterItem.tsx | 10 +- .../Tabs/Services/Clusters.constants.ts | 52 +++++++++ .../inventory/Tabs/Services/Clusters.tsx | 41 +++++-- .../inventory/Tabs/Services/Clusters.type.tsx | 1 + .../Tabs/Services/Services.constants.ts | 40 +++++++ .../Elements/Table/Filter/Filter.tsx | 18 ++-- .../Elements/Table/Filter/Filter.types.ts | 12 ++- .../Elements/Table/Filter/Filter.utils.ts | 70 ++++++------ .../components/fields/SelectDropdownField.tsx | 4 +- .../components/Elements/Table/Table.styles.ts | 4 + .../Form/TextInput/TextInput.styles.ts | 35 +++++- .../Form/TextInput/TextInputField.tsx | 44 ++++++-- .../SearchFilter/SearchFilter.messages.ts | 6 ++ .../SearchFilter/SearchFilter.styles.ts | 25 +++++ .../components/SearchFilter/SearchFilter.tsx | 101 ++++++++++++++++++ .../SearchFilter/SearchFilter.types.ts | 2 + .../SearchFilter/SearchFilter.utils.ts | 39 +++++++ .../shared/components/SearchFilter/index.ts | 1 + 19 files changed, 443 insertions(+), 67 deletions(-) create mode 100644 public/app/percona/inventory/Tabs/Services/Clusters.constants.ts create mode 100644 public/app/percona/inventory/Tabs/Services/Services.constants.ts create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.messages.ts create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.styles.ts create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.tsx create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.types.ts create mode 100644 public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts create mode 100644 public/app/percona/shared/components/SearchFilter/index.ts diff --git a/public/app/percona/inventory/Inventory.messages.ts b/public/app/percona/inventory/Inventory.messages.ts index 44f590eabd96f..efe6d8b6064cc 100644 --- a/public/app/percona/inventory/Inventory.messages.ts +++ b/public/app/percona/inventory/Inventory.messages.ts @@ -10,6 +10,7 @@ export const Messages = { monitoring: 'Monitoring', address: 'Address', port: 'Port', + cluster: 'Cluster', }, actions: { dashboard: 'Dashboard', @@ -30,6 +31,10 @@ export const Messages = { organizeByClusters: 'Organize by Clusters', technicalPreview: '(Technical Preview) ', }, + clusters: { + empty: 'No clusters available', + noMatch: 'No clusters found', + }, agents: { goBackToServices: 'Go back to services', goBackToNodes: 'Go back to nodes', diff --git a/public/app/percona/inventory/Tabs/Services/ClusterItem.tsx b/public/app/percona/inventory/Tabs/Services/ClusterItem.tsx index 4553762971802..7ae1e7acd3539 100644 --- a/public/app/percona/inventory/Tabs/Services/ClusterItem.tsx +++ b/public/app/percona/inventory/Tabs/Services/ClusterItem.tsx @@ -10,8 +10,8 @@ import { ClusterItemProps } from './Clusters.type'; import { removeClusterFilters, shouldClusterBeExpanded } from './Clusters.utils'; import ServicesTable from './ServicesTable'; -const ClusterItem: FC = ({ cluster, onDelete, onSelectionChange }) => { - const [isOpen, setIsOpen] = useState(shouldClusterBeExpanded(cluster.name)); +const ClusterItem: FC = ({ cluster, onDelete, onSelectionChange, openByDefault }) => { + const [isOpen, setIsOpen] = useState(shouldClusterBeExpanded(cluster.name) || openByDefault); const icon: IconName = cluster.type ? (DATABASE_ICONS[cluster.type] as IconName) : 'database'; const handleSelectionChange = useCallback( @@ -27,6 +27,12 @@ const ClusterItem: FC = ({ cluster, onDelete, onSelectionChang } }, [isOpen, cluster.name]); + useEffect(() => { + if (openByDefault !== undefined) { + setIsOpen(openByDefault || shouldClusterBeExpanded(cluster.name)); + } + }, [openByDefault, cluster.name]); + return ( > = [ + { + Header: Messages.services.columns.serviceId, + id: 'serviceId', + accessor: 'serviceId', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.cluster, + accessor: 'cluster', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.status, + accessor: 'status', + type: FilterFieldTypes.DROPDOWN, + options: STATUS_OPTIONS, + }, + { + Header: Messages.services.columns.serviceName, + accessor: 'serviceName', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.nodeName, + accessor: 'nodeName', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.monitoring, + accessor: 'agentsStatus', + type: FilterFieldTypes.RADIO_BUTTON, + options: MONITORING_OPTIONS, + }, + { + Header: Messages.services.columns.address, + accessor: 'address', + type: FilterFieldTypes.TEXT, + }, + { + Header: Messages.services.columns.port, + accessor: 'port', + type: FilterFieldTypes.TEXT, + }, +]; diff --git a/public/app/percona/inventory/Tabs/Services/Clusters.tsx b/public/app/percona/inventory/Tabs/Services/Clusters.tsx index 0b89950a06ff3..4822045ea4979 100644 --- a/public/app/percona/inventory/Tabs/Services/Clusters.tsx +++ b/public/app/percona/inventory/Tabs/Services/Clusters.tsx @@ -1,15 +1,21 @@ import React, { FC, useCallback, useMemo, useState } from 'react'; import { Row } from 'react-table'; +import { SearchFilter } from 'app/percona/shared/components/SearchFilter'; + +import { Messages } from '../../Inventory.messages'; import { FlattenService } from '../../Inventory.types'; import ClusterItem from './ClusterItem'; +import { CLUSTERS_COLUMNS } from './Clusters.constants'; import { ClustersProps, ServicesCluster } from './Clusters.type'; import { getClustersFromServices } from './Clusters.utils'; const Clusters: FC = ({ services, onDelete, onSelectionChange }) => { - const clusters = useMemo(() => getClustersFromServices(services), [services]); + const [filtered, setFiltered] = useState(services); + const clusters = useMemo(() => getClustersFromServices(filtered), [filtered]); const [selection, setSelection] = useState({}); + const filterEnabled = filtered !== services; const handleSelectionChange = useCallback( (cluster: ServicesCluster, selectedServices: Array>) => { @@ -25,16 +31,33 @@ const Clusters: FC = ({ services, onDelete, onSelectionChange }) [onSelectionChange] ); + const handleFiltering = useCallback((rows: FlattenService[]) => { + setFiltered(rows); + }, []); + return (
- {clusters.map((cluster) => ( - - ))} + + {clusters.length ? ( + clusters.map((cluster) => ( + + )) + ) : filterEnabled ? ( +
{Messages.clusters.noMatch}
+ ) : ( +
{Messages.clusters.empty}
+ )}
); }; diff --git a/public/app/percona/inventory/Tabs/Services/Clusters.type.tsx b/public/app/percona/inventory/Tabs/Services/Clusters.type.tsx index 45ea9c3e68d24..8adce85f204f5 100644 --- a/public/app/percona/inventory/Tabs/Services/Clusters.type.tsx +++ b/public/app/percona/inventory/Tabs/Services/Clusters.type.tsx @@ -12,6 +12,7 @@ export interface ClustersProps { export interface ClusterItemProps { cluster: ServicesCluster; + openByDefault?: boolean; onDelete: (service: FlattenService) => void; onSelectionChange: (cluster: ServicesCluster, services: Array>) => void; } diff --git a/public/app/percona/inventory/Tabs/Services/Services.constants.ts b/public/app/percona/inventory/Tabs/Services/Services.constants.ts new file mode 100644 index 0000000000000..818741af4db1a --- /dev/null +++ b/public/app/percona/inventory/Tabs/Services/Services.constants.ts @@ -0,0 +1,40 @@ +import { ALL_LABEL, ALL_VALUE } from 'app/percona/shared/components/Elements/Table/Filter/Filter.constants'; +import { ServiceStatus } from 'app/percona/shared/services/services/Services.types'; + +import { MonitoringStatus } from '../../Inventory.types'; + +export const ALL_OPTION = { value: ALL_VALUE, label: ALL_LABEL }; + +export const STATUS_OPTIONS = [ + { + label: 'Up', + value: ServiceStatus.UP, + }, + { + label: 'Down', + value: ServiceStatus.DOWN, + }, + { + label: 'Unknown', + value: ServiceStatus.UNKNOWN, + }, + { + label: 'N/A', + value: ServiceStatus.NA, + }, +]; + +export const STATUS_OPTIONS_WITH_ALL = [ALL_OPTION, ...STATUS_OPTIONS]; + +export const MONITORING_OPTIONS = [ + { + label: MonitoringStatus.OK, + value: MonitoringStatus.OK, + }, + { + label: MonitoringStatus.FAILED, + value: MonitoringStatus.FAILED, + }, +]; + +export const MONITORING_OPTIONS_WITH_ALL = [ALL_OPTION, ...MONITORING_OPTIONS]; diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx b/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx index b4807b0463120..5d58aeed2a600 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.tsx @@ -17,17 +17,22 @@ import { buildEmptyValues, buildParamsFromKey, buildSearchOptions, + getFilteredData, getQueryParams, - isInOptions, isOtherThanTextType, - isValueInTextColumn, } from './Filter.utils'; import { RadioButtonField } from './components/fields/RadioButtonField'; import { SearchTextField } from './components/fields/SearchTextField'; import { SelectColumnField } from './components/fields/SelectColumnField'; import { SelectDropdownField } from './components/fields/SelectDropdownField'; -export const Filter = ({ columns, rawData, setFilteredData, hasBackendFiltering = false, tableKey }: FilterProps) => { +export const Filter = ({ + columns, + rawData, + setFilteredData, + hasBackendFiltering = false, + tableKey, +}: FilterProps) => { const [openCollapse, setOpenCollapse] = useState(false); const [openSearchFields, setOpenSearchFields] = useState(false); const styles = useStyles2(getStyles); @@ -89,12 +94,7 @@ export const Filter = ({ columns, rawData, setFilteredData, hasBackendFiltering useEffect(() => { const queryParamsObj = getQueryParams(columns, queryParamsByKey); if (Object.keys(queryParamsByKey).length > 0 && !hasBackendFiltering) { - const dataArray = rawData.filter( - (filterValue) => - isValueInTextColumn(columns, filterValue, queryParamsObj) && - isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.DROPDOWN) && - isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.RADIO_BUTTON) - ); + const dataArray = getFilteredData(rawData, columns, queryParamsObj); setFilteredData(dataArray); } else { setFilteredData(rawData); diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.types.ts b/public/app/percona/shared/components/Elements/Table/Filter/Filter.types.ts index 360ee455d6206..b28cbd1369918 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.types.ts +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.types.ts @@ -2,10 +2,14 @@ import { ExtendedColumn } from '../Table.types'; -export interface FilterProps { +export interface FilterProps { columns: Array>; - rawData: Object[]; - setFilteredData: (data: Object[]) => void; - hasBackendFiltering: boolean; + rawData: T[]; + setFilteredData: (data: T[]) => void; + hasBackendFiltering?: boolean; tableKey?: string; + onFilterStateChange?: (isActive: boolean) => void; } + +// prevent additional usage of "any" +export type FilterFormValues = Record; diff --git a/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts b/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts index 8efbe9b682ae3..468177463a43b 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts +++ b/public/app/percona/shared/components/Elements/Table/Filter/Filter.utils.ts @@ -1,12 +1,12 @@ -/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions */ import { UrlQueryMap, UrlQueryValue } from '@grafana/data'; import { getValuesFromQueryParams } from 'app/percona/shared/helpers/getValuesFromQueryParams'; import { ExtendedColumn, FilterFieldTypes } from '..'; import { ALL_LABEL, ALL_VALUE, SEARCH_INPUT_FIELD_NAME, SEARCH_SELECT_FIELD_NAME } from './Filter.constants'; +import { FilterFormValues } from './Filter.types'; -export const getQueryParams = (columns: Array>, queryParams: UrlQueryMap) => { +export const getQueryParams = (columns: Array>, queryParams: UrlQueryMap) => { const customTransform = (params: UrlQueryValue): string | undefined => { if (params !== undefined && params !== null) { return params.toString(); @@ -20,8 +20,11 @@ export const getQueryParams = (columns: Array>, queryParams: return params ?? {}; }; -export const buildObjForQueryParams = (columns: Array>, values: Record) => { - let obj: Record = { +export const buildObjForQueryParams = ( + columns: Array>, + values: FilterFormValues +) => { + let obj: FilterFormValues = { [SEARCH_INPUT_FIELD_NAME]: values[SEARCH_INPUT_FIELD_NAME], [SEARCH_SELECT_FIELD_NAME]: values[SEARCH_SELECT_FIELD_NAME]?.value ?? values[SEARCH_SELECT_FIELD_NAME], }; @@ -48,10 +51,10 @@ export const buildObjForQueryParams = (columns: Array>, valu return obj; }; -export const buildParamsFromKey = ( +export const buildParamsFromKey = ( tableKey: string | undefined, - columns: Array>, - values: Record + columns: Array>, + values: FilterFormValues ) => { const params = buildObjForQueryParams(columns, values); if (tableKey) { @@ -64,7 +67,7 @@ export const buildParamsFromKey = ( return params; }; -export const buildSearchOptions = (columns: Array>) => { +export const buildSearchOptions = (columns: Array>) => { const searchOptions = columns .filter((value) => value.type === FilterFieldTypes.TEXT) .map((column) => ({ @@ -75,7 +78,7 @@ export const buildSearchOptions = (columns: Array>) => { return searchOptions; }; -export const buildEmptyValues = (columns: Array>) => { +export const buildEmptyValues = (columns: Array>) => { let obj = { [SEARCH_INPUT_FIELD_NAME]: undefined, [SEARCH_SELECT_FIELD_NAME]: ALL_VALUE, @@ -88,10 +91,10 @@ export const buildEmptyValues = (columns: Array>) => { return obj; }; -export const isValueInTextColumn = ( - columns: Array>, - filterValue: any, - queryParamsObj: { [key: keyof UrlQueryMap]: string } +export const isValueInTextColumn = ( + columns: Array>, + filterValue: T, + queryParamsObj: Record ) => { const searchInputValue = queryParamsObj[SEARCH_INPUT_FIELD_NAME]; const selectColumnValue = queryParamsObj[SEARCH_SELECT_FIELD_NAME]; @@ -101,7 +104,7 @@ export const isValueInTextColumn = ( if (searchInputValue) { if ( (column.accessor === selectColumnValue || selectColumnValue === ALL_VALUE) && - isTextIncluded(searchInputValue, filterValue[column.accessor as string]) + isTextIncluded(searchInputValue, filterValue[column.accessor as keyof T] as string | number) ) { result = true; } @@ -114,20 +117,20 @@ export const isValueInTextColumn = ( }; export const isTextIncluded = (needle: string, haystack: string | number): boolean => - haystack.toString().toLowerCase().includes(needle.toLowerCase()); + haystack?.toString().toLowerCase().includes(needle.toLowerCase()); -export const isInOptions = ( - columns: Array>, - filterValue: any, - queryParamsObj: { [key: keyof UrlQueryMap]: string }, +export const isInOptions = ( + columns: Array>, + filterValue: T, + queryParamsObj: Record, filterFieldType: FilterFieldTypes ) => { let result: boolean[] = []; columns.forEach((column) => { - const accessor = column.accessor as string; - const queryParamValueAccessor = queryParamsObj[accessor]; - const filterValueAccessor = filterValue[accessor]; + const accessor = column.accessor; + const queryParamValueAccessor = queryParamsObj[accessor as string]; + const filterValueAccessor = filterValue[accessor as keyof T]; if (column.type === filterFieldType) { if (queryParamValueAccessor) { if (queryParamValueAccessor.toLowerCase() === filterValueAccessor?.toString().toLowerCase()) { @@ -143,15 +146,22 @@ export const isInOptions = ( return result.every((value) => value); }; -export const isOtherThanTextType = (columns: Array>) => { - return columns.find((column) => { - return column.type !== undefined && column.type !== FilterFieldTypes.TEXT; - }) - ? true - : false; -}; +export const isOtherThanTextType = (columns: Array>): boolean => + columns.some((column) => column.type !== undefined && column.type !== FilterFieldTypes.TEXT); -export const buildColumnOptions = (column: ExtendedColumn) => { +export const buildColumnOptions = (column: ExtendedColumn) => { column.options = column.options?.map((option) => ({ ...option, value: option.value?.toString() })); return [{ value: ALL_VALUE, label: ALL_LABEL }, ...(column.options ?? [])]; }; + +export const getFilteredData = ( + rawData: T[], + columns: Array>, + queryParamsObj: Record +) => + rawData.filter( + (filterValue) => + isValueInTextColumn(columns, filterValue, queryParamsObj) && + isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.DROPDOWN) && + isInOptions(columns, filterValue, queryParamsObj, FilterFieldTypes.RADIO_BUTTON) + ); diff --git a/public/app/percona/shared/components/Elements/Table/Filter/components/fields/SelectDropdownField.tsx b/public/app/percona/shared/components/Elements/Table/Filter/components/fields/SelectDropdownField.tsx index e4ebda96dca81..9beecf5873fec 100644 --- a/public/app/percona/shared/components/Elements/Table/Filter/components/fields/SelectDropdownField.tsx +++ b/public/app/percona/shared/components/Elements/Table/Filter/components/fields/SelectDropdownField.tsx @@ -7,11 +7,11 @@ import { ExtendedColumn } from '../../..'; import { ALL_LABEL, ALL_VALUE } from '../../Filter.constants'; import { buildColumnOptions } from '../../Filter.utils'; -export const SelectDropdownField = ({ column }: { column: ExtendedColumn }) => { +export const SelectDropdownField = ({ column }: { column: ExtendedColumn }) => { const columnOptions = buildColumnOptions(column); return (
- + {({ input }) => ( { tr { height: 48px; + /* Allow the actions dialog to be visible */ + position: relative; + z-index: 0; + th { position: sticky; top: 0; diff --git a/public/app/percona/shared/components/Form/TextInput/TextInput.styles.ts b/public/app/percona/shared/components/Form/TextInput/TextInput.styles.ts index 766e2fadfe893..fc6d518dfd7f1 100644 --- a/public/app/percona/shared/components/Form/TextInput/TextInput.styles.ts +++ b/public/app/percona/shared/components/Form/TextInput/TextInput.styles.ts @@ -3,7 +3,7 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -export const getStyles = ({ v1 }: GrafanaTheme2) => { +export const getStyles = ({ v1, ...theme }: GrafanaTheme2) => { const { border, colors, isDark, palette, spacing, typography } = v1; const focusBoxShadow = isDark @@ -66,6 +66,22 @@ export const getStyles = ({ v1 }: GrafanaTheme2) => { padding: ${spacing.formLabelPadding}; color: ${colors.formLabel}; `, + inputContainer: css` + position: relative; + `, + iconContainer: css` + position: absolute; + left: ${theme.spacing(1)}; + top: 0; + bottom: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + `, + icon: css` + color: ${theme.colors.text.secondary}; + `, input: css` background-color: ${colors.formInputBg}; line-height: ${typography.lineHeight.md}; @@ -108,5 +124,22 @@ export const getStyles = ({ v1 }: GrafanaTheme2) => { transition: all 0.2s cubic-bezier(0.19, 1, 0.22, 1) 0s; } `, + inputWithIcon: css` + /* padding + icon */ + padding-left: calc(23px + ${theme.spacing(1)}); + `, + inputClearable: css` + padding-right: 70px; + `, + clearContainer: css` + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + `, + clearBtn: css``, }; }; diff --git a/public/app/percona/shared/components/Form/TextInput/TextInputField.tsx b/public/app/percona/shared/components/Form/TextInput/TextInputField.tsx index 55ac793e192e2..6d4bf9bdd9461 100644 --- a/public/app/percona/shared/components/Form/TextInput/TextInputField.tsx +++ b/public/app/percona/shared/components/Form/TextInput/TextInputField.tsx @@ -2,7 +2,7 @@ import { cx } from '@emotion/css'; import React, { FC, useMemo } from 'react'; import { Field, FieldInputProps, FieldMetaState, UseFieldConfig } from 'react-final-form'; -import { useStyles2 } from '@grafana/ui'; +import { Button, Icon, IconName, useStyles2 } from '@grafana/ui'; import { compose, Validator } from 'app/percona/shared/helpers/validatorsForm'; import { FieldInputAttrs, LabeledFieldProps } from '../../../helpers/types'; @@ -23,6 +23,8 @@ export interface TextInputFieldProps extends UseFieldConfig, LabeledFiel showErrorOnBlur?: boolean; showErrorOnRender?: boolean; validators?: Validator[]; + placeholderIcon?: IconName; + clearable?: boolean; } interface TextFieldRenderProps { @@ -51,6 +53,8 @@ export const TextInputField: FC = React.memo( tooltipDataTestId, tooltipLinkTarget, tooltipInteractive, + placeholderIcon, + clearable, ...fieldConfig }) => { const styles = useStyles2(getStyles); @@ -77,15 +81,35 @@ export const TextInputField: FC = React.memo( tooltipIcon={tooltipIcon} tooltipInteractive={tooltipInteractive} /> - +
+ {!!placeholderIcon && ( +
+ +
+ )} + + {clearable && !!input.value && ( +
+ +
+ )} +
{validationError}
diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.messages.ts b/public/app/percona/shared/components/SearchFilter/SearchFilter.messages.ts new file mode 100644 index 0000000000000..6687426e52752 --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.messages.ts @@ -0,0 +1,6 @@ +export const Messages = { + search: { + label: 'Search', + placeholder: 'Search to filter', + }, +}; diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.styles.ts b/public/app/percona/shared/components/SearchFilter/SearchFilter.styles.ts new file mode 100644 index 0000000000000..6d61f71f13b5c --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.styles.ts @@ -0,0 +1,25 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getStyles = (theme: GrafanaTheme2) => ({ + container: css` + display: flex; + flex-direction: row; + gap: ${theme.spacing(1)}; + justify-content: space-between; + align-items: center; + margin-bottom: -${theme.spacing(2)}; + `, + filtersContainer: css` + display: flex; + flex-direction: row; + gap: ${theme.spacing(1)}; + `, + filter: css` + width: 150px; + `, + searchBar: css` + width: 50%; + `, +}); diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.tsx b/public/app/percona/shared/components/SearchFilter/SearchFilter.tsx new file mode 100644 index 0000000000000..a919abc6ca0f4 --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.tsx @@ -0,0 +1,101 @@ +import { FormState } from 'final-form'; +import { debounce } from 'lodash'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { Form, FormSpy } from 'react-final-form'; + +import { useStyles2 } from '@grafana/ui'; + +import { ExtendedColumn } from '../Elements/Table'; +import { DEBOUNCE_DELAY, SEARCH_INPUT_FIELD_NAME } from '../Elements/Table/Filter/Filter.constants'; +import { getFilteredData, getQueryParams } from '../Elements/Table/Filter/Filter.utils'; +import { SelectDropdownField } from '../Elements/Table/Filter/components/fields/SelectDropdownField'; +import { TextInputField } from '../Form/TextInput'; + +import { Messages } from './SearchFilter.messages'; +import { getStyles } from './SearchFilter.styles'; +import { QueryParamsValues } from './SearchFilter.types'; +import { getFilterColumns, useQueryParamsByKey } from './SearchFilter.utils'; + +export interface SearchFilterProps { + rawData: T[]; + onFilteredDataChange: (data: T[]) => void; + columns: Array>; + tableKey?: string; + hasBackendFiltering?: boolean; +} + +const SearchFilter = ({ + columns, + rawData, + onFilteredDataChange, + tableKey, + hasBackendFiltering, +}: SearchFilterProps) => { + const filterColumns = useMemo(() => getFilterColumns(columns), [columns]); + const styles = useStyles2(getStyles); + const [queryParamsByKey, setQueryParamsByKey] = useQueryParamsByKey(tableKey); + const initialValues = useMemo( + () => getQueryParams(columns, queryParamsByKey), + [columns, queryParamsByKey] + ); + + useEffect(() => { + const queryParamsObj = getQueryParams(columns, queryParamsByKey); + + if (Object.keys(queryParamsByKey).length > 0 && !hasBackendFiltering) { + const dataArray = getFilteredData(rawData, columns, queryParamsObj); + onFilteredDataChange(dataArray); + } else { + onFilteredDataChange(rawData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [queryParamsByKey, rawData]); + + const onSubmit = useCallback( + (values: QueryParamsValues) => { + setQueryParamsByKey(columns, values); + }, + [columns, setQueryParamsByKey] + ); + + const handleFormValuesChange = debounce((state: FormState) => { + onSubmit(state.values); + }, DEBOUNCE_DELAY); + + return ( +
( +
+ +
+ {filterColumns.map((col) => ( +
+ +
+ ))} +
+ {!hasBackendFiltering && ( + + )} +
+ )} + /> + ); +}; + +export default SearchFilter; diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.types.ts b/public/app/percona/shared/components/SearchFilter/SearchFilter.types.ts new file mode 100644 index 0000000000000..afc739dcb1188 --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.types.ts @@ -0,0 +1,2 @@ +// prevent additional use of any +export type QueryParamsValues = Record; diff --git a/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts b/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts new file mode 100644 index 0000000000000..bf836ebd3a857 --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/SearchFilter.utils.ts @@ -0,0 +1,39 @@ +import { useCallback, useMemo } from 'react'; + +import { useQueryParams } from 'app/core/hooks/useQueryParams'; + +import { ExtendedColumn, FilterFieldTypes } from '../Elements/Table'; +import { buildParamsFromKey } from '../Elements/Table/Filter/Filter.utils'; + +import { QueryParamsValues } from './SearchFilter.types'; + +export const getFilterColumns = (columns: Array>): Array> => + columns.filter((col) => col.type === FilterFieldTypes.DROPDOWN || col.type === FilterFieldTypes.RADIO_BUTTON); + +export const useQueryParamsByKey = (tableKey?: string) => { + const [queryParams, setQueryParams] = useQueryParams(); + const queryParamsByKey = useMemo(() => { + if (tableKey) { + const params = queryParams[tableKey]; + + if (params) { + // @ts-ignore + const paramsObj = JSON.parse(params); + return paramsObj; + } else { + return {}; + } + } + return queryParams; + }, [queryParams, tableKey]); + + const setQueryParamsByKey = useCallback( + (columns: Array>, values: QueryParamsValues) => { + const params = buildParamsFromKey(tableKey, columns, values); + setQueryParams(params); + }, + [setQueryParams, tableKey] + ); + + return [queryParamsByKey, setQueryParamsByKey]; +}; diff --git a/public/app/percona/shared/components/SearchFilter/index.ts b/public/app/percona/shared/components/SearchFilter/index.ts new file mode 100644 index 0000000000000..2088cd94ceb4d --- /dev/null +++ b/public/app/percona/shared/components/SearchFilter/index.ts @@ -0,0 +1 @@ +export { default as SearchFilter } from './SearchFilter';