mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-25 00:07:47 +01:00
feat: command bar poc (#7350)
Command bar PoC using clone of search --------- Co-authored-by: sjaanus <sellinjaanus@gmail.com>
This commit is contained in:
parent
2a616c28bc
commit
576dd04dc5
238
frontend/src/component/commandBar/CommandBar.tsx
Normal file
238
frontend/src/component/commandBar/CommandBar.tsx
Normal file
@ -0,0 +1,238 @@
|
||||
import type React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
InputBase,
|
||||
Paper,
|
||||
styled,
|
||||
Tooltip,
|
||||
} from '@mui/material';
|
||||
import Close from '@mui/icons-material/Close';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import type { IGetSearchContextOutput } from 'hooks/useSearch';
|
||||
import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut';
|
||||
import { SEARCH_INPUT } from 'utils/testIds';
|
||||
import { useOnClickOutside } from 'hooks/useOnClickOutside';
|
||||
import { useOnBlur } from 'hooks/useOnBlur';
|
||||
import { RecentlyVisited } from './RecentlyVisited/RecentlyVisited';
|
||||
import { useRecentlyVisited } from 'hooks/useRecentlyVisited';
|
||||
|
||||
interface ICommandBarProps {
|
||||
initialValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
hasFilters?: boolean;
|
||||
disabled?: boolean;
|
||||
getSearchContext?: () => IGetSearchContextOutput;
|
||||
containerStyles?: React.CSSProperties;
|
||||
debounceTime?: number;
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
export const CommandResultsPaper = styled(Paper)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
width: '100%',
|
||||
left: 0,
|
||||
top: '20px',
|
||||
zIndex: 2,
|
||||
padding: theme.spacing(4, 1.5, 1.5),
|
||||
borderBottomLeftRadius: theme.spacing(1),
|
||||
borderBottomRightRadius: theme.spacing(1),
|
||||
boxShadow: '0px 8px 20px rgba(33, 33, 33, 0.15)',
|
||||
fontSize: theme.fontSizes.smallBody,
|
||||
color: theme.palette.text.secondary,
|
||||
wordBreak: 'break-word',
|
||||
}));
|
||||
|
||||
const StyledContainer = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'active',
|
||||
})<{
|
||||
active: boolean | undefined;
|
||||
}>(({ theme, active }) => ({
|
||||
display: 'flex',
|
||||
flexGrow: 1,
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
maxWidth: active ? '100%' : '400px',
|
||||
[theme.breakpoints.down('md')]: {
|
||||
marginTop: theme.spacing(1),
|
||||
maxWidth: '100%',
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledSearch = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: theme.palette.background.elevation1,
|
||||
border: `1px solid ${theme.palette.neutral.border}`,
|
||||
borderRadius: theme.shape.borderRadiusExtraLarge,
|
||||
padding: '3px 5px 3px 12px',
|
||||
width: '100%',
|
||||
zIndex: 3,
|
||||
'&:focus-within': {
|
||||
borderColor: theme.palette.primary.main,
|
||||
boxShadow: theme.boxShadows.main,
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledInputBase = styled(InputBase)(({ theme }) => ({
|
||||
width: '100%',
|
||||
backgroundColor: theme.palette.background.elevation1,
|
||||
}));
|
||||
|
||||
const StyledClose = styled(Close)(({ theme }) => ({
|
||||
color: theme.palette.neutral.main,
|
||||
fontSize: theme.typography.body1.fontSize,
|
||||
}));
|
||||
|
||||
export const CommandBar = ({
|
||||
initialValue = '',
|
||||
onChange,
|
||||
onFocus,
|
||||
onBlur,
|
||||
className,
|
||||
placeholder: customPlaceholder,
|
||||
hasFilters,
|
||||
disabled,
|
||||
getSearchContext,
|
||||
containerStyles,
|
||||
expandable = false,
|
||||
debounceTime = 200,
|
||||
...rest
|
||||
}: ICommandBarProps) => {
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchContainerRef = useRef<HTMLInputElement>(null);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const { lastVisited } = useRecentlyVisited();
|
||||
const hideSuggestions = () => {
|
||||
setShowSuggestions(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
//const { savedQuery, setSavedQuery } = useSavedQuery(id);
|
||||
|
||||
const [value, setValue] = useState<string>(initialValue);
|
||||
|
||||
//const debouncedOnChange = useAsyncDebounce(onChange, debounceTime);
|
||||
|
||||
const onSearchChange = (value: string) => {
|
||||
//debouncedOnChange(value);
|
||||
setValue(value);
|
||||
//setSavedQuery(value);
|
||||
};
|
||||
|
||||
const hotkey = useKeyboardShortcut(
|
||||
{
|
||||
modifiers: ['ctrl'],
|
||||
key: 'k',
|
||||
preventDefault: true,
|
||||
},
|
||||
() => {
|
||||
if (document.activeElement === searchInputRef.current) {
|
||||
searchInputRef.current?.blur();
|
||||
} else {
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
},
|
||||
);
|
||||
useKeyboardShortcut({ key: 'Escape' }, () => {
|
||||
if (searchContainerRef.current?.contains(document.activeElement)) {
|
||||
searchInputRef.current?.blur();
|
||||
}
|
||||
});
|
||||
const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`;
|
||||
|
||||
useOnClickOutside([searchContainerRef], hideSuggestions);
|
||||
useOnBlur(searchContainerRef, hideSuggestions);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
ref={searchContainerRef}
|
||||
style={containerStyles}
|
||||
active={expandable && showSuggestions}
|
||||
{...rest}
|
||||
>
|
||||
<StyledSearch className={className}>
|
||||
<SearchIcon
|
||||
sx={{
|
||||
mr: 1,
|
||||
color: (theme) => theme.palette.action.disabled,
|
||||
}}
|
||||
/>
|
||||
<StyledInputBase
|
||||
inputRef={searchInputRef}
|
||||
placeholder={placeholder}
|
||||
inputProps={{
|
||||
'aria-label': placeholder,
|
||||
'data-testid': SEARCH_INPUT,
|
||||
}}
|
||||
value={value}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
onFocus={() => {
|
||||
setShowSuggestions(true);
|
||||
onFocus?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Box sx={{ width: (theme) => theme.spacing(4) }}>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(value)}
|
||||
show={
|
||||
<Tooltip title='Clear search query' arrow>
|
||||
<IconButton
|
||||
size='small'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // prevent outside click from the lazily added element
|
||||
onSearchChange('');
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
sx={{
|
||||
padding: (theme) => theme.spacing(1),
|
||||
}}
|
||||
>
|
||||
<StyledClose />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</StyledSearch>
|
||||
|
||||
<ConditionallyRender
|
||||
condition={
|
||||
Boolean(hasFilters && getSearchContext) && showSuggestions
|
||||
}
|
||||
show={
|
||||
/*
|
||||
<SearchSuggestions
|
||||
onSuggestion={(suggestion) => {
|
||||
onSearchChange(suggestion);
|
||||
searchInputRef.current?.focus();
|
||||
}}
|
||||
savedQuery={savedQuery}
|
||||
getSearchContext={getSearchContext!}
|
||||
/>
|
||||
*/
|
||||
<>yousearched</>
|
||||
}
|
||||
elseShow={
|
||||
showSuggestions && (
|
||||
<CommandResultsPaper className='dropdown-outline'>
|
||||
<RecentlyVisited lastVisited={lastVisited} />
|
||||
</CommandResultsPaper>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
@ -0,0 +1,64 @@
|
||||
import {
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
styled,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { IconRenderer } from 'component/layout/MainLayout/NavigationSidebar/IconRenderer';
|
||||
import type { LastViewedPage } from 'hooks/useRecentlyVisited';
|
||||
import type { Theme } from '@mui/material/styles/createTheme';
|
||||
|
||||
const listItemButtonStyle = (theme: Theme) => ({
|
||||
borderRadius: theme.spacing(0.5),
|
||||
borderLeft: `${theme.spacing(0.5)} solid transparent`,
|
||||
'&.Mui-selected': {
|
||||
borderLeft: `${theme.spacing(0.5)} solid ${theme.palette.primary.main}`,
|
||||
},
|
||||
});
|
||||
|
||||
const StyledTypography = styled(Typography)(({ theme }) => ({
|
||||
fontSize: theme.fontSizes.bodySize,
|
||||
padding: theme.spacing(0, 3),
|
||||
}));
|
||||
|
||||
const StyledListItemIcon = styled(ListItemIcon)(({ theme }) => ({
|
||||
minWidth: theme.spacing(4),
|
||||
margin: theme.spacing(0.25, 0),
|
||||
}));
|
||||
|
||||
const StyledListItemText = styled(ListItemText)(({ theme }) => ({
|
||||
margin: 0,
|
||||
}));
|
||||
|
||||
export const RecentlyVisited = ({
|
||||
lastVisited,
|
||||
}: { lastVisited: LastViewedPage[] }) => {
|
||||
return (
|
||||
<>
|
||||
<StyledTypography color='textSecondary'>
|
||||
Recently visited
|
||||
</StyledTypography>
|
||||
<List>
|
||||
{lastVisited.map((item, index) => (
|
||||
<ListItemButton
|
||||
key={`recently-visited-${index}`}
|
||||
dense={true}
|
||||
component={Link}
|
||||
to={item.pathName ?? '/default'}
|
||||
sx={listItemButtonStyle}
|
||||
>
|
||||
<StyledListItemIcon>
|
||||
<IconRenderer path={item.pathName ?? '/default'} />
|
||||
</StyledListItemIcon>
|
||||
<StyledListItemText>
|
||||
<Typography>{item.pathName}</Typography>
|
||||
</StyledListItemText>
|
||||
</ListItemButton>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
@ -32,6 +32,7 @@ import { Notifications } from 'component/common/Notifications/Notifications';
|
||||
import { useAdminRoutes } from 'component/admin/useAdminRoutes';
|
||||
import InviteLinkButton from './InviteLink/InviteLinkButton/InviteLinkButton';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
import { CommandBar } from 'component/commandBar/CommandBar';
|
||||
|
||||
const HeaderComponent = styled(AppBar)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
@ -119,6 +120,7 @@ const Header: VFC = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
const disableNotifications = useUiFlag('disableNotifications');
|
||||
const commandBarUI = useUiFlag('commandBarUI');
|
||||
const { uiConfig, isOss } = useUiConfig();
|
||||
const smallScreen = useMediaQuery(theme.breakpoints.down('lg'));
|
||||
const [openDrawer, setOpenDrawer] = useState(false);
|
||||
@ -197,6 +199,7 @@ const Header: VFC = () => {
|
||||
|
||||
<StyledNav>
|
||||
<StyledUserContainer>
|
||||
{commandBarUI && <CommandBar />}
|
||||
<InviteLinkButton />
|
||||
<Tooltip
|
||||
title={
|
||||
|
100
frontend/src/hooks/useRecentlyVisited.ts
Normal file
100
frontend/src/hooks/useRecentlyVisited.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useLocation, useMatch } from 'react-router-dom';
|
||||
import { routes } from 'component/menu/routes';
|
||||
import { getLocalStorageItem, setLocalStorageItem } from '../utils/storage';
|
||||
import { basePath } from 'utils/formatPath';
|
||||
import { useCustomEvent } from './useCustomEvent';
|
||||
|
||||
const RECENTLY_VISITED_PAGES_UPDATED_EVENT = 'recentlyVisitedPagesUpdated';
|
||||
const MAX_ITEMS = 5;
|
||||
|
||||
export type LastViewedPage = {
|
||||
featureId?: string;
|
||||
projectId?: string;
|
||||
pathName?: string;
|
||||
};
|
||||
|
||||
const localStorageItems = (key: string): LastViewedPage[] => {
|
||||
return getLocalStorageItem(key) || [];
|
||||
};
|
||||
|
||||
export const useRecentlyVisited = () => {
|
||||
const key = `${basePath}:unleash-lastVisitedPages`;
|
||||
const featureMatch = useMatch('/projects/:projectId/features/:featureId');
|
||||
const projectMatch = useMatch('/projects/:projectId');
|
||||
|
||||
const [lastVisited, setLastVisited] = useState<LastViewedPage[]>(
|
||||
localStorageItems(key),
|
||||
);
|
||||
|
||||
const { emitEvent } = useCustomEvent(
|
||||
RECENTLY_VISITED_PAGES_UPDATED_EVENT,
|
||||
() => {
|
||||
setLastVisited(localStorageItems(key));
|
||||
},
|
||||
);
|
||||
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (!location.pathname) return;
|
||||
|
||||
const path = routes.find((r) => r.path === location.pathname);
|
||||
if (path) {
|
||||
setCappedLastVisited({ pathName: path.path });
|
||||
} else if (featureMatch?.params.featureId) {
|
||||
setCappedLastVisited({
|
||||
featureId: featureMatch?.params.featureId,
|
||||
projectId: featureMatch?.params.projectId,
|
||||
});
|
||||
} else if (projectMatch?.params.projectId) {
|
||||
setCappedLastVisited({
|
||||
projectId: projectMatch?.params.projectId,
|
||||
});
|
||||
}
|
||||
}, [location, featureMatch, projectMatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastVisited) {
|
||||
setLocalStorageItem(key, lastVisited);
|
||||
emitEvent();
|
||||
}
|
||||
}, [JSON.stringify(lastVisited), key, emitEvent]);
|
||||
|
||||
const pageEquals = (existing: LastViewedPage, page: LastViewedPage) => {
|
||||
if (existing.featureId && existing.featureId === page.featureId)
|
||||
return true;
|
||||
if (existing.pathName && existing.pathName === page.pathName)
|
||||
return true;
|
||||
if (
|
||||
existing.projectId &&
|
||||
!existing.featureId &&
|
||||
!page.featureId &&
|
||||
existing.projectId === page.projectId
|
||||
)
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const setCappedLastVisited = useCallback(
|
||||
(page: LastViewedPage) => {
|
||||
if (page.featureId && !page.projectId) return;
|
||||
const filtered = lastVisited.filter(
|
||||
(item) => !pageEquals(item, page),
|
||||
);
|
||||
const updatedLastVisited = [page, ...filtered];
|
||||
|
||||
const sliced =
|
||||
updatedLastVisited.length > MAX_ITEMS
|
||||
? updatedLastVisited.slice(0, MAX_ITEMS)
|
||||
: updatedLastVisited;
|
||||
setLastVisited(sliced);
|
||||
},
|
||||
[JSON.stringify(lastVisited)],
|
||||
);
|
||||
|
||||
return {
|
||||
lastVisited,
|
||||
setLastVisited: setCappedLastVisited,
|
||||
};
|
||||
};
|
@ -54,6 +54,7 @@ process.nextTick(async () => {
|
||||
manyStrategiesPagination: true,
|
||||
enableLegacyVariants: false,
|
||||
flagCreator: true,
|
||||
commandBarUI: true,
|
||||
},
|
||||
},
|
||||
authentication: {
|
||||
|
Loading…
Reference in New Issue
Block a user