feat: dashboard search and filter (#1005)

* feat: enable search and filter in dashboards
This commit is contained in:
Pranshu Chittora 2022-04-22 18:57:05 +05:30 committed by GitHub
parent 2b5b79e34a
commit 182adc551c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 632 additions and 43 deletions

View File

@ -1,2 +1,3 @@
node_modules node_modules
build build
*.typegen.ts

View File

@ -31,6 +31,7 @@
"@testing-library/react": "^11.1.0", "@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10", "@testing-library/user-event": "^12.1.10",
"@welldone-software/why-did-you-render": "^6.2.1", "@welldone-software/why-did-you-render": "^6.2.1",
"@xstate/react": "^3.0.0",
"antd": "4.19.2", "antd": "4.19.2",
"axios": "^0.21.0", "axios": "^0.21.0",
"babel-eslint": "^10.1.0", "babel-eslint": "^10.1.0",
@ -58,6 +59,7 @@
"i18next-browser-languagedetector": "^6.1.3", "i18next-browser-languagedetector": "^6.1.3",
"i18next-http-backend": "^1.3.2", "i18next-http-backend": "^1.3.2",
"jest": "^27.5.1", "jest": "^27.5.1",
"js-base64": "^3.7.2",
"less": "^4.1.2", "less": "^4.1.2",
"less-loader": "^10.2.0", "less-loader": "^10.2.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
@ -85,7 +87,8 @@
"uuid": "^8.3.2", "uuid": "^8.3.2",
"web-vitals": "^0.2.4", "web-vitals": "^0.2.4",
"webpack": "^5.23.0", "webpack": "^5.23.0",
"webpack-dev-server": "^4.3.1" "webpack-dev-server": "^4.3.1",
"xstate": "^4.31.0"
}, },
"browserslist": { "browserslist": {
"production": [ "production": [

View File

@ -0,0 +1,50 @@
import { createMachine } from 'xstate';
export const DashboardSearchAndFilter = createMachine({
tsTypes: {} as import('./Dashboard.machine.typegen').Typegen0,
initial: 'Idle',
states: {
Category: {
on: {
NEXT: {
actions: 'onSelectOperator',
target: 'Operator',
},
onBlur: {
actions: 'onBlurPurge',
target: 'Idle',
},
},
},
Operator: {
on: {
NEXT: {
actions: 'onSelectValue',
target: 'Value',
},
onBlur: {
actions: 'onBlurPurge',
target: 'Idle',
},
},
},
Value: {
on: {
onBlur: {
actions: ['onValidateQuery', 'onBlurPurge'],
target: 'Idle',
},
},
},
Idle: {
on: {
NEXT: {
actions: 'onSelectCategory',
description: 'Select Category',
target: 'Category',
},
},
},
},
id: 'Dashboard Search And Filter',
});

View File

@ -0,0 +1,32 @@
// This file was automatically generated. Edits will be overwritten
export interface Typegen0 {
'@@xstate/typegen': true;
eventsCausingActions: {
onSelectOperator: 'NEXT';
onBlurPurge: 'onBlur';
onSelectValue: 'NEXT';
onValidateQuery: 'onBlur';
onSelectCategory: 'NEXT';
};
internalEvents: {
'xstate.init': { type: 'xstate.init' };
};
invokeSrcNameMap: {};
missingImplementations: {
actions:
| 'onSelectOperator'
| 'onBlurPurge'
| 'onSelectValue'
| 'onValidateQuery'
| 'onSelectCategory';
services: never;
guards: never;
delays: never;
};
eventsCausingServices: {};
eventsCausingGuards: {};
eventsCausingDelays: {};
matchesStates: 'Category' | 'Operator' | 'Value' | 'Idle';
tags: never;
}

View File

@ -0,0 +1,23 @@
import React from 'react';
import { QueryChipContainer, QueryChipItem } from './styles';
import { IQueryStructure } from './types';
export default function QueryChip({
queryData,
onRemove,
}: {
queryData: IQueryStructure;
onRemove: (id: string) => void;
}): JSX.Element {
const { category, operator, value, id } = queryData;
return (
<QueryChipContainer>
<QueryChipItem>{category}</QueryChipItem>
<QueryChipItem>{operator}</QueryChipItem>
<QueryChipItem closable onClose={(): void => onRemove(id)}>
{Array.isArray(value) ? value.join(', ') : null}
</QueryChipItem>
</QueryChipContainer>
);
}

View File

@ -0,0 +1,216 @@
import { CloseCircleFilled } from '@ant-design/icons';
import { useMachine } from '@xstate/react';
import { Button, Select } from 'antd';
import { RefSelectProps } from 'antd/lib/select';
import history from 'lib/history';
import { filter, map } from 'lodash-es';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { Dashboard } from 'types/api/dashboard/getAll';
import AppReducer from 'types/reducer/app';
import { v4 as uuidv4 } from 'uuid';
import { DashboardSearchAndFilter } from './Dashboard.machine';
import QueryChip from './QueryChip';
import { QueryChipItem, SearchContainer } from './styles';
import { IOptionsData, IQueryStructure, TCategory, TOperator } from './types';
import {
convertQueriesToURLQuery,
convertURLQueryStringToQuery,
executeSearchQueries,
OptionsSchemas,
OptionsValueResolution,
} from './utils';
function SearchFilter({
searchData,
filterDashboards,
}: {
searchData: Dashboard[];
filterDashboards: (filteredDashboards: Dashboard[]) => void;
}): JSX.Element {
const { isDarkMode } = useSelector<AppState, AppReducer>((state) => state.app);
const [category, setCategory] = useState<TCategory>();
const [optionsData, setOptionsData] = useState<IOptionsData>(
OptionsSchemas.attribute,
);
const selectRef = useRef() as React.MutableRefObject<RefSelectProps>;
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const [staging, setStaging] = useState<string[] | string[][] | unknown[]>([]);
const [queries, setQueries] = useState<IQueryStructure[]>([]);
useEffect(() => {
const searchQueryString = new URLSearchParams(history.location.search).get(
'search',
);
if (searchQueryString)
setQueries(convertURLQueryStringToQuery(searchQueryString) || []);
}, []);
useEffect(() => {
filterDashboards(executeSearchQueries(queries, searchData));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queries, searchData]);
const updateURLWithQuery = useCallback(
(inputQueries?: IQueryStructure[]): void => {
history.push({
pathname: history.location.pathname,
search:
inputQueries || queries
? `?search=${convertQueriesToURLQuery(inputQueries || queries)}`
: '',
});
},
[queries],
);
useEffect(() => {
if (Array.isArray(queries) && queries.length > 0) {
updateURLWithQuery();
}
}, [queries, updateURLWithQuery]);
const [state, send] = useMachine(DashboardSearchAndFilter, {
actions: {
onSelectCategory: () => {
setOptionsData(OptionsSchemas.attribute);
},
onSelectOperator: () => {
setOptionsData(OptionsSchemas.operator);
},
onSelectValue: () => {
setOptionsData(
OptionsValueResolution(category as TCategory, searchData) as IOptionsData,
);
},
onBlurPurge: () => {
setSelectedValues([]);
setStaging([]);
},
onValidateQuery: () => {
if (staging.length <= 2 && selectedValues.length === 0) {
return;
}
setQueries([
...queries,
{
id: uuidv4(),
category: staging[0] as string,
operator: staging[1] as TOperator,
value: selectedValues,
},
]);
},
},
});
const nextState = (): void => {
send('NEXT');
};
const removeQueryById = (queryId: string): void => {
setQueries((queries) => {
const updatedQueries = filter(queries, ({ id }) => id !== queryId);
updateURLWithQuery(updatedQueries);
return updatedQueries;
});
};
const handleChange = (value: never | string[]): void => {
if (!value) {
return;
}
if (optionsData.mode) {
setSelectedValues(value.filter(Boolean));
return;
}
setStaging([...staging, value]);
if (state.value === 'Category') {
setCategory(`${value}`.toLowerCase() as TCategory);
}
nextState();
setSelectedValues([]);
};
const handleFocus = (): void => {
if (state.value === 'Idle') {
send('NEXT');
selectRef.current?.focus();
}
};
const handleBlur = (): void => {
send('onBlur');
selectRef?.current?.blur();
};
const clearQueries = (): void => {
setQueries([]);
history.push({
pathname: history.location.pathname,
search: ``,
});
};
return (
<SearchContainer isDarkMode={isDarkMode}>
<div
style={{
maxWidth: '70%',
display: 'flex',
overflowX: 'auto',
}}
>
{map(queries, (query) => (
<QueryChip key={query.id} queryData={query} onRemove={removeQueryById} />
))}
{map(staging, (value) => (
<QueryChipItem key={JSON.stringify(value)}>
{value as string}
</QueryChipItem>
))}
</div>
{optionsData && (
<Select
placeholder={
!queries.length &&
!staging.length &&
!selectedValues.length &&
'Search or Filter results'
}
size="small"
ref={selectRef}
mode={optionsData.mode as 'tags' | 'multiple'}
style={{ flex: 1 }}
onChange={handleChange}
bordered={false}
suffixIcon={null}
value={selectedValues}
onFocus={handleFocus}
onBlur={handleBlur}
>
{optionsData.options &&
Array.isArray(optionsData.options) &&
optionsData.options.map(
(optionItem): JSX.Element => {
return (
<Select.Option
key={(optionItem.value as string) || (optionItem.name as string)}
value={optionItem.value || optionItem.name}
>
{optionItem.name}
</Select.Option>
);
},
)}
</Select>
)}
{queries && queries.length > 0 && (
<Button icon={<CloseCircleFilled />} type="text" onClick={clearQueries} />
)}
</SearchContainer>
);
}
export default SearchFilter;

View File

@ -0,0 +1,30 @@
import { grey } from '@ant-design/colors';
import { Tag } from 'antd';
import styled from 'styled-components';
export const SearchContainer = styled.div<{
isDarkMode: boolean;
}>`
background: ${({ isDarkMode }): string => (isDarkMode ? '#000' : '#fff')};
width: 100%;
display: flex;
align-items: center;
gap: 0.2rem;
padding: 0.2rem 0;
margin: 1rem 0;
border: 1px solid #ccc5;
`;
export const QueryChipContainer = styled.span`
display: flex;
align-items: center;
margin-right: 0.5rem;
&:hover {
& > * {
background: ${grey.primary}44;
}
}
`;
export const QueryChipItem = styled(Tag)`
margin-right: 0.1rem;
`;

View File

@ -0,0 +1,18 @@
export type TOperator = '=' | '!=';
export type TCategory = 'title' | 'description' | 'tags';
export interface IQueryStructure {
category: string;
id: string;
operator: TOperator;
value: string | string[];
}
interface IOptions {
name: string;
value?: string;
}
export interface IOptionsData {
mode: undefined | 'tags' | 'multiple';
options: IOptions[] | [];
}

View File

@ -0,0 +1,152 @@
/* eslint-disable no-param-reassign */
/* eslint-disable no-restricted-syntax */
/* eslint-disable sonarjs/cognitive-complexity */
import { decode, encode } from 'js-base64';
import { flattenDeep, map, uniqWith } from 'lodash-es';
import { Dashboard } from 'types/api/dashboard/getAll';
import { IOptionsData, IQueryStructure, TCategory, TOperator } from './types';
export const convertQueriesToURLQuery = (
queries: IQueryStructure[],
): string => {
if (!queries || !queries.length) {
return '';
}
return encode(JSON.stringify(queries));
};
export const convertURLQueryStringToQuery = (
queryString: string,
): IQueryStructure[] => {
return JSON.parse(decode(queryString));
};
export const resolveOperator = (
result: unknown,
operator: TOperator,
): boolean => {
if (operator === '!=') {
return !result;
}
if (operator === '=') {
return !!result;
}
return !!result;
};
export const executeSearchQueries = (
queries: IQueryStructure[] = [],
searchData: Dashboard[] = [],
): Dashboard[] => {
if (!searchData.length || !queries.length) {
return searchData;
}
queries.forEach((query: IQueryStructure) => {
const { operator } = query;
let { value } = query;
const categoryLowercase: TCategory = `${query.category}`.toLowerCase() as
| 'title'
| 'description';
value = flattenDeep([value]);
searchData = searchData.filter(({ data: searchPayload }: Dashboard) => {
try {
const searchSpace =
flattenDeep([searchPayload[categoryLowercase]]).filter(Boolean) || null;
if (!searchSpace || !searchSpace.length)
return resolveOperator(false, operator);
for (const searchSpaceItem of searchSpace) {
if (searchSpaceItem)
for (const queryValue of value) {
if (searchSpaceItem.match(queryValue)) {
return resolveOperator(true, operator);
}
}
}
} catch (error) {
console.error(error);
}
return resolveOperator(false, operator);
});
});
return searchData;
};
export const OptionsSchemas = {
attribute: {
mode: undefined,
options: [
{
name: 'Title',
},
{
name: 'Description',
},
{
name: 'Tags',
},
],
},
operator: {
mode: undefined,
options: [
{
value: '=',
name: 'Equal',
},
{
name: 'Not Equal',
value: '!=',
},
],
},
};
export function OptionsValueResolution(
category: TCategory,
searchData: Dashboard[],
): Record<string, unknown> | IOptionsData {
const OptionsValueSchema = {
title: {
mode: 'tags',
options: uniqWith(
map(searchData, (searchItem) => ({ name: searchItem.data.title })),
(prev, next) => prev.name === next.name,
),
},
description: {
mode: 'tags',
options: uniqWith(
map(searchData, (searchItem) =>
searchItem.data.description
? {
name: searchItem.data.description,
value: searchItem.data.description,
}
: null,
).filter(Boolean),
(prev, next) => prev?.name === next?.name,
),
},
tags: {
mode: 'tags',
options: uniqWith(
map(
flattenDeep(
map(searchData, (searchItem) => searchItem.data.tags).filter(Boolean),
),
(tag) => ({ name: tag }),
),
(prev, next) => prev.name === next.name,
),
},
};
return (
OptionsValueSchema[category] ||
({ mode: undefined, options: [] } as IOptionsData)
);
}

View File

@ -0,0 +1,24 @@
import { Dashboard } from 'types/api/dashboard/getAll';
interface IDashboardSearchData {
title: string;
description: string | undefined;
tags: string[];
id: string;
}
export const generateSearchData = (
dashboards: Dashboard[],
): IDashboardSearchData[] => {
const dashboardSearchData: IDashboardSearchData[] = [];
dashboards.forEach((dashboard) => {
dashboardSearchData.push({
id: dashboard.uuid,
title: dashboard.data.title,
description: dashboard.data.description,
tags: dashboard.data.tags || [],
});
});
return dashboardSearchData;
};

View File

@ -1,15 +1,17 @@
/* eslint-disable react/no-unstable-nested-components */ /* eslint-disable react/no-unstable-nested-components */
import { PlusOutlined } from '@ant-design/icons'; import { PlusOutlined } from '@ant-design/icons';
import { Row, Table, TableColumnProps, Typography } from 'antd'; import { Card, Row, Table, TableColumnProps, Typography } from 'antd';
import createDashboard from 'api/dashboard/create'; import createDashboard from 'api/dashboard/create';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import TextToolTip from 'components/TextToolTip'; import TextToolTip from 'components/TextToolTip';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import SearchFilter from 'container/ListOfDashboard/SearchFilter';
import history from 'lib/history'; import history from 'lib/history';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { generatePath } from 'react-router-dom'; import { generatePath } from 'react-router-dom';
import { AppState } from 'store/reducers'; import { AppState } from 'store/reducers';
import { Dashboard } from 'types/api/dashboard/getAll';
import DashboardReducer from 'types/reducer/dashboards'; import DashboardReducer from 'types/reducer/dashboards';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
@ -25,6 +27,11 @@ function ListOfAllDashboard(): JSX.Element {
(state) => state.dashboards, (state) => state.dashboards,
); );
const [filteredDashboards, setFilteredDashboards] = useState<Dashboard[]>();
useEffect(() => {
setFilteredDashboards(dashboards);
}, [dashboards]);
const [newDashboardState, setNewDashboardState] = useState({ const [newDashboardState, setNewDashboardState] = useState({
loading: false, loading: false,
error: false, error: false,
@ -76,7 +83,7 @@ function ListOfAllDashboard(): JSX.Element {
}, },
]; ];
const data: Data[] = dashboards.map((e) => ({ const data: Data[] = (filteredDashboards || dashboards).map((e) => ({
createdBy: e.created_at, createdBy: e.created_at,
description: e.data.description || '', description: e.data.description || '',
id: e.uuid, id: e.uuid,
@ -138,47 +145,52 @@ function ListOfAllDashboard(): JSX.Element {
}; };
return ( return (
<TableContainer> <Card>
<Table <Row justify="space-between">
pagination={{ <Typography>Dashboard List</Typography>
pageSize: 9,
defaultPageSize: 9,
}}
showHeader
bordered
sticky
loading={loading}
title={(): JSX.Element => {
return (
<Row justify="space-between">
<Typography>Dashboard List</Typography>
<ButtonContainer> <ButtonContainer>
<TextToolTip <TextToolTip
{...{ {...{
text: `More details on how to create dashboards`, text: `More details on how to create dashboards`,
url: 'https://signoz.io/docs/userguide/dashboards', url: 'https://signoz.io/docs/userguide/dashboards',
}} }}
/> />
<NewDashboardButton <NewDashboardButton
onClick={onNewDashboardHandler} onClick={onNewDashboardHandler}
icon={<PlusOutlined />} icon={<PlusOutlined />}
type="primary" type="primary"
loading={newDashboardState.loading} loading={newDashboardState.loading}
danger={newDashboardState.error} danger={newDashboardState.error}
> >
{getText()} {getText()}
</NewDashboardButton> </NewDashboardButton>
</ButtonContainer> </ButtonContainer>
</Row> </Row>
); {!loading && (
}} <SearchFilter
columns={columns} searchData={dashboards}
dataSource={data} filterDashboards={setFilteredDashboards}
showSorterTooltip />
/> )}
</TableContainer>
<TableContainer>
<Table
pagination={{
pageSize: 9,
defaultPageSize: 9,
}}
showHeader
bordered
sticky
loading={loading}
columns={columns}
dataSource={data}
showSorterTooltip
/>
</TableContainer>
</Card>
); );
} }

View File

@ -2708,6 +2708,14 @@
resolved "https://registry.yarnpkg.com/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d" resolved "https://registry.yarnpkg.com/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d"
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ== integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
"@xstate/react@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@xstate/react/-/react-3.0.0.tgz#888d9a6f128c70b632c18ad55f1f851f6ab092ba"
integrity sha512-KHSCfwtb8gZ7QH2luihvmKYI+0lcdHQOmGNRUxUEs4zVgaJCyd8csCEmwPsudpliLdUmyxX2pzUBojFkINpotw==
dependencies:
use-isomorphic-layout-effect "^1.0.0"
use-sync-external-store "^1.0.0"
"@xtuc/ieee754@^1.2.0": "@xtuc/ieee754@^1.2.0":
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
@ -8012,6 +8020,11 @@ jest@^27.5.1:
import-local "^3.0.2" import-local "^3.0.2"
jest-cli "^27.5.1" jest-cli "^27.5.1"
js-base64@^3.7.2:
version "3.7.2"
resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.2.tgz#816d11d81a8aff241603d19ce5761e13e41d7745"
integrity sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==
js-cookie@^2.2.1: js-cookie@^2.2.1:
version "2.2.1" version "2.2.1"
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"
@ -12392,6 +12405,16 @@ url@^0.11.0:
punycode "1.3.2" punycode "1.3.2"
querystring "0.2.0" querystring "0.2.0"
use-isomorphic-layout-effect@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb"
integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==
use-sync-external-store@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.0.0.tgz#d98f4a9c2e73d0f958e7e2d2c2bfb5f618cbd8fd"
integrity sha512-AFVsxg5GkFg8GDcxnl+Z0lMAz9rE8DGJCc28qnBuQF7lac57B5smLcT37aXpXIIPz75rW4g3eXHPjhHwdGskOw==
use@^3.1.0: use@^3.1.0:
version "3.1.1" version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"
@ -12878,6 +12901,11 @@ xss@1.0.10:
commander "^2.20.3" commander "^2.20.3"
cssfilter "0.0.10" cssfilter "0.0.10"
xstate@^4.31.0:
version "4.31.0"
resolved "https://registry.yarnpkg.com/xstate/-/xstate-4.31.0.tgz#039cf6f865dd9e104012eb76a14df757c988ec58"
integrity sha512-UK5m6OqUsTlPuKWkfRR5cR9/Yt7sysFyEg+PVIbEH9mwHSf9zuCvWO7rRvhBq7T+3pEXLKTEMfaqmLxl9Ob1pw==
xtend@^4.0.0: xtend@^4.0.0:
version "4.0.2" version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"