From 3e0149c058cd75807bdd9add96fef080e59a1e31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C4=B1dvan=20Akca?= Date: Fri, 10 Mar 2023 16:27:32 +0300 Subject: [PATCH 1/4] feat: add searchable json viewer component --- .../src/components/ExecutionStep/index.tsx | 8 +- .../components/SearchableJSONViewer/index.tsx | 78 +++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/components/SearchableJSONViewer/index.tsx diff --git a/packages/web/src/components/ExecutionStep/index.tsx b/packages/web/src/components/ExecutionStep/index.tsx index 106d03db..3c011fa2 100644 --- a/packages/web/src/components/ExecutionStep/index.tsx +++ b/packages/web/src/components/ExecutionStep/index.tsx @@ -10,7 +10,7 @@ import Box from '@mui/material/Box'; import type { IApp, IExecutionStep, IStep } from '@automatisch/types'; import TabPanel from 'components/TabPanel'; -import JSONViewer from 'components/JSONViewer'; +import SearchableJSONViewer from 'components/SearchableJSONViewer'; import AppIcon from 'components/AppIcon'; import { GET_APPS } from 'graphql/queries/get-apps'; import useFormatMessage from 'hooks/useFormatMessage'; @@ -92,16 +92,16 @@ export default function ExecutionStep( - + - + {hasError && ( - + )} diff --git a/packages/web/src/components/SearchableJSONViewer/index.tsx b/packages/web/src/components/SearchableJSONViewer/index.tsx new file mode 100644 index 00000000..acfcdd79 --- /dev/null +++ b/packages/web/src/components/SearchableJSONViewer/index.tsx @@ -0,0 +1,78 @@ +import * as React from 'react'; +import get from 'lodash/get'; +import set from 'lodash/set'; +import throttle from 'lodash/throttle'; +import { Box } from '@mui/material'; + +import { IJSONObject, IJSONValue } from '@automatisch/types'; +import JSONViewer from 'components/JSONViewer'; +import SearchInput from 'components/SearchInput'; + +type JSONViewerProps = { + data: IJSONObject; +}; + +type Entry = [string, IJSONValue]; + +const SearchableJSONViewer = ({ data }: JSONViewerProps) => { + const [filteredData, setFilteredData] = React.useState(data); + + const allEntries = React.useMemo(() => { + const entries: Entry[] = []; + const collectEntries = (obj: IJSONObject, prefix?: string) => { + for (const key in obj) { + if (typeof obj[key] === 'object' && obj[key] !== null) { + entries.push([[prefix, key].filter(Boolean).join('.'), obj[key]]); + + collectEntries( + obj[key] as IJSONObject, + [prefix, key].filter(Boolean).join('.') + ); + } else { + entries.push([[prefix, key].filter(Boolean).join('.'), obj[key]]); + } + } + }; + + collectEntries(data); + return entries; + }, [data]); + + const onSearchChange = React.useMemo( + () => + throttle((event: React.ChangeEvent) => { + const search = (event.target as HTMLInputElement).value.toLowerCase(); + const newFilteredData: IJSONObject = {}; + + if (!search) { + setFilteredData(data); + return; + } + + allEntries.forEach(([key, value]) => { + if ( + key.toLowerCase().includes(search) || + (typeof value !== 'object' && + value.toString().toLowerCase().includes(search)) + ) { + const value = get(filteredData, key); + set(newFilteredData, key, value); + } + }); + + setFilteredData(newFilteredData); + }, 400), + [allEntries] + ); + + return ( + <> + + + + + + ); +}; + +export default SearchableJSONViewer; From aebfcc38dd722643db575c6eb78bca64f63b6ae7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C4=B1dvan=20Akca?= Date: Fri, 10 Mar 2023 17:58:06 +0300 Subject: [PATCH 2/4] feat(SearchableJSONViewer): cover no result case --- .../components/SearchableJSONViewer/index.tsx | 23 ++++++++++++++----- packages/web/src/locales/en.json | 5 ++-- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/web/src/components/SearchableJSONViewer/index.tsx b/packages/web/src/components/SearchableJSONViewer/index.tsx index acfcdd79..c381c2bd 100644 --- a/packages/web/src/components/SearchableJSONViewer/index.tsx +++ b/packages/web/src/components/SearchableJSONViewer/index.tsx @@ -2,11 +2,13 @@ import * as React from 'react'; import get from 'lodash/get'; import set from 'lodash/set'; import throttle from 'lodash/throttle'; -import { Box } from '@mui/material'; +import isEmpty from 'lodash/isEmpty'; +import { Box, Typography } from '@mui/material'; import { IJSONObject, IJSONValue } from '@automatisch/types'; import JSONViewer from 'components/JSONViewer'; import SearchInput from 'components/SearchInput'; +import useFormatMessage from 'hooks/useFormatMessage'; type JSONViewerProps = { data: IJSONObject; @@ -15,7 +17,10 @@ type JSONViewerProps = { type Entry = [string, IJSONValue]; const SearchableJSONViewer = ({ data }: JSONViewerProps) => { - const [filteredData, setFilteredData] = React.useState(data); + const [filteredData, setFilteredData] = React.useState( + data + ); + const formatMessage = useFormatMessage(); const allEntries = React.useMemo(() => { const entries: Entry[] = []; @@ -23,7 +28,6 @@ const SearchableJSONViewer = ({ data }: JSONViewerProps) => { for (const key in obj) { if (typeof obj[key] === 'object' && obj[key] !== null) { entries.push([[prefix, key].filter(Boolean).join('.'), obj[key]]); - collectEntries( obj[key] as IJSONObject, [prefix, key].filter(Boolean).join('.') @@ -60,17 +64,24 @@ const SearchableJSONViewer = ({ data }: JSONViewerProps) => { } }); - setFilteredData(newFilteredData); + if (isEmpty(newFilteredData)) { + setFilteredData(null); + } else { + setFilteredData(newFilteredData); + } }, 400), [allEntries] ); return ( <> - + - + {filteredData && } + {!filteredData && ( + {formatMessage('jsonViewer.noDataFound')} + )} ); }; diff --git a/packages/web/src/locales/en.json b/packages/web/src/locales/en.json index dee7e9cd..bb127ad5 100644 --- a/packages/web/src/locales/en.json +++ b/packages/web/src/locales/en.json @@ -138,5 +138,6 @@ "resetPasswordForm.confirmPasswordFieldLabel": "Confirm password", "resetPasswordForm.passwordUpdated": "The password has been updated. Now, you can login.", "usageAlert.informationText": "Tasks: {consumedTaskCount}/{allowedTaskCount} (Resets {relativeResetDate})", - "usageAlert.viewPlans": "View plans" -} \ No newline at end of file + "usageAlert.viewPlans": "View plans", + "jsonViewer.noDataFound": "We couldn't find anything matching your search" +} From f7753aa1b41bb00d02981c76acee7ae9f0b9fb48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C4=B1dvan=20Akca?= Date: Thu, 16 Mar 2023 18:38:39 +0300 Subject: [PATCH 3/4] fix(SearchableJSONViewer): remove undefined values from filtered arrays --- .../web/src/components/SearchableJSONViewer/index.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/web/src/components/SearchableJSONViewer/index.tsx b/packages/web/src/components/SearchableJSONViewer/index.tsx index c381c2bd..fdfa9cba 100644 --- a/packages/web/src/components/SearchableJSONViewer/index.tsx +++ b/packages/web/src/components/SearchableJSONViewer/index.tsx @@ -3,6 +3,7 @@ import get from 'lodash/get'; import set from 'lodash/set'; import throttle from 'lodash/throttle'; import isEmpty from 'lodash/isEmpty'; +import toPath from 'lodash/toPath'; import { Box, Typography } from '@mui/material'; import { IJSONObject, IJSONValue } from '@automatisch/types'; @@ -61,6 +62,16 @@ const SearchableJSONViewer = ({ data }: JSONViewerProps) => { ) { const value = get(filteredData, key); set(newFilteredData, key, value); + const keyPath = toPath(key); + const parentKeyPath = keyPath.slice(0, keyPath.length - 1); + const parentKey = parentKeyPath.join('.'); + const parentValue = get(newFilteredData, parentKey); + if (Array.isArray(parentValue)) { + const filteredParentValue = parentValue.filter( + (item) => item !== undefined + ); + set(newFilteredData, parentKey, filteredParentValue); + } } }); From 0a5912eb8e71ecf8c6b0b83537708c2e9c0abffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C4=B1dvan=20Akca?= Date: Sun, 19 Mar 2023 13:23:22 +0300 Subject: [PATCH 4/4] refactor(SearchableJSONViewer): rewrite collecting keys and values of data --- .../components/SearchableJSONViewer/index.tsx | 98 ++++++++++--------- 1 file changed, 53 insertions(+), 45 deletions(-) diff --git a/packages/web/src/components/SearchableJSONViewer/index.tsx b/packages/web/src/components/SearchableJSONViewer/index.tsx index fdfa9cba..13195849 100644 --- a/packages/web/src/components/SearchableJSONViewer/index.tsx +++ b/packages/web/src/components/SearchableJSONViewer/index.tsx @@ -3,10 +3,11 @@ import get from 'lodash/get'; import set from 'lodash/set'; import throttle from 'lodash/throttle'; import isEmpty from 'lodash/isEmpty'; -import toPath from 'lodash/toPath'; +import forIn from 'lodash/forIn'; +import isPlainObject from 'lodash/isPlainObject'; import { Box, Typography } from '@mui/material'; -import { IJSONObject, IJSONValue } from '@automatisch/types'; +import { IJSONObject } from '@automatisch/types'; import JSONViewer from 'components/JSONViewer'; import SearchInput from 'components/SearchInput'; import useFormatMessage from 'hooks/useFormatMessage'; @@ -15,7 +16,54 @@ type JSONViewerProps = { data: IJSONObject; }; -type Entry = [string, IJSONValue]; +function aggregate( + data: any, + searchTerm: string, + result = {}, + prefix: string[] = [], + withinArray = false +) { + if (withinArray) { + const containerValue = get(result, prefix, []); + + result = aggregate( + data, + searchTerm, + result, + prefix.concat(containerValue.length.toString()) + ); + + return result; + } + + if (isPlainObject(data)) { + forIn(data, (value, key) => { + const fullKey = [...prefix, key]; + + if (key.toLowerCase().includes(searchTerm)) { + set(result, fullKey, value); + return; + } + + result = aggregate(value, searchTerm, result, fullKey); + }); + } + + if (Array.isArray(data)) { + forIn(data, (value) => { + result = aggregate(value, searchTerm, result, prefix, true); + }); + } + + if ( + ['string', 'number'].includes(typeof data) && + String(data).toLowerCase().includes(searchTerm) + ) { + set(result, prefix, data); + } + + return result; +} const SearchableJSONViewer = ({ data }: JSONViewerProps) => { const [filteredData, setFilteredData] = React.useState( @@ -23,57 +71,17 @@ const SearchableJSONViewer = ({ data }: JSONViewerProps) => { ); const formatMessage = useFormatMessage(); - const allEntries = React.useMemo(() => { - const entries: Entry[] = []; - const collectEntries = (obj: IJSONObject, prefix?: string) => { - for (const key in obj) { - if (typeof obj[key] === 'object' && obj[key] !== null) { - entries.push([[prefix, key].filter(Boolean).join('.'), obj[key]]); - collectEntries( - obj[key] as IJSONObject, - [prefix, key].filter(Boolean).join('.') - ); - } else { - entries.push([[prefix, key].filter(Boolean).join('.'), obj[key]]); - } - } - }; - - collectEntries(data); - return entries; - }, [data]); - const onSearchChange = React.useMemo( () => throttle((event: React.ChangeEvent) => { const search = (event.target as HTMLInputElement).value.toLowerCase(); - const newFilteredData: IJSONObject = {}; if (!search) { setFilteredData(data); return; } - allEntries.forEach(([key, value]) => { - if ( - key.toLowerCase().includes(search) || - (typeof value !== 'object' && - value.toString().toLowerCase().includes(search)) - ) { - const value = get(filteredData, key); - set(newFilteredData, key, value); - const keyPath = toPath(key); - const parentKeyPath = keyPath.slice(0, keyPath.length - 1); - const parentKey = parentKeyPath.join('.'); - const parentValue = get(newFilteredData, parentKey); - if (Array.isArray(parentValue)) { - const filteredParentValue = parentValue.filter( - (item) => item !== undefined - ); - set(newFilteredData, parentKey, filteredParentValue); - } - } - }); + const newFilteredData = aggregate(data, search); if (isEmpty(newFilteredData)) { setFilteredData(null); @@ -81,7 +89,7 @@ const SearchableJSONViewer = ({ data }: JSONViewerProps) => { setFilteredData(newFilteredData); } }, 400), - [allEntries] + [data] ); return (