diff --git a/frontend/src/component/commandBar/CommandBar.tsx b/frontend/src/component/commandBar/CommandBar.tsx new file mode 100644 index 0000000000..4dcb94ca8c --- /dev/null +++ b/frontend/src/component/commandBar/CommandBar.tsx @@ -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(null); + const searchContainerRef = useRef(null); + const [showSuggestions, setShowSuggestions] = useState(false); + const { lastVisited } = useRecentlyVisited(); + const hideSuggestions = () => { + setShowSuggestions(false); + onBlur?.(); + }; + + //const { savedQuery, setSavedQuery } = useSavedQuery(id); + + const [value, setValue] = useState(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 ( + + + theme.palette.action.disabled, + }} + /> + onSearchChange(e.target.value)} + onFocus={() => { + setShowSuggestions(true); + onFocus?.(); + }} + disabled={disabled} + /> + theme.spacing(4) }}> + + { + e.stopPropagation(); // prevent outside click from the lazily added element + onSearchChange(''); + searchInputRef.current?.focus(); + }} + sx={{ + padding: (theme) => theme.spacing(1), + }} + > + + + + } + /> + + + + { + onSearchChange(suggestion); + searchInputRef.current?.focus(); + }} + savedQuery={savedQuery} + getSearchContext={getSearchContext!} + /> + */ + <>yousearched + } + elseShow={ + showSuggestions && ( + + + + ) + } + /> + + ); +}; diff --git a/frontend/src/component/commandBar/RecentlyVisited/RecentlyVisited.tsx b/frontend/src/component/commandBar/RecentlyVisited/RecentlyVisited.tsx new file mode 100644 index 0000000000..5eee8da944 --- /dev/null +++ b/frontend/src/component/commandBar/RecentlyVisited/RecentlyVisited.tsx @@ -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 ( + <> + + Recently visited + + + {lastVisited.map((item, index) => ( + + + + + + {item.pathName} + + + ))} + + + ); +}; diff --git a/frontend/src/component/menu/Header/Header.tsx b/frontend/src/component/menu/Header/Header.tsx index d4d68fd313..7433be8b88 100644 --- a/frontend/src/component/menu/Header/Header.tsx +++ b/frontend/src/component/menu/Header/Header.tsx @@ -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 = () => { + {commandBarUI && } { + 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( + 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, + }; +}; diff --git a/src/server-dev.ts b/src/server-dev.ts index 789163bd44..a1187a3d78 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -54,6 +54,7 @@ process.nextTick(async () => { manyStrategiesPagination: true, enableLegacyVariants: false, flagCreator: true, + commandBarUI: true, }, }, authentication: {