From 182adc551cb916b1d5e39dd865f280d0391373d1 Mon Sep 17 00:00:00 2001 From: Pranshu Chittora Date: Fri, 22 Apr 2022 18:57:05 +0530 Subject: [PATCH] feat: dashboard search and filter (#1005) * feat: enable search and filter in dashboards --- frontend/.eslintignore | 1 + frontend/package.json | 5 +- .../SearchFilter/Dashboard.machine.tsx | 50 ++++ .../SearchFilter/Dashboard.machine.typegen.ts | 32 +++ .../SearchFilter/QueryChip.tsx | 23 ++ .../ListOfDashboard/SearchFilter/index.tsx | 216 ++++++++++++++++++ .../ListOfDashboard/SearchFilter/styles.ts | 30 +++ .../ListOfDashboard/SearchFilter/types.ts | 18 ++ .../ListOfDashboard/SearchFilter/utils.ts | 152 ++++++++++++ .../dashboardSearchAndFilter.ts | 24 ++ .../src/container/ListOfDashboard/index.tsx | 96 ++++---- frontend/yarn.lock | 28 +++ 12 files changed, 632 insertions(+), 43 deletions(-) create mode 100644 frontend/src/container/ListOfDashboard/SearchFilter/Dashboard.machine.tsx create mode 100644 frontend/src/container/ListOfDashboard/SearchFilter/Dashboard.machine.typegen.ts create mode 100644 frontend/src/container/ListOfDashboard/SearchFilter/QueryChip.tsx create mode 100644 frontend/src/container/ListOfDashboard/SearchFilter/index.tsx create mode 100644 frontend/src/container/ListOfDashboard/SearchFilter/styles.ts create mode 100644 frontend/src/container/ListOfDashboard/SearchFilter/types.ts create mode 100644 frontend/src/container/ListOfDashboard/SearchFilter/utils.ts create mode 100644 frontend/src/container/ListOfDashboard/dashboardSearchAndFilter.ts diff --git a/frontend/.eslintignore b/frontend/.eslintignore index dd87e2d73f..545037e39a 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1,2 +1,3 @@ node_modules build +*.typegen.ts diff --git a/frontend/package.json b/frontend/package.json index ed63737caf..dad3b0589e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,6 +31,7 @@ "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", "@welldone-software/why-did-you-render": "^6.2.1", + "@xstate/react": "^3.0.0", "antd": "4.19.2", "axios": "^0.21.0", "babel-eslint": "^10.1.0", @@ -58,6 +59,7 @@ "i18next-browser-languagedetector": "^6.1.3", "i18next-http-backend": "^1.3.2", "jest": "^27.5.1", + "js-base64": "^3.7.2", "less": "^4.1.2", "less-loader": "^10.2.0", "lodash-es": "^4.17.21", @@ -85,7 +87,8 @@ "uuid": "^8.3.2", "web-vitals": "^0.2.4", "webpack": "^5.23.0", - "webpack-dev-server": "^4.3.1" + "webpack-dev-server": "^4.3.1", + "xstate": "^4.31.0" }, "browserslist": { "production": [ diff --git a/frontend/src/container/ListOfDashboard/SearchFilter/Dashboard.machine.tsx b/frontend/src/container/ListOfDashboard/SearchFilter/Dashboard.machine.tsx new file mode 100644 index 0000000000..daeee9f023 --- /dev/null +++ b/frontend/src/container/ListOfDashboard/SearchFilter/Dashboard.machine.tsx @@ -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', +}); diff --git a/frontend/src/container/ListOfDashboard/SearchFilter/Dashboard.machine.typegen.ts b/frontend/src/container/ListOfDashboard/SearchFilter/Dashboard.machine.typegen.ts new file mode 100644 index 0000000000..50fdc449c3 --- /dev/null +++ b/frontend/src/container/ListOfDashboard/SearchFilter/Dashboard.machine.typegen.ts @@ -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; +} diff --git a/frontend/src/container/ListOfDashboard/SearchFilter/QueryChip.tsx b/frontend/src/container/ListOfDashboard/SearchFilter/QueryChip.tsx new file mode 100644 index 0000000000..04825b7a81 --- /dev/null +++ b/frontend/src/container/ListOfDashboard/SearchFilter/QueryChip.tsx @@ -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 ( + + {category} + {operator} + onRemove(id)}> + {Array.isArray(value) ? value.join(', ') : null} + + + ); +} diff --git a/frontend/src/container/ListOfDashboard/SearchFilter/index.tsx b/frontend/src/container/ListOfDashboard/SearchFilter/index.tsx new file mode 100644 index 0000000000..d8acdc2c42 --- /dev/null +++ b/frontend/src/container/ListOfDashboard/SearchFilter/index.tsx @@ -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((state) => state.app); + const [category, setCategory] = useState(); + const [optionsData, setOptionsData] = useState( + OptionsSchemas.attribute, + ); + const selectRef = useRef() as React.MutableRefObject; + const [selectedValues, setSelectedValues] = useState([]); + const [staging, setStaging] = useState([]); + const [queries, setQueries] = useState([]); + + 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 ( + +
+ {map(queries, (query) => ( + + ))} + {map(staging, (value) => ( + + {value as string} + + ))} +
+ {optionsData && ( + + )} + {queries && queries.length > 0 && ( +