1
0
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:
David Leek 2024-06-11 14:27:59 +02:00 committed by GitHub
parent 2a616c28bc
commit 576dd04dc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 406 additions and 0 deletions

View 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>
);
};

View File

@ -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>
</>
);
};

View File

@ -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={

View 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,
};
};

View File

@ -54,6 +54,7 @@ process.nextTick(async () => {
manyStrategiesPagination: true,
enableLegacyVariants: false,
flagCreator: true,
commandBarUI: true,
},
},
authentication: {