Add SearchPanel component and subcomponents, use it in StateTab

This commit is contained in:
Zsofia Vagi 2023-06-16 17:52:25 +02:00 committed by Zsofia Vagi
parent 2097b49ffe
commit 7a10825e16
5 changed files with 405 additions and 14 deletions

View File

@ -0,0 +1,45 @@
import { StylingFunction } from 'react-base16-styling';
import React, { ReactElement } from 'react';
export const BUTTON_DIRECTION = {
LEFT: 'left',
RIGHT: 'right',
} as const;
interface JumpSearchResultButtonProps {
buttonDirection: string;
buttonDisabled: boolean;
styling: StylingFunction;
jumpToNewResult: () => void;
}
function JumpSearchResultButton({
buttonDirection,
buttonDisabled,
styling,
jumpToNewResult,
}: JumpSearchResultButtonProps): ReactElement {
return (
<button
{...styling('jumpResultButton')}
onClick={() => jumpToNewResult()}
disabled={buttonDisabled}
>
<svg
{...styling('jumpResultButtonArrow')}
viewBox="4 0 14 18"
preserveAspectRatio="xMidYMid meet"
>
<g>
{buttonDirection === BUTTON_DIRECTION.LEFT ? (
<path d="M15.41 16.09l-4.58-4.59 4.58-4.59-1.41-1.41-6 6 6 6z" />
) : (
<path d="M8.59 16.34l4.58-4.59-4.58-4.59 1.41-1.41 6 6-6 6z" />
)}
</g>
</svg>
</button>
);
}
export default JumpSearchResultButton;

View File

@ -0,0 +1,30 @@
import React, { ReactElement } from 'react';
import { StylingFunction } from 'react-base16-styling';
export interface SearchBarProps {
onChange: (s: string) => void;
text: string;
className?: string;
styling: StylingFunction;
}
function SearchBar({
onChange,
text,
className,
styling,
}: SearchBarProps): ReactElement {
return (
<div className={`search-bar ${className || ''}`}>
<input
{...styling('searchInput')}
placeholder={'Search'}
value={text}
type={'text'}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export default SearchBar;

View File

@ -0,0 +1,161 @@
import React, { ReactElement, useEffect, useState } from 'react';
import { StylingFunction } from 'react-base16-styling';
import { searchInObject } from '../utils/objectSearch';
import { Value } from '../utils/searchWorker';
import JumpSearchResultButton, {
BUTTON_DIRECTION,
} from './JumpSearchResultButton';
import SearchBar from './SearchBar';
export interface SearchQuery {
queryText: string;
location: {
keys: boolean;
values: boolean;
};
}
const INITIAL_QUERY: SearchQuery = {
queryText: '',
location: {
keys: true,
values: true,
},
};
export interface SearchPanelProps {
state: Value;
onSubmit: (result: {
searchResult: string[];
searchInProgress: boolean;
}) => void;
onReset: () => void;
styling: StylingFunction;
}
type SearchStatus = 'done' | 'pending' | 'unset';
function SearchPanel({
onSubmit,
onReset,
styling,
state,
}: SearchPanelProps): ReactElement {
const [query, setQuery] = useState<SearchQuery>(INITIAL_QUERY);
const [searchStatus, setSearchStatus] = useState<SearchStatus>('unset');
const [results, setResults] = useState<string[][] | undefined>(undefined);
const [resultIndex, setResultIndex] = useState(0);
async function handleSubmit() {
setSearchStatus('pending');
const result = await searchInObject(state, query);
setResults(result.map((r) => r.split('.')));
setResultIndex(0);
setSearchStatus('done');
}
function reset() {
setQuery(INITIAL_QUERY);
setSearchStatus('unset');
setResults(undefined);
setResultIndex(0);
onReset();
}
useEffect(() => {
results &&
onSubmit({ searchResult: results[0] || [], searchInProgress: true });
}, [results, onSubmit]);
return (
<div className={'search-panel'} {...styling('searchPanel')}>
<SearchBar
text={query.queryText}
onChange={(text: string) => setQuery({ ...query, queryText: text })}
styling={styling}
/>
<div>
<input
{...styling('searchPanelParameterSelection')}
type={'checkbox'}
checked={query.location.keys}
onChange={(event) =>
setQuery({
...query,
location: { ...query.location, keys: event.target.checked },
})
}
/>
<span>Keys</span>
</div>
<div>
<input
{...styling('searchPanelParameterSelection')}
type={'checkbox'}
checked={query.location.values}
onChange={(event) =>
setQuery({
...query,
location: { ...query.location, values: event.target.checked },
})
}
/>
<span>Values</span>
</div>
<button
{...styling('searchButton')}
onClick={() => handleSubmit()}
disabled={
(!query.location.keys && !query.location.values) || !query.queryText
}
>
Go
</button>
{searchStatus === 'pending' && 'Searching...'}
{searchStatus === 'done' && (
<>
<JumpSearchResultButton
buttonDirection={BUTTON_DIRECTION.LEFT}
buttonDisabled={!results || results.length < 2}
styling={styling}
jumpToNewResult={() => {
if (!results) {
return;
}
const newIndex =
resultIndex - 1 < 0 ? results.length - 1 : resultIndex - 1;
setResultIndex(newIndex);
onSubmit({
searchResult: results[newIndex] || [],
searchInProgress: true,
});
}}
/>
<JumpSearchResultButton
buttonDirection={BUTTON_DIRECTION.RIGHT}
buttonDisabled={!results || results.length < 2}
styling={styling}
jumpToNewResult={() => {
if (!results) {
return;
}
const newIndex = (resultIndex + 1) % results.length || 0;
setResultIndex(newIndex);
onSubmit({
searchResult: results[newIndex] || [],
searchInProgress: true,
});
}}
/>
{results &&
`${results.length ? resultIndex + 1 : 0}/${results.length}`}
<button {...styling('searchButton')} onClick={() => reset()}>
Reset
</button>
</>
)}
</div>
);
}
export default SearchPanel;

View File

@ -1,10 +1,11 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { JSONTree } from 'react-json-tree';
import { Action } from 'redux';
import getItemString from './getItemString';
import getJsonTreeTheme from './getJsonTreeTheme';
import { TabComponentProps } from '../ActionPreview';
import SearchPanel from '../searchPanel/SearchPanel';
const StateTab: React.FunctionComponent<
TabComponentProps<any, Action<unknown>>
@ -18,9 +19,52 @@ const StateTab: React.FunctionComponent<
isWideLayout,
sortStateTreeAlphabetically,
disableStateTreeCollection,
}) => (
}) => {
interface SearchState {
searchResult: string[];
searchInProgress: boolean;
}
const [searchState, setSearchState] = useState<SearchState>({
searchResult: [],
searchInProgress: false,
});
const displayedResult = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (searchState.searchInProgress && displayedResult.current) {
displayedResult.current.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center',
});
setSearchState({ ...searchState, searchInProgress: false });
}
}, [searchState, setSearchState]);
return (
<>
<SearchPanel
onSubmit={setSearchState}
onReset={() =>
setSearchState({
searchResult: [],
searchInProgress: false,
})
}
styling={styling}
state={nextState}
/>
<JSONTree
labelRenderer={labelRenderer}
labelRenderer={(keyPath, nodeType, expanded, expandable) => {
return isMatch(searchState.searchResult, [...keyPath].reverse()) ? (
<span {...styling('queryResultLabel')} ref={displayedResult}>
{labelRenderer(keyPath, nodeType, expanded, expandable)}
</span>
) : (
labelRenderer(keyPath, nodeType, expanded, expandable)
);
}}
theme={getJsonTreeTheme(base16Theme)}
data={nextState}
getItemString={(type, data) =>
@ -30,8 +74,28 @@ const StateTab: React.FunctionComponent<
hideRoot
sortObjectKeys={sortStateTreeAlphabetically}
{...(disableStateTreeCollection ? { collectionLimit: 0 } : {})}
isSearchInProgress={searchState.searchInProgress}
searchResultPath={searchState.searchResult}
valueRenderer={(raw, value, ...keyPath) => {
return isMatch(searchState.searchResult, [...keyPath].reverse()) ? (
<span {...styling('queryResult')} ref={displayedResult}>
{raw as string}
</span>
) : (
<span>{raw as string}</span>
);
}}
/>
);
</>
);
};
const isMatch = (resultPath: string[], nodePath: (string | number)[]) => {
return (
resultPath.length === nodePath.length &&
resultPath.every((result, index) => result === nodePath[index].toString())
);
};
StateTab.propTypes = {
nextState: PropTypes.any.isRequired,

View File

@ -17,6 +17,7 @@ const colorMap = (theme: Base16Theme) => ({
SELECTED_BACKGROUND_COLOR: rgba(theme.base03, 20),
SKIPPED_BACKGROUND_COLOR: rgba(theme.base03, 10),
HEADER_BACKGROUND_COLOR: rgba(theme.base03, 30),
HEADER_BACKGROUND_COLOR_OPAQUE: rgba(theme.base03, 100),
HEADER_BORDER_COLOR: rgba(theme.base03, 20),
BORDER_COLOR: rgba(theme.base03, 50),
LIST_BORDER_COLOR: rgba(theme.base03, 50),
@ -34,6 +35,7 @@ const colorMap = (theme: Base16Theme) => ({
LINK_COLOR: rgba(theme.base0E, 90),
LINK_HOVER_COLOR: theme.base0E,
ERROR_COLOR: theme.base08,
SEARCH_BUTTON_COLOR: rgba(theme.base00, 50),
});
type Color = keyof ReturnType<typeof colorMap>;
@ -267,6 +269,95 @@ const getSheetFromColorMap = (map: ColorMap) => ({
'border-bottom-color': map.HEADER_BORDER_COLOR,
},
searchPanel: {
display: 'flex',
position: 'sticky',
top: 0,
width: '100%',
'z-index': 1,
height: '2em',
gap: '1em',
padding: '5px 10px',
'align-items': 'center',
'border-bottom-width': '1px',
'border-bottom-style': 'solid',
'background-color': map.HEADER_BACKGROUND_COLOR_OPAQUE,
'border-bottom-color': map.HEADER_BORDER_COLOR,
},
searchForm: {
display: 'flex',
gap: '1em',
'align-items': 'center',
},
searchPanelParameterSelection: {
display: 'inline-grid',
color: map.SEARCH_BUTTON_COLOR,
width: '1.15em',
height: '1.15em',
border: '0.15em solid currentColor',
'border-radius': '0.15em',
transform: 'translateY(-0.075em)',
'place-content': 'center',
appearance: 'none',
'background-color': map.SEARCH_BUTTON_COLOR,
'&::before': {
'background-color': map.TEXT_COLOR,
'transform-origin': 'bottom left',
'clip-path':
'polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%)',
content: '""',
width: '0.65em',
height: '0.65em',
transform: 'scale(0)',
transition: '120ms transform ease-in-out',
},
'&:checked::before': {
transform: 'scale(1)',
},
},
searchInput: {
'background-color': 'white',
opacity: 0.9,
'border-color': 'transparent',
'border-radius': '4px',
height: '1.2rem',
},
searchButton: {
'background-color': map.SEARCH_BUTTON_COLOR,
'border-color': 'transparent',
'border-radius': '4px',
height: '1.56rem',
color: map.TEXT_COLOR,
},
jumpResultButton: {
'background-color': map.SEARCH_BUTTON_COLOR,
'border-color': 'transparent',
'border-radius': '4px',
},
jumpResultButtonArrow: {
cursor: 'hand',
fill: map.TEXT_COLOR,
width: '0.6rem',
height: '1rem',
},
queryResult: {
'background-color': '#FFFF00',
},
queryResultLabel: {
'background-color': '#FFFF00',
'text-indent': 0,
},
tabSelector: {
position: 'relative',
display: 'inline-flex',